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>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from .content_range import ContentRangeHandler
|
||||
from .directory import DirectoryHandler
|
||||
from .error import ErrorHandler
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ContentRangeHandler",
|
||||
"DirectoryHandler",
|
||||
"ErrorHandler",
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic.exceptions import (
|
||||
HeaderNotFound,
|
||||
InvalidRangeType,
|
||||
RangeNotSatisfiable,
|
||||
)
|
||||
from sanic.models.protocol_types import Range
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Request
|
||||
|
||||
|
||||
class ContentRangeHandler(Range):
|
||||
"""Parse and process the incoming request headers to extract the content range information.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request object.
|
||||
stats (os.stat_result): The stats of the file being served.
|
||||
""" # noqa: E501
|
||||
|
||||
__slots__ = ("start", "end", "size", "total", "headers")
|
||||
|
||||
def __init__(self, request: Request, stats: os.stat_result) -> None:
|
||||
self.total = stats.st_size
|
||||
_range = request.headers.getone("range", None)
|
||||
if _range is None:
|
||||
raise HeaderNotFound("Range Header Not Found")
|
||||
unit, _, value = tuple(map(str.strip, _range.partition("=")))
|
||||
if unit != "bytes":
|
||||
raise InvalidRangeType(
|
||||
"{} is not a valid Range Type".format(unit), self
|
||||
)
|
||||
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
|
||||
try:
|
||||
self.start = int(start_b) if start_b else None
|
||||
except ValueError:
|
||||
raise RangeNotSatisfiable(
|
||||
"'{}' is invalid for Content Range".format(start_b), self
|
||||
)
|
||||
try:
|
||||
self.end = int(end_b) if end_b else None
|
||||
except ValueError:
|
||||
raise RangeNotSatisfiable(
|
||||
"'{}' is invalid for Content Range".format(end_b), self
|
||||
)
|
||||
if self.end is None:
|
||||
if self.start is None:
|
||||
raise RangeNotSatisfiable(
|
||||
"Invalid for Content Range parameters", self
|
||||
)
|
||||
else:
|
||||
# this case represents `Content-Range: bytes 5-`
|
||||
self.end = self.total - 1
|
||||
else:
|
||||
if self.start is None:
|
||||
# this case represents `Content-Range: bytes -5`
|
||||
self.start = self.total - self.end
|
||||
self.end = self.total - 1
|
||||
if self.start > self.end:
|
||||
raise RangeNotSatisfiable(
|
||||
"Invalid for Content Range parameters", self
|
||||
)
|
||||
self.size = self.end - self.start + 1
|
||||
self.headers = {
|
||||
"Content-Range": "bytes %s-%s/%s"
|
||||
% (self.start, self.end, self.total)
|
||||
}
|
||||
|
||||
def __bool__(self):
|
||||
return hasattr(self, "size") and self.size > 0
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import datetime
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
from stat import S_ISDIR
|
||||
from typing import cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.pages.directory_page import DirectoryPage, FileInfo
|
||||
from sanic.request import Request
|
||||
from sanic.response import file, html, redirect
|
||||
|
||||
|
||||
def _is_path_within_root(path: Path, root: Path) -> bool:
|
||||
"""Check if a path (after resolution) is within the root directory.
|
||||
|
||||
Returns False for:
|
||||
- Broken symlinks (cannot be resolved)
|
||||
- Paths that resolve outside the root directory
|
||||
- Any errors during resolution
|
||||
"""
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(root.resolve())
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class DirectoryHandler:
|
||||
"""Serve files from a directory.
|
||||
|
||||
Args:
|
||||
uri (str): The URI to serve the files at.
|
||||
directory (Path): The directory to serve files from.
|
||||
directory_view (bool): Whether to show a directory listing or not.
|
||||
index (str | Sequence[str] | None): The index file(s) to
|
||||
serve if the directory is requested. Defaults to None.
|
||||
root_path (Optional[Path]): The root path for security checks.
|
||||
Symlinks resolving outside this path will be hidden from
|
||||
directory listings. Defaults to directory if not specified.
|
||||
follow_external_symlink_files (bool): Whether to show file symlinks
|
||||
pointing outside root in directory listings. Defaults to False.
|
||||
follow_external_symlink_dirs (bool): Whether to show directory symlinks
|
||||
pointing outside root in directory listings. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uri: str,
|
||||
directory: Path,
|
||||
directory_view: bool = False,
|
||||
index: str | Sequence[str] | None = None,
|
||||
root_path: Path | None = None,
|
||||
follow_external_symlink_files: bool = False,
|
||||
follow_external_symlink_dirs: bool = False,
|
||||
) -> None:
|
||||
if isinstance(index, str):
|
||||
index = [index]
|
||||
elif index is None:
|
||||
index = []
|
||||
self.base = uri.strip("/")
|
||||
self.directory = directory
|
||||
self.directory_view = directory_view
|
||||
self.index = tuple(index)
|
||||
self.root_path = root_path if root_path is not None else directory
|
||||
self.follow_external_symlink_files = follow_external_symlink_files
|
||||
self.follow_external_symlink_dirs = follow_external_symlink_dirs
|
||||
|
||||
async def handle(self, request: Request, path: str):
|
||||
"""Handle the request.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request object.
|
||||
path (str): The path to the file to serve.
|
||||
|
||||
Raises:
|
||||
NotFound: If the file is not found.
|
||||
IsADirectoryError: If the path is a directory and directory_view is False.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
""" # noqa: E501
|
||||
current = unquote(path).strip("/")[len(self.base) :].strip("/") # noqa: E203
|
||||
for file_name in self.index:
|
||||
index_file = self.directory / current / file_name
|
||||
if index_file.is_file():
|
||||
return await file(index_file)
|
||||
|
||||
if self.directory_view:
|
||||
return self._index(
|
||||
self.directory / current, path, request.app.debug
|
||||
)
|
||||
|
||||
if self.index:
|
||||
raise NotFound("File not found")
|
||||
|
||||
raise IsADirectoryError(f"{self.directory.as_posix()} is a directory")
|
||||
|
||||
def _index(self, location: Path, path: str, debug: bool):
|
||||
# Remove empty path elements, append slash
|
||||
if "//" in path or not path.endswith("/"):
|
||||
return redirect(
|
||||
"/" + "".join([f"{p}/" for p in path.split("/") if p])
|
||||
)
|
||||
|
||||
# Render file browser
|
||||
page = DirectoryPage(self._iter_files(location), path, debug)
|
||||
return html(page.render())
|
||||
|
||||
def _prepare_file(self, path: Path) -> dict[str, int | str] | None:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
modified = (
|
||||
datetime.fromtimestamp(stat.st_mtime)
|
||||
.isoformat()[:19]
|
||||
.replace("T", " ")
|
||||
)
|
||||
is_dir = S_ISDIR(stat.st_mode)
|
||||
icon = "📁" if is_dir else "📄"
|
||||
file_name = path.name
|
||||
if is_dir:
|
||||
file_name += "/"
|
||||
return {
|
||||
"priority": is_dir * -1,
|
||||
"file_name": file_name,
|
||||
"icon": icon,
|
||||
"file_access": modified,
|
||||
"file_size": stat.st_size,
|
||||
}
|
||||
|
||||
def _iter_files(self, location: Path) -> Iterable[FileInfo]:
|
||||
prepared = []
|
||||
for f in location.iterdir():
|
||||
if f.is_symlink() and not _is_path_within_root(f, self.root_path):
|
||||
# External symlink - check if allowed based on type
|
||||
try:
|
||||
is_dir = f.resolve().is_dir()
|
||||
except OSError:
|
||||
continue # Broken symlink
|
||||
if is_dir and not self.follow_external_symlink_dirs:
|
||||
continue
|
||||
if not is_dir and not self.follow_external_symlink_files:
|
||||
continue
|
||||
file_info = self._prepare_file(f)
|
||||
if file_info is not None:
|
||||
prepared.append(file_info)
|
||||
for item in sorted(prepared, key=itemgetter("priority", "file_name")):
|
||||
del item["priority"]
|
||||
yield cast(FileInfo, item)
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.request.types import Request
|
||||
from sanic.response import text
|
||||
from sanic.response.types import HTTPResponse
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""Process and handle all uncaught exceptions.
|
||||
|
||||
This error handling framework is built into the core that can be extended
|
||||
by the developers to perform a wide range of tasks from recording the error
|
||||
stats to reporting them to an external service that can be used for
|
||||
realtime alerting system.
|
||||
|
||||
Args:
|
||||
base (BaseRenderer): The renderer to use for the error pages.
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base: type[BaseRenderer] = TextRenderer,
|
||||
):
|
||||
self.cached_handlers: dict[
|
||||
tuple[type[BaseException], str | None], RouteHandler | None
|
||||
] = {}
|
||||
self.debug = False
|
||||
self.base = base
|
||||
|
||||
def _full_lookup(self, exception, route_name: str | None = None):
|
||||
return self.lookup(exception, route_name)
|
||||
|
||||
def _add(
|
||||
self,
|
||||
key: tuple[type[BaseException], str | None],
|
||||
handler: RouteHandler,
|
||||
) -> None:
|
||||
if key in self.cached_handlers:
|
||||
exc, name = key
|
||||
if name is None:
|
||||
name = "__ALL_ROUTES__"
|
||||
|
||||
message = (
|
||||
f"Duplicate exception handler definition on: route={name} "
|
||||
f"and exception={exc}"
|
||||
)
|
||||
raise ServerError(message)
|
||||
self.cached_handlers[key] = handler
|
||||
|
||||
def add(self, exception, handler, route_names: list[str] | None = None):
|
||||
"""Add a new exception handler to an already existing handler object.
|
||||
|
||||
Args:
|
||||
exception (sanic.exceptions.SanicException or Exception): Type
|
||||
of exception that needs to be handled.
|
||||
handler (function): Reference to the function that will
|
||||
handle the exception.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
""" # noqa: E501
|
||||
if route_names:
|
||||
for route in route_names:
|
||||
self._add((exception, route), handler)
|
||||
else:
|
||||
self._add((exception, None), handler)
|
||||
|
||||
def lookup(self, exception, route_name: str | None = None):
|
||||
"""Lookup the existing instance of `ErrorHandler` and fetch the registered handler for a specific type of exception.
|
||||
|
||||
This method leverages a dict lookup to speedup the retrieval process.
|
||||
|
||||
Args:
|
||||
exception (sanic.exceptions.SanicException or Exception): Type
|
||||
of exception.
|
||||
|
||||
Returns:
|
||||
Registered function if found, ``None`` otherwise.
|
||||
|
||||
""" # noqa: E501
|
||||
exception_class = type(exception)
|
||||
|
||||
for name in (route_name, None):
|
||||
exception_key = (exception_class, name)
|
||||
handler = self.cached_handlers.get(exception_key)
|
||||
if handler:
|
||||
return handler
|
||||
|
||||
for name in (route_name, None):
|
||||
for ancestor in type.mro(exception_class):
|
||||
exception_key = (ancestor, name)
|
||||
if exception_key in self.cached_handlers:
|
||||
handler = self.cached_handlers[exception_key]
|
||||
self.cached_handlers[(exception_class, route_name)] = (
|
||||
handler
|
||||
)
|
||||
return handler
|
||||
|
||||
if ancestor is BaseException:
|
||||
break
|
||||
self.cached_handlers[(exception_class, route_name)] = None
|
||||
handler = None
|
||||
return handler
|
||||
|
||||
_lookup = _full_lookup
|
||||
|
||||
def response(self, request, exception):
|
||||
"""Fetch and executes an exception handler and returns a response object.
|
||||
|
||||
Args:
|
||||
request (sanic.request.Request): Instance of the request.
|
||||
exception (sanic.exceptions.SanicException or Exception): Exception to handle.
|
||||
|
||||
Returns:
|
||||
Wrap the return value obtained from the `default` function or the registered handler for that type of exception.
|
||||
|
||||
""" # noqa: E501
|
||||
route_name = request.name if request else None
|
||||
handler = self._lookup(exception, route_name)
|
||||
response = None
|
||||
try:
|
||||
if handler:
|
||||
response = handler(request, exception)
|
||||
if response is None:
|
||||
response = self.default(request, exception)
|
||||
except Exception:
|
||||
try:
|
||||
url = repr(request.url)
|
||||
except AttributeError: # no cov
|
||||
url = "unknown"
|
||||
response_message = (
|
||||
'Exception raised in exception handler "%s" for uri: %s'
|
||||
)
|
||||
error_logger.exception(response_message, handler.__name__, url)
|
||||
|
||||
if self.debug:
|
||||
return text(response_message % (handler.__name__, url), 500)
|
||||
else:
|
||||
return text("An error occurred while handling an error", 500)
|
||||
return response
|
||||
|
||||
def default(self, request: Request, exception: Exception) -> HTTPResponse:
|
||||
"""Provide a default behavior for the objects of ErrorHandler.
|
||||
|
||||
If a developer chooses to extend the ErrorHandler, they can
|
||||
provide a custom implementation for this method to behave in a way
|
||||
they see fit.
|
||||
|
||||
Args:
|
||||
request (sanic.request.Request): Incoming request.
|
||||
exception (sanic.exceptions.SanicException or Exception): Exception object.
|
||||
|
||||
Returns:
|
||||
HTTPResponse: The response object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
class CustomErrorHandler(ErrorHandler):
|
||||
def default(self, request: Request, exception: Exception) -> HTTPResponse:
|
||||
# Custom logic for handling the exception and creating a response
|
||||
custom_response = my_custom_logic(request, exception)
|
||||
return custom_response
|
||||
|
||||
app = Sanic("MyApp", error_handler=CustomErrorHandler())
|
||||
```
|
||||
""" # noqa: E501
|
||||
self.log(request, exception)
|
||||
fallback = request.app.config.FALLBACK_ERROR_FORMAT
|
||||
return exception_response(
|
||||
request,
|
||||
exception,
|
||||
debug=self.debug,
|
||||
base=self.base,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log(request: Request, exception: Exception) -> None:
|
||||
"""Logs information about an incoming request and the associated exception.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming request to be logged.
|
||||
exception (Exception): The exception that occurred during the handling of the request.
|
||||
|
||||
Returns:
|
||||
None
|
||||
""" # noqa: E501
|
||||
quiet = getattr(exception, "quiet", False)
|
||||
noisy = getattr(request.app.config, "NOISY_EXCEPTIONS", False)
|
||||
if quiet is False or noisy is True:
|
||||
try:
|
||||
url = repr(request.url)
|
||||
except AttributeError: # no cov
|
||||
url = "unknown"
|
||||
|
||||
error_logger.exception(
|
||||
"Exception occurred while handling uri: %s", url
|
||||
)
|
||||
Reference in New Issue
Block a user