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,396 @@
|
||||
import re
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from sanic import HTTPResponse, Request, Sanic
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.helpers import Default, _default
|
||||
from sanic.log import logger
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
|
||||
|
||||
WILDCARD_PATTERN = re.compile(r".*")
|
||||
ORIGIN_HEADER = "access-control-allow-origin"
|
||||
ALLOW_HEADERS_HEADER = "access-control-allow-headers"
|
||||
ALLOW_METHODS_HEADER = "access-control-allow-methods"
|
||||
EXPOSE_HEADER = "access-control-expose-headers"
|
||||
CREDENTIALS_HEADER = "access-control-allow-credentials"
|
||||
REQUEST_METHOD_HEADER = "access-control-request-method"
|
||||
REQUEST_HEADERS_HEADER = "access-control-request-headers"
|
||||
MAX_AGE_HEADER = "access-control-max-age"
|
||||
VARY_HEADER = "vary"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CORSSettings:
|
||||
allow_headers: frozenset[str]
|
||||
allow_methods: frozenset[str]
|
||||
allow_origins: tuple[re.Pattern, ...]
|
||||
always_send: bool
|
||||
automatic_options: bool
|
||||
expose_headers: frozenset[str]
|
||||
max_age: str
|
||||
send_wildcard: bool
|
||||
supports_credentials: bool
|
||||
|
||||
|
||||
def add_cors(app: Sanic) -> None:
|
||||
_setup_cors_settings(app)
|
||||
|
||||
@app.on_response
|
||||
async def _add_cors_headers(request, response):
|
||||
preflight = (
|
||||
request.app.ctx.cors.automatic_options
|
||||
and request.method == "OPTIONS"
|
||||
)
|
||||
|
||||
if preflight and not request.headers.get(REQUEST_METHOD_HEADER):
|
||||
logger.info(
|
||||
"No Access-Control-Request-Method header found on request. "
|
||||
"CORS headers will not be applied."
|
||||
)
|
||||
return
|
||||
|
||||
_add_origin_header(request, response)
|
||||
|
||||
if ORIGIN_HEADER not in response.headers:
|
||||
return
|
||||
|
||||
_add_expose_header(request, response)
|
||||
_add_credentials_header(request, response)
|
||||
_add_vary_header(request, response)
|
||||
|
||||
if preflight:
|
||||
_add_max_age_header(request, response)
|
||||
_add_allow_header(request, response)
|
||||
_add_methods_header(request, response)
|
||||
|
||||
@app.before_server_start(priority=PRIORITY)
|
||||
async def _assign_cors_settings(app, _):
|
||||
for group in app.router.groups.values():
|
||||
_cors = SimpleNamespace()
|
||||
for route in group:
|
||||
cors = getattr(route.handler, "__cors__", None)
|
||||
if cors:
|
||||
for key, value in cors.__dict__.items():
|
||||
setattr(_cors, key, value)
|
||||
|
||||
for route in group:
|
||||
route.ctx._cors = _cors
|
||||
|
||||
|
||||
def cors(
|
||||
*,
|
||||
origin: Union[str, Default] = _default,
|
||||
expose_headers: Union[list[str], Default] = _default,
|
||||
allow_headers: Union[list[str], Default] = _default,
|
||||
allow_methods: Union[list[str], Default] = _default,
|
||||
supports_credentials: Union[bool, Default] = _default,
|
||||
max_age: Union[str, int, timedelta, Default] = _default,
|
||||
):
|
||||
def decorator(f):
|
||||
f.__cors__ = SimpleNamespace(
|
||||
_cors_origin=origin,
|
||||
_cors_expose_headers=expose_headers,
|
||||
_cors_supports_credentials=supports_credentials,
|
||||
_cors_allow_origins=(
|
||||
_parse_allow_origins(origin)
|
||||
if origin is not _default
|
||||
else origin
|
||||
),
|
||||
_cors_allow_headers=(
|
||||
_parse_allow_headers(allow_headers)
|
||||
if allow_headers is not _default
|
||||
else allow_headers
|
||||
),
|
||||
_cors_allow_methods=(
|
||||
_parse_allow_methods(allow_methods)
|
||||
if allow_methods is not _default
|
||||
else allow_methods
|
||||
),
|
||||
_cors_max_age=(
|
||||
_parse_max_age(max_age) if max_age is not _default else max_age
|
||||
),
|
||||
)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _setup_cors_settings(app: Sanic) -> None:
|
||||
if app.config.CORS_ORIGINS == "*" and app.config.CORS_SUPPORTS_CREDENTIALS:
|
||||
raise SanicException(
|
||||
"Cannot use supports_credentials in conjunction with "
|
||||
"an origin string of '*'. See: "
|
||||
"http://www.w3.org/TR/cors/#resource-requests"
|
||||
)
|
||||
|
||||
allow_headers = _get_allow_headers(app)
|
||||
allow_methods = _get_allow_methods(app)
|
||||
allow_origins = _get_allow_origins(app)
|
||||
expose_headers = _get_expose_headers(app)
|
||||
max_age = _get_max_age(app)
|
||||
|
||||
app.ctx.cors = CORSSettings(
|
||||
allow_headers=allow_headers,
|
||||
allow_methods=allow_methods,
|
||||
allow_origins=allow_origins,
|
||||
always_send=app.config.CORS_ALWAYS_SEND,
|
||||
automatic_options=app.config.CORS_AUTOMATIC_OPTIONS,
|
||||
expose_headers=expose_headers,
|
||||
max_age=max_age,
|
||||
send_wildcard=(
|
||||
app.config.CORS_SEND_WILDCARD and WILDCARD_PATTERN in allow_origins
|
||||
),
|
||||
supports_credentials=app.config.CORS_SUPPORTS_CREDENTIALS,
|
||||
)
|
||||
|
||||
|
||||
def _get_from_cors_ctx(request: Request, key: str, default: Any = None):
|
||||
if request.route:
|
||||
value = getattr(request.route.ctx._cors, key, default)
|
||||
if value is not _default:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _add_origin_header(request: Request, response: HTTPResponse) -> None:
|
||||
request_origin = request.headers.get("origin")
|
||||
origin_value = ""
|
||||
allow_origins = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_allow_origins",
|
||||
request.app.ctx.cors.allow_origins,
|
||||
)
|
||||
fallback_origin = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_origin",
|
||||
request.app.config.CORS_ORIGINS,
|
||||
)
|
||||
|
||||
if request_origin:
|
||||
if request.app.ctx.cors.send_wildcard:
|
||||
origin_value = "*"
|
||||
else:
|
||||
for pattern in allow_origins:
|
||||
if pattern.match(request_origin):
|
||||
origin_value = request_origin
|
||||
break
|
||||
elif request.app.ctx.cors.always_send:
|
||||
if WILDCARD_PATTERN in allow_origins:
|
||||
origin_value = "*"
|
||||
else:
|
||||
if isinstance(fallback_origin, str) and "," not in fallback_origin:
|
||||
origin_value = fallback_origin
|
||||
else:
|
||||
origin_value = request.app.config.get("SERVER_NAME", "")
|
||||
|
||||
if origin_value:
|
||||
response.headers[ORIGIN_HEADER] = origin_value
|
||||
|
||||
|
||||
def _add_expose_header(request: Request, response: HTTPResponse) -> None:
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
headers = None
|
||||
expose_headers = _get_from_cors_ctx(
|
||||
request, "_cors_expose_headers", request.app.ctx.cors.expose_headers
|
||||
)
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials, it is
|
||||
# treated as the literal header name "*" without special semantics.
|
||||
# Note that the Authorization header can't be wildcarded and always
|
||||
# needs to be listed explicitly.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
|
||||
if not with_credentials and "*" in expose_headers:
|
||||
headers = ["*"]
|
||||
elif expose_headers:
|
||||
headers = expose_headers
|
||||
|
||||
if headers:
|
||||
response.headers[EXPOSE_HEADER] = ",".join(headers)
|
||||
|
||||
|
||||
def _add_credentials_header(request: Request, response: HTTPResponse) -> None:
|
||||
supports_credentials = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_supports_credentials",
|
||||
request.app.ctx.cors.supports_credentials,
|
||||
)
|
||||
if supports_credentials:
|
||||
response.headers[CREDENTIALS_HEADER] = "true"
|
||||
|
||||
|
||||
def _add_allow_header(request: Request, response: HTTPResponse) -> None:
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
request_headers = {
|
||||
h.strip().lower()
|
||||
for h in request.headers.get(REQUEST_HEADERS_HEADER, "").split(",")
|
||||
}
|
||||
allow_headers = _get_from_cors_ctx(
|
||||
request, "_cors_allow_headers", request.app.ctx.cors.allow_headers
|
||||
)
|
||||
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials,
|
||||
# it is treated as the literal header name "*" without special semantics.
|
||||
# Note that the Authorization header can't be wildcarded and always needs
|
||||
# to be listed explicitly.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
if not with_credentials and "*" in allow_headers:
|
||||
allow_headers = ["*"]
|
||||
else:
|
||||
allow_headers = request_headers & allow_headers
|
||||
|
||||
if allow_headers:
|
||||
response.headers[ALLOW_HEADERS_HEADER] = ",".join(allow_headers)
|
||||
|
||||
|
||||
def _add_max_age_header(request: Request, response: HTTPResponse) -> None:
|
||||
max_age = _get_from_cors_ctx(
|
||||
request, "_cors_max_age", request.app.ctx.cors.max_age
|
||||
)
|
||||
if max_age:
|
||||
response.headers[MAX_AGE_HEADER] = max_age
|
||||
|
||||
|
||||
def _add_methods_header(request: Request, response: HTTPResponse) -> None:
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials, it
|
||||
# is treated as the literal method name "*" without
|
||||
# special semantics.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
methods = None
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
allow_methods = _get_from_cors_ctx(
|
||||
request, "_cors_allow_methods", request.app.ctx.cors.allow_methods
|
||||
)
|
||||
|
||||
if not with_credentials and "*" in allow_methods:
|
||||
methods = {"*"}
|
||||
elif request.route:
|
||||
group = request.app.router.groups.get(request.route.segments)
|
||||
if group:
|
||||
group_methods = {method.lower() for method in group.methods}
|
||||
if allow_methods:
|
||||
methods = group_methods & allow_methods
|
||||
else:
|
||||
methods = group_methods
|
||||
|
||||
if methods:
|
||||
response.headers[ALLOW_METHODS_HEADER] = ",".join(methods).upper()
|
||||
|
||||
|
||||
def _add_vary_header(request: Request, response: HTTPResponse) -> None:
|
||||
allow_origins = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_allow_origins",
|
||||
request.app.ctx.cors.allow_origins,
|
||||
)
|
||||
if len(allow_origins) > 1:
|
||||
response.headers[VARY_HEADER] = "origin"
|
||||
|
||||
|
||||
def _get_allow_origins(app: Sanic) -> tuple[re.Pattern, ...]:
|
||||
origins = app.config.CORS_ORIGINS
|
||||
return _parse_allow_origins(origins)
|
||||
|
||||
|
||||
def _parse_allow_origins(
|
||||
value: Union[str, re.Pattern, list[Union[str, re.Pattern]]],
|
||||
) -> tuple[re.Pattern, ...]:
|
||||
origins: Optional[
|
||||
Union[list[str], list[re.Pattern], list[Union[str, re.Pattern]]]
|
||||
] = None
|
||||
if value and isinstance(value, str):
|
||||
if value == "*":
|
||||
origins = [WILDCARD_PATTERN]
|
||||
else:
|
||||
origins = [origin.strip() for origin in value.split(",")]
|
||||
elif isinstance(value, re.Pattern):
|
||||
origins = [value]
|
||||
elif isinstance(value, list):
|
||||
origins = value
|
||||
|
||||
return tuple(
|
||||
pattern
|
||||
if isinstance(pattern, re.Pattern)
|
||||
else re.compile(re.escape(pattern))
|
||||
for pattern in (origins or [])
|
||||
)
|
||||
|
||||
|
||||
def _get_expose_headers(app: Sanic) -> frozenset[str]:
|
||||
expose_headers = (
|
||||
(
|
||||
app.config.CORS_EXPOSE_HEADERS
|
||||
if isinstance(
|
||||
app.config.CORS_EXPOSE_HEADERS, (list, set, frozenset, tuple)
|
||||
)
|
||||
else app.config.CORS_EXPOSE_HEADERS.split(",")
|
||||
)
|
||||
if app.config.CORS_EXPOSE_HEADERS
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(header.lower() for header in expose_headers)
|
||||
|
||||
|
||||
def _get_allow_headers(app: Sanic) -> frozenset[str]:
|
||||
return _parse_allow_headers(app.config.CORS_ALLOW_HEADERS)
|
||||
|
||||
|
||||
def _parse_allow_headers(value: str) -> frozenset[str]:
|
||||
allow_headers = (
|
||||
(
|
||||
value
|
||||
if isinstance(
|
||||
value,
|
||||
(list, set, frozenset, tuple),
|
||||
)
|
||||
else value.split(",")
|
||||
)
|
||||
if value
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(header.lower() for header in allow_headers)
|
||||
|
||||
|
||||
def _get_max_age(app: Sanic) -> str:
|
||||
return _parse_max_age(app.config.CORS_MAX_AGE or "")
|
||||
|
||||
|
||||
def _parse_max_age(value) -> str:
|
||||
max_age = value or ""
|
||||
if isinstance(max_age, timedelta):
|
||||
max_age = str(int(max_age.total_seconds()))
|
||||
return str(max_age)
|
||||
|
||||
|
||||
def _get_allow_methods(app: Sanic) -> frozenset[str]:
|
||||
return _parse_allow_methods(app.config.CORS_METHODS)
|
||||
|
||||
|
||||
def _parse_allow_methods(value) -> frozenset[str]:
|
||||
allow_methods = (
|
||||
(
|
||||
value
|
||||
if isinstance(
|
||||
value,
|
||||
(list, set, frozenset, tuple),
|
||||
)
|
||||
else value.split(",")
|
||||
)
|
||||
if value
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(method.lower() for method in allow_methods)
|
||||
|
||||
|
||||
def _is_request_with_credentials(request: Request) -> bool:
|
||||
return bool(request.headers.get("authorization") or request.cookies)
|
||||
@@ -0,0 +1,34 @@
|
||||
from ...exceptions import InitError
|
||||
from ..base import Extension
|
||||
from .cors import add_cors
|
||||
from .methods import add_auto_handlers, add_http_methods
|
||||
|
||||
|
||||
class HTTPExtension(Extension):
|
||||
name = "http"
|
||||
|
||||
def startup(self, _) -> None:
|
||||
self.all_methods: bool = self.config.HTTP_ALL_METHODS
|
||||
self.auto_head: bool = self.config.HTTP_AUTO_HEAD
|
||||
self.auto_options: bool = self.config.HTTP_AUTO_OPTIONS
|
||||
self.auto_trace: bool = self.config.HTTP_AUTO_TRACE
|
||||
self.cors: bool = self.config.CORS
|
||||
|
||||
if self.all_methods:
|
||||
add_http_methods(self.app, ["CONNECT", "TRACE"])
|
||||
|
||||
if self.auto_head or self.auto_options or self.auto_trace:
|
||||
add_auto_handlers(
|
||||
self.app, self.auto_head, self.auto_options, self.auto_trace
|
||||
)
|
||||
|
||||
if self.cors:
|
||||
add_cors(self.app)
|
||||
else:
|
||||
return
|
||||
|
||||
if self.app.ctx.cors.automatic_options and not self.auto_options:
|
||||
raise InitError(
|
||||
"Configuration mismatch. If CORS_AUTOMATIC_OPTIONS is set to "
|
||||
"True, then you must run SanicExt with auto_options=True"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
from collections.abc import Sequence
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from operator import itemgetter
|
||||
from typing import Union
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.constants import HTTPMethod
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.response import empty, raw
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
from sanic_ext.utils.route import clean_route_name
|
||||
|
||||
|
||||
def add_http_methods(
|
||||
app: Sanic, methods: Sequence[Union[str, HTTPMethod]]
|
||||
) -> None:
|
||||
"""
|
||||
Adds HTTP methods to an app
|
||||
|
||||
:param app: Your Sanic app
|
||||
:type app: Sanic
|
||||
:param methods: The http methods being added, eg: CONNECT, TRACE
|
||||
:type methods: Sequence[str]
|
||||
"""
|
||||
|
||||
app.router.ALLOWED_METHODS = tuple(
|
||||
[*app.router.ALLOWED_METHODS, *methods] # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def add_auto_handlers(
|
||||
app: Sanic, auto_head: bool, auto_options: bool, auto_trace: bool
|
||||
) -> None:
|
||||
if auto_trace and "TRACE" not in app.router.ALLOWED_METHODS:
|
||||
raise SanicException(
|
||||
"Cannot use apply(..., auto_trace=True) if TRACE is not an "
|
||||
"allowed HTTP method. Make sure apply(..., all_http_methods=True) "
|
||||
"has been set."
|
||||
)
|
||||
|
||||
async def head_handler(request, get_handler, *args, **kwargs):
|
||||
retval = get_handler(request, *args, **kwargs)
|
||||
if isawaitable(retval):
|
||||
retval = await retval
|
||||
return retval
|
||||
|
||||
async def options_handler(request, methods, *args, **kwargs):
|
||||
resp = empty()
|
||||
resp.headers["allow"] = ",".join([*methods, "OPTIONS"])
|
||||
return resp
|
||||
|
||||
async def trace_handler(request):
|
||||
cleaned_head = b""
|
||||
for line in request.head.split(b"\r\n"):
|
||||
first_word, _ = line.split(b" ", 1)
|
||||
|
||||
if (
|
||||
first_word.lower().replace(b":", b"").decode("utf-8")
|
||||
not in request.app.config.TRACE_EXCLUDED_HEADERS
|
||||
):
|
||||
cleaned_head += line + b"\r\n"
|
||||
|
||||
message = "\r\n\r\n".join(
|
||||
[part.decode("utf-8") for part in [cleaned_head, request.body]]
|
||||
)
|
||||
return raw(message, content_type="message/http")
|
||||
|
||||
@app.before_server_start(priority=PRIORITY)
|
||||
def _add_handlers(app, _):
|
||||
nonlocal auto_head
|
||||
nonlocal auto_options
|
||||
|
||||
if auto_head:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "GET" in group.methods and "HEAD" not in group.methods:
|
||||
for route in group:
|
||||
if "GET" in route.methods:
|
||||
host = route.requirements.get("host")
|
||||
name = f"{route.name}_head"
|
||||
handler = openapi.definition(
|
||||
summary=clean_route_name(route.name).title(),
|
||||
description="Retrieve HEAD details",
|
||||
)(partial(head_handler, get_handler=route.handler))
|
||||
handler.__auto_handler__ = True
|
||||
handler.__route_handler__ = route.handler
|
||||
app.add_route(
|
||||
handler=handler,
|
||||
uri=group.uri,
|
||||
methods=["HEAD"],
|
||||
strict_slashes=group.strict,
|
||||
name=name,
|
||||
host=host,
|
||||
unquote=group.unquote,
|
||||
)
|
||||
app.finalize()
|
||||
|
||||
if auto_trace:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "TRACE" not in group.methods:
|
||||
app.add_route(
|
||||
handler=trace_handler,
|
||||
uri=group.uri,
|
||||
methods=["TRACE"],
|
||||
strict_slashes=group.strict,
|
||||
)
|
||||
app.finalize()
|
||||
|
||||
if auto_options:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "OPTIONS" not in group.methods:
|
||||
if not group.requirements:
|
||||
hosts = [None]
|
||||
else:
|
||||
hosts = set(
|
||||
map(itemgetter("host"), group.requirements)
|
||||
)
|
||||
|
||||
try:
|
||||
base_route = next(
|
||||
r for r in group if not r.name.endswith("_head")
|
||||
)
|
||||
except StopIteration:
|
||||
base_route = group[0]
|
||||
|
||||
name = f"{base_route.name}_options"
|
||||
handler = openapi.definition(
|
||||
summary=clean_route_name(base_route.name).title(),
|
||||
description="Retrieve OPTIONS details",
|
||||
)(partial(options_handler, methods=group.methods))
|
||||
handler.__auto_handler__ = True
|
||||
app.add_route(
|
||||
handler=handler,
|
||||
uri=group.uri,
|
||||
methods=["OPTIONS"],
|
||||
strict_slashes=group.strict,
|
||||
name=name,
|
||||
host=hosts,
|
||||
unquote=group.unquote,
|
||||
)
|
||||
app.finalize()
|
||||
Reference in New Issue
Block a user