diff --git a/radicale/__init__.py b/radicale/__init__.py index 1f10773..870bf36 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -29,13 +29,11 @@ import os import threading from typing import Iterable, Optional, cast -import pkg_resources - -from radicale import config, log, types +from radicale import config, log, types, utils from radicale.app import Application from radicale.log import logger -VERSION: str = pkg_resources.get_distribution("radicale").version +VERSION: str = utils.package_version("radicale") _application_instance: Optional[Application] = None _application_config_path: Optional[str] = None diff --git a/radicale/httputils.py b/radicale/httputils.py index 1bf2513..8255615 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -24,13 +24,25 @@ Helper functions for HTTP. import contextlib import os +import pathlib +import sys import time from http import client -from typing import List, Mapping, cast +from typing import List, Mapping, Union, cast from radicale import config, pathutils, types from radicale.log import logger +if sys.version_info < (3, 9): + import pkg_resources + + _TRAVERSABLE_LIKE_TYPE = pathlib.Path +else: + import importlib.abc + from importlib import resources + + _TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path] + NOT_ALLOWED: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Access to the requested resource forbidden.") @@ -140,36 +152,63 @@ def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse: "Redirected to %s" % location) -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: +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: 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 - try: - filesystem_path = pathutils.path_to_filesystem( - folder, path[len(path_prefix):].strip("/")) - except ValueError as e: - logger.debug("Web content with unsafe path %r requested: %s", - path, e, exc_info=True) - return NOT_FOUND - if os.path.isdir(filesystem_path) and not path.endswith("/"): - return redirect(base_prefix + path + "/") - if os.path.isdir(filesystem_path) and index_file: - filesystem_path = os.path.join(filesystem_path, index_file) - if not os.path.isfile(filesystem_path): + 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(): return NOT_FOUND content_type = MIMETYPES.get( - os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) - with open(filesystem_path, "rb") as f: - answer = f.read() - last_modified = time.strftime( + os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE) + headers = {"Content-Type": content_type} + if isinstance(traversable, pathlib.Path): + headers["Last-Modified"] = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", - time.gmtime(os.fstat(f.fileno()).st_mtime)) - headers = { - "Content-Type": content_type, - "Last-Modified": last_modified} + time.gmtime(traversable.stat().st_mtime)) + answer = traversable.read_bytes() return client.OK, headers, answer + + +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) diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index db6a871..6946f59 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -29,7 +29,6 @@ from hashlib import sha256 from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set, Tuple, Union, overload) -import pkg_resources import vobject from radicale import config @@ -41,7 +40,7 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",) CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",) CACHE_VERSION: bytes = "".join( - "%s=%s;" % (pkg, pkg_resources.get_distribution(pkg).version) + "%s=%s;" % (pkg, utils.package_version(pkg)) for pkg in CACHE_DEPS).encode() diff --git a/radicale/utils.py b/radicale/utils.py index 33c7735..6125792 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -16,12 +16,18 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import sys from importlib import import_module from typing import Callable, Sequence, Type, TypeVar, Union from radicale import config from radicale.log import logger +if sys.version_info < (3, 8): + import pkg_resources +else: + from importlib import metadata + _T_co = TypeVar("_T_co", covariant=True) @@ -43,3 +49,9 @@ def load_plugin(internal_types: Sequence[str], module_name: str, (module_name, module, e)) from e logger.info("%s type is %r", module_name, module) return class_(configuration) + + +def package_version(name): + if sys.version_info < (3, 8): + return pkg_resources.get_distribution(name).version + return metadata.version(name) diff --git a/radicale/web/internal.py b/radicale/web/internal.py index f21d2bc..01516b5 100644 --- a/radicale/web/internal.py +++ b/radicale/web/internal.py @@ -25,9 +25,7 @@ Features: """ -import pkg_resources - -from radicale import config, httputils, types, web +from radicale import httputils, types, web MIMETYPES = httputils.MIMETYPES # deprecated FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated @@ -35,13 +33,7 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated class Web(web.BaseWeb): - folder: str - - def __init__(self, configuration: config.Configuration) -> None: - super().__init__(configuration) - self.folder = pkg_resources.resource_filename( - __name__, "internal_data") - def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: - return httputils.serve_folder(self.folder, base_prefix, path) + return httputils.serve_resource("radicale.web", "internal_data", + base_prefix, path) diff --git a/setup.py b/setup.py index 17cd743..1fd204e 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,10 @@ WEB_FILES = ["web/internal_data/css/icon.png", "web/internal_data/fn.js", "web/internal_data/index.html"] +install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", + "python-dateutil>=2.7.3"] +if sys.version_info < (3, 9): + install_requires.append("setuptools") setup_requires = [] if {"pytest", "test", "ptr"}.intersection(sys.argv): setup_requires.append("pytest-runner") @@ -76,8 +80,7 @@ setup( exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), package_data={"radicale": [*WEB_FILES, "py.typed"]}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, - install_requires=["defusedxml", "passlib", "vobject>=0.9.6", - "python-dateutil>=2.7.3", "setuptools"], + install_requires=install_requires, setup_requires=setup_requires, tests_require=tests_require, extras_require={"test": tests_require,