Source code for litestar.static_files

from __future__ import annotations

import os
from os.path import commonpath
from pathlib import Path, PurePath
from typing import TYPE_CHECKING, Any, Literal

from litestar.exceptions import ImproperlyConfiguredException, NotFoundException
from litestar.file_system import (
    AnyFileSystem,
    BaseFileSystem,
    FileInfo,
    FileSystemRegistry,
    LinkableFileSystem,
    maybe_wrap_fsspec_file_system,
)
from litestar.handlers import get, head
from litestar.response.file import ASGIFileResponse
from litestar.router import Router
from litestar.status_codes import HTTP_404_NOT_FOUND
from litestar.types import Empty
from litestar.utils import normalize_path

__all__ = ("create_static_files_router",)

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence

    from litestar import Request
    from litestar.datastructures import CacheControlHeader
    from litestar.openapi.spec import SecurityRequirement
    from litestar.types import (
        AfterRequestHookHandler,
        AfterResponseHookHandler,
        BeforeRequestHookHandler,
        EmptyType,
        ExceptionHandlersMap,
        Guard,
        Middleware,
        PathType,
    )


[docs] def create_static_files_router( path: str, directories: Sequence[PathType], file_system: AnyFileSystem | str | None = None, send_as_attachment: bool = False, html_mode: bool = False, name: str = "static", after_request: AfterRequestHookHandler | None = None, after_response: AfterResponseHookHandler | None = None, before_request: BeforeRequestHookHandler | None = None, cache_control: CacheControlHeader | None = None, exception_handlers: ExceptionHandlersMap | None = None, guards: Sequence[Guard] | None = None, include_in_schema: bool | EmptyType = Empty, middleware: Sequence[Middleware] | None = None, opt: Mapping[str, Any] | None = None, security: Sequence[SecurityRequirement] | None = None, tags: Sequence[str] | None = None, router_class: type[Router] = Router, allow_symlinks_outside_directory: bool = False, ) -> Router: """Create a router with handlers to serve static files. Args: path: Path to serve static files under directories: Directories to serve static files from file_system: The file system to load the file from. Instances of :class:`~litestar.file_system.BaseFileSystem`, :class:`fsspec.spec.AbstractFileSystem`, :class:`fsspec.asyn.AsyncFileSystem` will be used directly. If passed string, use it to look up the corresponding file system from the :class:`~litestar.file_system.FileSystemRegistry`. If not given, the file will be loaded from :attr:`~litestar.file_system.FileSystemRegistry.default` send_as_attachment: Whether to send the file as an attachment html_mode: When in HTML: - Serve an ``index.html`` file from ``/`` - Serve ``404.html`` when a file could not be found name: Name to pass to the generated handlers after_request: ``after_request`` handlers passed to the router after_response: ``after_response`` handlers passed to the router before_request: ``before_request`` handlers passed to the router cache_control: ``cache_control`` passed to the router exception_handlers: Exception handlers passed to the router guards: Guards passed to the router include_in_schema: Include the routes / router in the OpenAPI schema middleware: Middlewares passed to the router opt: Opts passed to the router security: Security options passed to the router tags: ``tags`` passed to the router router_class: The class used to construct a router from allow_symlinks_outside_directory: Allow serving files that link a path inside a base directory (as specified in 'directories') to a path outside it. This should be handled with caution, as it allows potentially unintended access to files outside the defined 'directories' via symlink chains. .. seealso:: :ref:`usage/static-files:Handling symlinks` """ if file_system is not None and not isinstance(file_system, str): file_system = maybe_wrap_fsspec_file_system(file_system) resolved_directories = tuple(os.path.normpath(Path(p).absolute()) for p in directories) _validate_config(path=path, directories=resolved_directories) path = normalize_path(path) headers = None if cache_control: headers = {cache_control.HEADER_NAME: cache_control.to_header()} @get("{file_path:path}", name=name) async def get_handler(file_path: PurePath, request: Request) -> ASGIFileResponse: return await _handler( path=file_path.as_posix(), is_head_response=False, directories=resolved_directories, fs=file_system, is_html_mode=html_mode, send_as_attachment=send_as_attachment, headers=headers, allow_symlinks_outside_directory=allow_symlinks_outside_directory, request=request, ) @head("/{file_path:path}", name=f"{name}/head") async def head_handler(file_path: PurePath, request: Request) -> ASGIFileResponse: return await _handler( path=file_path.as_posix(), is_head_response=True, directories=resolved_directories, fs=file_system, is_html_mode=html_mode, send_as_attachment=send_as_attachment, headers=headers, allow_symlinks_outside_directory=allow_symlinks_outside_directory, request=request, ) handlers = [get_handler, head_handler] if html_mode: @get("/", name=f"{name}/index") async def index_handler(request: Request) -> ASGIFileResponse: return await _handler( path="/", is_head_response=False, directories=resolved_directories, fs=file_system, is_html_mode=True, send_as_attachment=send_as_attachment, headers=headers, allow_symlinks_outside_directory=allow_symlinks_outside_directory, request=request, ) handlers.append(index_handler) return router_class( after_request=after_request, after_response=after_response, before_request=before_request, cache_control=cache_control, exception_handlers=exception_handlers, guards=guards, include_in_schema=include_in_schema, middleware=middleware, opt=opt, path=path, route_handlers=handlers, security=security, tags=tags, )
async def _handler( *, path: str, is_head_response: bool, directories: tuple[str, ...], send_as_attachment: bool, fs: BaseFileSystem | str | None, is_html_mode: bool, headers: dict[str, str] | None, allow_symlinks_outside_directory: bool, request: Request, ) -> ASGIFileResponse: split_path = path.split("/") filename = split_path[-1] joined_path = Path(*split_path) fs = _get_file_system(fs, request) resolved_path, fs_info = await _get_fs_info( directories=directories, file_path=joined_path, fs=fs, allow_symlinks_outside_directory=allow_symlinks_outside_directory, ) content_disposition_type: Literal["inline", "attachment"] = "attachment" if send_as_attachment else "inline" if is_html_mode and fs_info and fs_info["type"] == "directory": filename = "index.html" resolved_path, fs_info = await _get_fs_info( directories=directories, file_path=Path(resolved_path or joined_path) / filename, fs=fs, allow_symlinks_outside_directory=allow_symlinks_outside_directory, ) if fs_info and fs_info["type"] == "file": return ASGIFileResponse( file_path=resolved_path or joined_path, file_info=fs_info, file_system=fs, filename=filename, content_disposition_type=content_disposition_type, is_head_response=is_head_response, headers=headers, ) if is_html_mode: # for some reason coverage doesn't catch these two lines filename = "404.html" # pragma: no cover resolved_path, fs_info = await _get_fs_info( # pragma: no cover directories=directories, file_path=filename, fs=fs, allow_symlinks_outside_directory=allow_symlinks_outside_directory, ) if fs_info and fs_info["type"] == "file": return ASGIFileResponse( file_path=resolved_path or joined_path, file_info=fs_info, file_system=fs, filename=filename, status_code=HTTP_404_NOT_FOUND, content_disposition_type=content_disposition_type, is_head_response=is_head_response, headers=headers, ) raise NotFoundException( f"no file or directory match the path {resolved_path or joined_path} was found" ) # pragma: no cover async def _get_fs_info( directories: Sequence[PathType], file_path: PathType, fs: BaseFileSystem | LinkableFileSystem, allow_symlinks_outside_directory: bool, ) -> tuple[Path, FileInfo] | tuple[None, None]: """Return the resolved path and a :class:`~litestar.file_system.FileInfo`""" for directory in directories: try: joined_path = Path(directory, file_path) file_info = await fs.info(joined_path) if not file_info.get("is_symlink"): # not a symlink, just normalize the path normalized_file_path = os.path.normpath(joined_path) elif allow_symlinks_outside_directory: # we want to read 'through' a symlink. in both # cases we can just get the normpath. normalized_file_path = os.path.normpath(joined_path) else: # file *is* a symlink, *and* we do not want to read 'through' it, so ask # the fs to resolve potential symlinks and return the real path to the # file. this means that if our path is '/path/to/file' and a symlink # pointing to '/some/other/location', the latter part will be used to # check if we are allowed to serve this file. in this example, if the # configured base directory is '/path/to', we would not serve the file # located at '/path/to/file', since it links to '/some/other/location', # which is *not* a subpath of '/path/to' resolver = LinkableFileSystem.get_symlink_resolver(fs) if resolver is None: raise TypeError( f"path {joined_path} contains a a symlink, but the file system " f"{fs} does not support resolving symlinks." ) normalized_file_path = await resolver(fs, joined_path) directory_path = str(directory) if ( file_info and commonpath([directory_path, file_info["name"], joined_path]) == directory_path and os.path.commonpath([directory, normalized_file_path]) == directory_path ): return joined_path, file_info except FileNotFoundError: continue return None, None def _validate_config(path: str, directories: tuple[PathType, ...]) -> None: if not path: raise ImproperlyConfiguredException("path must be a non-zero length string") if not directories or not any(bool(d) for d in directories): raise ImproperlyConfiguredException("directories must include at least one path") if "{" in path: raise ImproperlyConfiguredException("path parameters are not supported for static files") def _get_file_system(fs: BaseFileSystem | str | None, request: Request) -> BaseFileSystem: if isinstance(fs, BaseFileSystem): return fs registry = request.app.plugins.get(FileSystemRegistry) if isinstance(fs, str): return registry[fs] return registry.default