hack-house/.venv/lib/python3.12/site-packages/sanic/mixins/static.py
leetcrypt bb1d662ee1 chore: rename project coven → hack-house ⛧
Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"),
README, CLI strings, and branch (coven → hack-house). Gitea repo renamed
cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest +
golden-vector test still green, binary is now `hack-house`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:29:14 -07:00

426 lines
16 KiB
Python

from __future__ import annotations
from collections.abc import Sequence
from email.utils import formatdate
from functools import partial, wraps
from os import PathLike, path
from pathlib import Path, PurePath
from urllib.parse import unquote
from sanic_routing.route import Route
from sanic.base.meta import SanicMeta
from sanic.compat import clear_function_annotate, stat_async
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
from sanic.handlers import ContentRangeHandler
from sanic.handlers.directory import DirectoryHandler
from sanic.log import error_logger
from sanic.mixins.base import BaseMixin
from sanic.models.futures import FutureStatic
from sanic.request import Request
from sanic.response import HTTPResponse, file, file_stream, validate_file
from sanic.response.convenience import guess_content_type
class StaticMixin(BaseMixin, metaclass=SanicMeta):
def __init__(self, *args, **kwargs) -> None:
self._future_statics: set[FutureStatic] = set()
def _apply_static(self, static: FutureStatic) -> Route:
raise NotImplementedError # noqa
def static(
self,
uri: str,
file_or_directory: PathLike | str,
pattern: str = r"/?.+",
use_modified_since: bool = True,
use_content_range: bool = False,
stream_large_files: bool | int = False,
name: str = "static",
host: str | None = None,
strict_slashes: bool | None = None,
content_type: str | None = None,
apply: bool = True,
resource_type: str | None = None,
index: str | Sequence[str] | None = None,
directory_view: bool = False,
directory_handler: DirectoryHandler | None = None,
follow_external_symlink_files: bool = False,
follow_external_symlink_dirs: bool = False,
):
"""Register a root to serve files from. The input can either be a file or a directory.
This method provides an easy and simple way to set up the route necessary to serve static files.
Args:
uri (str): URL path to be used for serving static content.
file_or_directory (Union[PathLike, str]): Path to the static file
or directory with static files.
pattern (str, optional): Regex pattern identifying the valid
static files. Defaults to `r"/?.+"`.
use_modified_since (bool, optional): If true, send file modified
time, and return not modified if the browser's matches the
server's. Defaults to `True`.
use_content_range (bool, optional): If true, process header for
range requests and sends the file part that is requested.
Defaults to `False`.
stream_large_files (Union[bool, int], optional): If `True`, use
the `StreamingHTTPResponse.file_stream` handler rather than
the `HTTPResponse.file handler` to send the file. If this
is an integer, it represents the threshold size to switch
to `StreamingHTTPResponse.file_stream`. Defaults to `False`,
which means that the response will not be streamed.
name (str, optional): User-defined name used for url_for.
Defaults to `"static"`.
host (Optional[str], optional): Host IP or FQDN for the
service to use.
strict_slashes (Optional[bool], optional): Instruct Sanic to
check if the request URLs need to terminate with a slash.
content_type (Optional[str], optional): User-defined content type
for header.
apply (bool, optional): If true, will register the route
immediately. Defaults to `True`.
resource_type (Optional[str], optional): Explicitly declare a
resource to be a `"file"` or a `"dir"`.
index (Optional[Union[str, Sequence[str]]], optional): When
exposing against a directory, index is the name that will
be served as the default file. When multiple file names are
passed, then they will be tried in order.
directory_view (bool, optional): Whether to fallback to showing
the directory viewer when exposing a directory. Defaults
to `False`.
directory_handler (Optional[DirectoryHandler], optional): An
instance of DirectoryHandler that can be used for explicitly
controlling and subclassing the behavior of the default
directory handler.
follow_external_symlink_files (bool, optional): Whether to serve
files that are symlinks pointing outside the static root.
Defaults to `False` for security.
follow_external_symlink_dirs (bool, optional): Whether to serve
files from directories that are symlinks pointing outside
the static root. Defaults to `False` for security.
Returns:
List[sanic.router.Route]: Routes registered on the router.
Examples:
Serving a single file:
```python
app.static('/foo', 'path/to/static/file.txt')
```
Serving all files from a directory:
```python
app.static('/static', 'path/to/static/directory')
```
Serving large files with a specific threshold:
```python
app.static('/static', 'path/to/large/files', stream_large_files=1000000)
```
""" # noqa: E501
name = self.generate_name(name)
if strict_slashes is None and self.strict_slashes is not None:
strict_slashes = self.strict_slashes
if not isinstance(file_or_directory, (str, bytes, PurePath)):
raise ValueError(
f"Static route must be a valid path, not {file_or_directory}"
)
try:
file_or_directory = Path(file_or_directory).resolve()
except TypeError:
raise TypeError(
"Static file or directory must be a path-like object or string"
)
if directory_handler and (directory_view or index):
raise ValueError(
"When explicitly setting directory_handler, you cannot "
"set either directory_view or index. Instead, pass "
"these arguments to your DirectoryHandler instance."
)
if not directory_handler:
directory_handler = DirectoryHandler(
uri=uri,
directory=file_or_directory,
directory_view=directory_view,
index=index,
root_path=file_or_directory,
follow_external_symlink_files=follow_external_symlink_files,
follow_external_symlink_dirs=follow_external_symlink_dirs,
)
static = FutureStatic(
uri,
file_or_directory,
pattern,
use_modified_since,
use_content_range,
stream_large_files,
name,
host,
strict_slashes,
content_type,
resource_type,
directory_handler,
follow_external_symlink_files,
follow_external_symlink_dirs,
)
self._future_statics.add(static)
if apply:
self._apply_static(static)
class StaticHandleMixin(metaclass=SanicMeta):
def _apply_static(self, static: FutureStatic) -> Route:
return self._register_static(static)
def _register_static(
self,
static: FutureStatic,
):
# TODO: Though sanic is not a file server, I feel like we should
# at least make a good effort here. Modified-since is nice, but
# we could also look into etags, expires, and caching
"""
Register a static directory handler with Sanic by adding a route to the
router and registering a handler.
"""
file_or_directory: PathLike
if isinstance(static.file_or_directory, bytes):
file_or_directory = Path(static.file_or_directory.decode("utf-8"))
elif isinstance(static.file_or_directory, PurePath):
file_or_directory = static.file_or_directory
elif isinstance(static.file_or_directory, str):
file_or_directory = Path(static.file_or_directory)
else:
raise ValueError("Invalid file path string.")
uri = static.uri
name = static.name
# If we're not trying to match a file directly,
# serve from the folder
if not static.resource_type:
if not path.isfile(file_or_directory):
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "dir":
if path.isfile(file_or_directory):
raise TypeError(
"Resource type improperly identified as directory. "
f"'{file_or_directory}'"
)
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "file" and not path.isfile(
file_or_directory
):
raise TypeError(
"Resource type improperly identified as file. "
f"'{file_or_directory}'"
)
elif static.resource_type != "file":
raise ValueError(
"The resource_type should be set to 'file' or 'dir'"
)
# special prefix for static files
# if not static.name.startswith("_static_"):
# name = f"_static_{static.name}"
_handler = wraps(self._static_request_handler)(
partial(
self._static_request_handler,
file_or_directory=str(file_or_directory),
use_modified_since=static.use_modified_since,
use_content_range=static.use_content_range,
stream_large_files=static.stream_large_files,
content_type=static.content_type,
directory_handler=static.directory_handler,
follow_external_symlink_files=static.follow_external_symlink_files,
follow_external_symlink_dirs=static.follow_external_symlink_dirs,
)
)
route, _ = self.route( # type: ignore
uri=uri,
methods=["GET", "HEAD"],
name=name,
host=static.host,
strict_slashes=static.strict_slashes,
static=True,
)(_handler)
return route
async def _static_request_handler(
self,
request: Request,
*,
file_or_directory: str,
use_modified_since: bool,
use_content_range: bool,
stream_large_files: bool | int,
directory_handler: DirectoryHandler,
follow_external_symlink_files: bool,
follow_external_symlink_dirs: bool,
content_type: str | None = None,
__file_uri__: str | None = None,
):
not_found = FileNotFound(
"File not found",
path=Path(file_or_directory),
relative_url=__file_uri__,
)
# Merge served directory and requested file if provided
file_path = await self._get_file_path(
file_or_directory,
__file_uri__,
not_found,
follow_external_symlink_files,
follow_external_symlink_dirs,
)
try:
headers = {}
# Check if the client has been sent this file before
# and it has not been modified since
stats = None
if use_modified_since:
stats = await stat_async(file_path)
modified_since = stats.st_mtime
response = await validate_file(request.headers, modified_since)
if response:
return response
headers["Last-Modified"] = formatdate(
modified_since, usegmt=True
)
_range = None
if use_content_range:
_range = None
if not stats:
stats = await stat_async(file_path)
headers["Accept-Ranges"] = "bytes"
headers["Content-Length"] = str(stats.st_size)
if request.method != "HEAD":
try:
_range = ContentRangeHandler(request, stats)
except HeaderNotFound:
pass
else:
del headers["Content-Length"]
headers.update(_range.headers)
if "content-type" not in headers:
content_type = content_type or guess_content_type(file_path)
if "charset=" not in content_type and (
content_type.startswith("text/")
or content_type == "application/javascript"
):
content_type += "; charset=utf-8"
headers["Content-Type"] = content_type
if request.method == "HEAD":
return HTTPResponse(headers=headers)
else:
if stream_large_files:
if isinstance(stream_large_files, bool):
threshold = 1024 * 1024
else:
threshold = stream_large_files
if not stats:
stats = await stat_async(file_path)
if stats.st_size >= threshold:
return await file_stream(
file_path, headers=headers, _range=_range
)
return await file(file_path, headers=headers, _range=_range)
except (IsADirectoryError, PermissionError):
return await directory_handler.handle(request, request.path)
except RangeNotSatisfiable:
raise
except FileNotFoundError:
raise not_found
except Exception:
error_logger.exception(
"Exception in static request handler: "
f"path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise
async def _get_file_path(
self,
file_or_directory,
__file_uri__,
not_found,
follow_external_symlink_files: bool,
follow_external_symlink_dirs: bool,
):
"""
Resolve a filesystem path safely.
Security goals:
- Prevent path traversal via `..`
- Prevent escaping the root via symlinks unless explicitly allowed
- Treat file URIs as relative paths even if they look absolute
"""
def reject():
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
root_raw = Path(unquote(file_or_directory))
root_path = root_raw.resolve()
file_path_raw = root_raw
if __file_uri__:
# URLs may start with `/`, Path() interprets as absolute
rel_uri = unquote(__file_uri__).lstrip("/")
file_path_raw = Path(root_raw, rel_uri)
if ".." in file_path_raw.parts:
reject()
file_path = file_path_raw.resolve()
try:
file_path.relative_to(root_path)
except ValueError:
# Check if it's a symlink and determine its type
is_file_symlink = (
file_path_raw.is_symlink() and not file_path.is_dir()
)
if is_file_symlink:
allowed = follow_external_symlink_files
else:
allowed = follow_external_symlink_dirs
if not allowed:
reject()
return file_path
# Clear __annotate__ on methods that may be pickled via functools.partial
# to avoid PicklingError in Python 3.14+ (PEP 649)
clear_function_annotate(
StaticHandleMixin._static_request_handler,
StaticHandleMixin._get_file_path,
StaticHandleMixin._register_static,
)