2021-12-08 21:45:42 +01:00
# This file is part of Radicale - CalDAV and CardDAV server
2018-08-28 16:19:36 +02:00
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
2024-06-18 08:24:04 +02:00
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
2025-02-10 19:34:13 +01:00
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
2018-08-28 16:19:36 +02:00
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
2020-01-12 23:32:28 +01:00
"""
Helper functions for HTTP .
"""
2021-07-26 20:56:46 +02:00
import contextlib
2022-01-18 18:20:16 +01:00
import os
2022-04-04 18:17:01 +02:00
import pathlib
import sys
2022-01-18 18:20:16 +01:00
import time
2018-08-28 16:19:36 +02:00
from http import client
2022-04-04 18:17:01 +02:00
from typing import List , Mapping , Union , cast
2018-08-28 16:19:36 +02:00
2022-01-18 18:20:16 +01:00
from radicale import config , pathutils , types
2020-09-14 21:19:48 +02:00
from radicale . log import logger
2022-04-04 18:17:01 +02:00
if sys . version_info < ( 3 , 9 ) :
import pkg_resources
_TRAVERSABLE_LIKE_TYPE = pathlib . Path
else :
import importlib . abc
from importlib import resources
2024-12-06 06:13:50 +01:00
if sys . version_info < ( 3 , 13 ) :
_TRAVERSABLE_LIKE_TYPE = Union [ importlib . abc . Traversable , pathlib . Path ]
else :
_TRAVERSABLE_LIKE_TYPE = Union [ importlib . resources . abc . Traversable , pathlib . Path ]
2022-04-04 18:17:01 +02:00
2021-07-26 20:56:46 +02:00
NOT_ALLOWED : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . FORBIDDEN , ( ( " Content-Type " , " text/plain " ) , ) ,
" Access to the requested resource forbidden. " )
2021-07-26 20:56:46 +02:00
FORBIDDEN : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . FORBIDDEN , ( ( " Content-Type " , " text/plain " ) , ) ,
" Action on the requested resource refused. " )
2021-07-26 20:56:46 +02:00
BAD_REQUEST : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . BAD_REQUEST , ( ( " Content-Type " , " text/plain " ) , ) , " Bad Request " )
2021-07-26 20:56:46 +02:00
NOT_FOUND : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . NOT_FOUND , ( ( " Content-Type " , " text/plain " ) , ) ,
" The requested resource could not be found. " )
2021-07-26 20:56:46 +02:00
CONFLICT : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . CONFLICT , ( ( " Content-Type " , " text/plain " ) , ) ,
" Conflict in the request. " )
2021-07-26 20:56:46 +02:00
METHOD_NOT_ALLOWED : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . METHOD_NOT_ALLOWED , ( ( " Content-Type " , " text/plain " ) , ) ,
" The method is not allowed on the requested resource. " )
2021-07-26 20:56:46 +02:00
PRECONDITION_FAILED : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . PRECONDITION_FAILED ,
( ( " Content-Type " , " text/plain " ) , ) , " Precondition failed. " )
2021-07-26 20:56:46 +02:00
REQUEST_TIMEOUT : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . REQUEST_TIMEOUT , ( ( " Content-Type " , " text/plain " ) , ) ,
" Connection timed out. " )
2021-07-26 20:56:46 +02:00
REQUEST_ENTITY_TOO_LARGE : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . REQUEST_ENTITY_TOO_LARGE , ( ( " Content-Type " , " text/plain " ) , ) ,
" Request body too large. " )
2021-07-26 20:56:46 +02:00
REMOTE_DESTINATION : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . BAD_GATEWAY , ( ( " Content-Type " , " text/plain " ) , ) ,
" Remote destination not supported. " )
2021-07-26 20:56:46 +02:00
DIRECTORY_LISTING : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . FORBIDDEN , ( ( " Content-Type " , " text/plain " ) , ) ,
" Directory listings are not supported. " )
2025-02-10 19:34:29 +01:00
INSUFFICIENT_STORAGE : types . WSGIResponse = (
client . INSUFFICIENT_STORAGE , ( ( " Content-Type " , " text/plain " ) , ) ,
" Insufficient Storage. Please contact the administrator. " )
2021-07-26 20:56:46 +02:00
INTERNAL_SERVER_ERROR : types . WSGIResponse = (
2018-08-28 16:19:36 +02:00
client . INTERNAL_SERVER_ERROR , ( ( " Content-Type " , " text/plain " ) , ) ,
" A server error occurred. Please contact the administrator. " )
2021-07-26 20:56:46 +02:00
DAV_HEADERS : str = " 1, 2, 3, calendar-access, addressbook, extended-mkcol "
2020-09-14 21:19:48 +02:00
2022-01-18 18:20:16 +01:00
MIMETYPES : Mapping [ str , str ] = {
" .css " : " text/css " ,
" .eot " : " application/vnd.ms-fontobject " ,
" .gif " : " image/gif " ,
" .html " : " text/html " ,
" .js " : " application/javascript " ,
" .manifest " : " text/cache-manifest " ,
" .png " : " image/png " ,
" .svg " : " image/svg+xml " ,
" .ttf " : " application/font-sfnt " ,
" .txt " : " text/plain " ,
" .woff " : " application/font-woff " ,
" .woff2 " : " font/woff2 " ,
" .xml " : " text/xml " }
FALLBACK_MIMETYPE : str = " application/octet-stream "
2020-09-14 21:19:48 +02:00
2021-07-26 20:56:46 +02:00
def decode_request ( configuration : " config.Configuration " ,
environ : types . WSGIEnviron , text : bytes ) - > str :
2020-09-14 21:19:48 +02:00
""" Try to magically decode ``text`` according to given ``environ``. """
# List of charsets to try
2021-07-26 20:56:46 +02:00
charsets : List [ str ] = [ ]
2020-09-14 21:19:48 +02:00
# First append content charset given in the request
content_type = environ . get ( " CONTENT_TYPE " )
if content_type and " charset= " in content_type :
charsets . append (
content_type . split ( " charset= " ) [ 1 ] . split ( " ; " ) [ 0 ] . strip ( ) )
# Then append default Radicale charset
2021-07-26 20:56:46 +02:00
charsets . append ( cast ( str , configuration . get ( " encoding " , " request " ) ) )
2020-09-14 21:19:48 +02:00
# Then append various fallbacks
charsets . append ( " utf-8 " )
charsets . append ( " iso8859-1 " )
2020-09-14 21:20:39 +02:00
# Remove duplicates
for i , s in reversed ( list ( enumerate ( charsets ) ) ) :
if s in charsets [ : i ] :
del charsets [ i ]
2020-09-14 21:19:48 +02:00
# Try to decode
for charset in charsets :
2021-07-26 20:56:46 +02:00
with contextlib . suppress ( UnicodeDecodeError ) :
2020-09-14 21:19:48 +02:00
return text . decode ( charset )
2020-09-14 21:20:39 +02:00
raise UnicodeDecodeError ( " decode_request " , text , 0 , len ( text ) ,
" all codecs failed [ %s ] " % " , " . join ( charsets ) )
2020-09-14 21:19:48 +02:00
2021-07-26 20:56:46 +02:00
def read_raw_request_body ( configuration : " config.Configuration " ,
environ : types . WSGIEnviron ) - > bytes :
2020-09-14 21:19:48 +02:00
content_length = int ( environ . get ( " CONTENT_LENGTH " ) or 0 )
if not content_length :
return b " "
content = environ [ " wsgi.input " ] . read ( content_length )
if len ( content ) < content_length :
raise RuntimeError ( " Request body too short: %d " % len ( content ) )
return content
2021-07-26 20:56:46 +02:00
def read_request_body ( configuration : " config.Configuration " ,
environ : types . WSGIEnviron ) - > str :
content = decode_request ( configuration , environ ,
read_raw_request_body ( configuration , environ ) )
2024-06-11 13:26:21 +02:00
if configuration . get ( " logging " , " request_content_on_debug " ) :
logger . debug ( " Request content: \n %s " , content )
2024-06-18 08:24:25 +02:00
else :
2024-08-28 07:48:45 +02:00
logger . debug ( " Request content: suppressed by config/option [logging] request_content_on_debug " )
2020-09-14 21:19:48 +02:00
return content
2022-01-18 18:20:15 +01:00
def redirect ( location : str , status : int = client . FOUND ) - > types . WSGIResponse :
return ( status ,
{ " Location " : location , " Content-Type " : " text/plain " } ,
" Redirected to %s " % location )
2022-01-18 18:20:16 +01:00
2022-04-04 18:17:01 +02:00
def _serve_traversable (
traversable : _TRAVERSABLE_LIKE_TYPE , base_prefix : str , path : str ,
path_prefix : str , index_file : str , mimetypes : Mapping [ str , str ] ,
fallback_mimetype : str ) - > types . WSGIResponse :
2022-01-18 18:20:16 +01:00
if path != path_prefix and not path . startswith ( path_prefix ) :
raise ValueError ( " path must start with path_prefix: %r --> %r " %
( path_prefix , path ) )
assert pathutils . sanitize_path ( path ) == path
2022-04-04 18:17:01 +02:00
parts_path = path [ len ( path_prefix ) : ] . strip ( ' / ' )
parts = parts_path . split ( " / " ) if parts_path else [ ]
for part in parts :
if not pathutils . is_safe_filesystem_path_component ( part ) :
logger . debug ( " Web content with unsafe path %r requested " , path )
return NOT_FOUND
if ( not traversable . is_dir ( ) or
all ( part != entry . name for entry in traversable . iterdir ( ) ) ) :
return NOT_FOUND
traversable = traversable . joinpath ( part )
if traversable . is_dir ( ) :
if not path . endswith ( " / " ) :
return redirect ( base_prefix + path + " / " )
if not index_file :
return NOT_FOUND
traversable = traversable . joinpath ( index_file )
if not traversable . is_file ( ) :
2022-01-18 18:20:16 +01:00
return NOT_FOUND
content_type = MIMETYPES . get (
2022-04-04 18:17:01 +02:00
os . path . splitext ( traversable . name ) [ 1 ] . lower ( ) , FALLBACK_MIMETYPE )
headers = { " Content-Type " : content_type }
if isinstance ( traversable , pathlib . Path ) :
headers [ " Last-Modified " ] = time . strftime (
2022-01-18 18:20:16 +01:00
" %a , %d % b % Y % H: % M: % S GMT " ,
2022-04-04 18:17:01 +02:00
time . gmtime ( traversable . stat ( ) . st_mtime ) )
answer = traversable . read_bytes ( )
2025-03-06 08:22:02 +01:00
if path == " /.web/index.html " or path == " /.web/ " :
# enable link on the fly in index.html if InfCloud index.html is existing
# class="infcloudlink-hidden" -> class="infcloudlink"
2025-03-06 08:51:56 +01:00
path_posix = str ( traversable )
2025-03-06 08:22:02 +01:00
path_posix_infcloud = path_posix . replace ( " /internal_data/index.html " , " /internal_data/infcloud/index.html " )
if os . path . isfile ( path_posix_infcloud ) :
# logger.debug("Enable InfCloud link in served page: %r", path)
answer = answer . replace ( b " infcloudlink-hidden " , b " infcloud " )
elif path == " /.web/infcloud/config.js " :
# adjust on the fly default config.js of InfCloud installation
# logger.debug("Adjust on-the-fly default InfCloud config.js in served page: %r", path)
2025-03-06 08:52:54 +01:00
answer = answer . replace ( b " location.pathname.replace(RegExp( ' /+[^/]+/*(index \\ .html)?$ ' ), ' ' )+ " , b " location.pathname.replace(RegExp( ' / \\ .web \\ .infcloud/(index \\ .html)?$ ' ), ' ' )+ " )
2025-03-06 08:22:02 +01:00
answer = answer . replace ( b " ' /caldav.php/ ' , " , b " ' / ' , " )
answer = answer . replace ( b " settingsAccount: true, " , b " settingsAccount: false, " )
elif path == " /.web/infcloud/main.js " :
# adjust on the fly default main.js of InfCloud installation
logger . debug ( " Adjust on-the-fly default InfCloud main.js in served page: %r " , path )
answer = answer . replace ( b " ' InfCloud - the open source CalDAV/CardDAV web client ' " , b " ' InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server ' " )
2022-01-18 18:20:16 +01:00
return client . OK , headers , answer
2022-04-04 18:17:01 +02:00
def serve_resource (
package : str , resource : str , base_prefix : str , path : str ,
path_prefix : str = " /.web " , index_file : str = " index.html " ,
mimetypes : Mapping [ str , str ] = MIMETYPES ,
fallback_mimetype : str = FALLBACK_MIMETYPE ) - > types . WSGIResponse :
if sys . version_info < ( 3 , 9 ) :
traversable = pathlib . Path (
pkg_resources . resource_filename ( package , resource ) )
else :
traversable = resources . files ( package ) . joinpath ( resource )
return _serve_traversable ( traversable , base_prefix , path , path_prefix ,
index_file , mimetypes , fallback_mimetype )
def serve_folder (
folder : str , base_prefix : str , path : str ,
path_prefix : str = " /.web " , index_file : str = " index.html " ,
mimetypes : Mapping [ str , str ] = MIMETYPES ,
fallback_mimetype : str = FALLBACK_MIMETYPE ) - > types . WSGIResponse :
# deprecated: use `serve_resource` instead
traversable = pathlib . Path ( folder )
return _serve_traversable ( traversable , base_prefix , path , path_prefix ,
index_file , mimetypes , fallback_mimetype )