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>
426 lines
16 KiB
Python
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,
|
|
)
|