diff --git a/sanic/__init__.py b/sanic/__init__.py index 64961760..67394ae6 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -2,6 +2,22 @@ from sanic.__version__ import __version__ from sanic.app import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTPMethod +from sanic.exceptions import ( + BadRequest, + ExpectationFailed, + FileNotFound, + Forbidden, + HeaderNotFound, + InternalServerError, + InvalidHeader, + MethodNotAllowed, + NotFound, + RangeNotSatisfiable, + SanicException, + ServerError, + ServiceUnavailable, + Unauthorized, +) from sanic.request import Request from sanic.response import ( HTTPResponse, @@ -9,6 +25,7 @@ from sanic.response import ( file, html, json, + raw, redirect, text, ) @@ -17,16 +34,34 @@ from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket __all__ = ( "__version__", + # Common objects "Sanic", "Blueprint", "HTTPMethod", "HTTPResponse", "Request", "Websocket", + # Common exceptions + "BadRequest", + "ExpectationFailed", + "FileNotFound", + "Forbidden", + "HeaderNotFound", + "InternalServerError", + "InvalidHeader", + "MethodNotAllowed", + "NotFound", + "RangeNotSatisfiable", + "SanicException", + "ServerError", + "ServiceUnavailable", + "Unauthorized", + # Common response methods "empty", "file", "html", "json", + "raw", "redirect", "text", ) diff --git a/sanic/app.py b/sanic/app.py index 51f7db19..6c784540 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -16,7 +16,7 @@ from asyncio import ( ) from asyncio.futures import Future from collections import defaultdict, deque -from contextlib import suppress +from contextlib import contextmanager, suppress from functools import partial from inspect import isawaitable from os import environ @@ -33,6 +33,7 @@ from typing import ( Deque, Dict, Iterable, + Iterator, List, Optional, Set, @@ -91,6 +92,7 @@ from sanic.signals import Signal, SignalRouter from sanic.touchup import TouchUp, TouchUpMeta from sanic.types.shared_ctx import SharedContext from sanic.worker.inspector import Inspector +from sanic.worker.loader import CertLoader from sanic.worker.manager import WorkerManager @@ -138,6 +140,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): "_test_client", "_test_manager", "blueprints", + "certloader_class", "config", "configure_logging", "ctx", @@ -180,6 +183,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): loads: Optional[Callable[..., Any]] = None, inspector: bool = False, inspector_class: Optional[Type[Inspector]] = None, + certloader_class: Optional[Type[CertLoader]] = None, ) -> None: super().__init__(name=name) # logging @@ -214,6 +218,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): self.asgi = False self.auto_reload = False self.blueprints: Dict[str, Blueprint] = {} + self.certloader_class: Type[CertLoader] = ( + certloader_class or CertLoader + ) self.configure_logging: bool = configure_logging self.ctx: Any = ctx or SimpleNamespace() self.error_handler: ErrorHandler = error_handler or ErrorHandler() @@ -436,14 +443,15 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): ctx = params.pop("route_context") - routes = self.router.add(**params) - if isinstance(routes, Route): - routes = [routes] + with self.amend(): + routes = self.router.add(**params) + if isinstance(routes, Route): + routes = [routes] - for r in routes: - r.extra.websocket = websocket - r.extra.static = params.get("static", False) - r.ctx.__dict__.update(ctx) + for r in routes: + r.extra.websocket = websocket + r.extra.static = params.get("static", False) + r.ctx.__dict__.update(ctx) return routes @@ -452,17 +460,19 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): middleware: FutureMiddleware, route_names: Optional[List[str]] = None, ): - if route_names: - return self.register_named_middleware( - middleware.middleware, route_names, middleware.attach_to - ) - else: - return self.register_middleware( - middleware.middleware, middleware.attach_to - ) + with self.amend(): + if route_names: + return self.register_named_middleware( + middleware.middleware, route_names, middleware.attach_to + ) + else: + return self.register_middleware( + middleware.middleware, middleware.attach_to + ) def _apply_signal(self, signal: FutureSignal) -> Signal: - return self.signal_router.add(*signal) + with self.amend(): + return self.signal_router.add(*signal) def dispatch( self, @@ -1523,6 +1533,27 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta): # Lifecycle # -------------------------------------------------------------------- # + @contextmanager + def amend(self) -> Iterator[None]: + """ + If the application has started, this function allows changes + to be made to add routes, middleware, and signals. + """ + if not self.state.is_started: + yield + else: + do_router = self.router.finalized + do_signal_router = self.signal_router.finalized + if do_router: + self.router.reset() + if do_signal_router: + self.signal_router.reset() + yield + if do_signal_router: + self.signalize(self.config.TOUCHUP) + if do_router: + self.finalize() + def finalize(self): try: self.router.finalize() diff --git a/sanic/asgi.py b/sanic/asgi.py index e5c07c2a..27285c35 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Optional from urllib.parse import quote from sanic.compat import Header -from sanic.exceptions import ServerError +from sanic.exceptions import BadRequest, ServerError from sanic.helpers import Default from sanic.http import Stage from sanic.log import error_logger, logger @@ -132,12 +132,20 @@ class ASGIApp: instance.sanic_app.state.is_started = True setattr(instance.transport, "add_task", sanic_app.loop.create_task) - headers = Header( - [ - (key.decode("latin-1"), value.decode("latin-1")) - for key, value in scope.get("headers", []) - ] - ) + try: + headers = Header( + [ + ( + key.decode("ASCII"), + value.decode(errors="surrogateescape"), + ) + for key, value in scope.get("headers", []) + ] + ) + except UnicodeDecodeError: + raise BadRequest( + "Header names can only contain US-ASCII characters" + ) path = ( scope["path"][1:] if scope["path"].startswith("/") diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 8baac0a4..51c0bb7f 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,5 +1,6 @@ -from asyncio import CancelledError -from typing import Any, Dict, Optional, Union +from asyncio import CancelledError, Protocol +from os import PathLike +from typing import Any, Dict, Optional, Sequence, Union from sanic.helpers import STATUS_CODES @@ -9,51 +10,158 @@ class RequestCancelled(CancelledError): class ServerKilled(Exception): - ... + """ + Exception Sanic server uses when killing a server process for something + unexpected happening. + """ class SanicException(Exception): + """ + Generic exception that will generate an HTTP response when raised + in the context of a request lifecycle. + + Usually it is best practice to use one of the more specific exceptions + than this generic. Even when trying to raise a 500, it is generally + preferrable to use :class:`.ServerError` + + .. code-block:: python + + raise SanicException( + "Something went wrong", + status_code=999, + context={ + "info": "Some additional details", + }, + headers={ + "X-Foo": "bar" + } + ) + + :param message: The message to be sent to the client. If ``None`` + then the appropriate HTTP response status message will be used + instead, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param status_code: The HTTP response code to send, if applicable. If + ``None``, then it will be 500, defaults to None + :type status_code: Optional[int], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional + """ + + status_code: int = 500 + quiet: Optional[bool] = False + headers: Dict[str, str] = {} message: str = "" def __init__( self, message: Optional[Union[str, bytes]] = None, status_code: Optional[int] = None, + *, quiet: Optional[bool] = None, context: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, ) -> None: self.context = context self.extra = extra + status_code = status_code or getattr( + self.__class__, "status_code", None + ) + quiet = quiet or getattr(self.__class__, "quiet", None) + headers = headers or getattr(self.__class__, "headers", {}) if message is None: if self.message: message = self.message - elif status_code is not None: + elif status_code: msg: bytes = STATUS_CODES.get(status_code, b"") message = msg.decode("utf8") super().__init__(message) - if status_code is not None: - self.status_code = status_code - - # quiet=None/False/True with None meaning choose by status - if quiet or quiet is None and status_code not in (None, 500): - self.quiet = True + self.status_code = status_code + self.quiet = quiet + self.headers = headers -class NotFound(SanicException): +class HTTPException(SanicException): + """ + A base class for other exceptions and should not be called directly. + """ + + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + + +class NotFound(HTTPException): """ **Status**: 404 Not Found + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Not Found' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 404 quiet = True -class BadRequest(SanicException): +class BadRequest(HTTPException): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 400 @@ -61,51 +169,133 @@ class BadRequest(SanicException): InvalidUsage = BadRequest +BadURL = BadRequest -class BadURL(BadRequest): - ... - - -class MethodNotAllowed(SanicException): +class MethodNotAllowed(HTTPException): """ **Status**: 405 Method Not Allowed + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Method Not Allowed' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param method: The HTTP method that was used, defaults to an empty string + :type method: Optional[str], optional + :param allowed_methods: The HTTP methods that can be used instead of the + one that was attempted + :type allowed_methods: Optional[Sequence[str]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 405 quiet = True - def __init__(self, message, method, allowed_methods): - super().__init__(message) - self.headers = {"Allow": ", ".join(allowed_methods)} + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + method: str = "", + allowed_methods: Optional[Sequence[str]] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + if allowed_methods: + self.headers = { + **self.headers, + "Allow": ", ".join(allowed_methods), + } + self.method = method + self.allowed_methods = allowed_methods MethodNotSupported = MethodNotAllowed -class ServerError(SanicException): +class ServerError(HTTPException): """ **Status**: 500 Internal Server Error + + A general server-side error has occurred. If no other HTTP exception is + appropriate, then this should be used + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Internal Server Error' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 500 -class ServiceUnavailable(SanicException): +InternalServerError = ServerError + + +class ServiceUnavailable(HTTPException): """ **Status**: 503 Service Unavailable The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state. + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 503 quiet = True -class URLBuildError(ServerError): +class URLBuildError(HTTPException): """ **Status**: 500 Internal Server Error + + An exception used by Sanic internals when unable to build a URL. """ status_code = 500 @@ -114,30 +304,77 @@ class URLBuildError(ServerError): class FileNotFound(NotFound): """ **Status**: 404 Not Found + + A specific form of :class:`.NotFound` that is specifically when looking + for a file on the file system at a known path. + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Not Found' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param path: The path, if any, to the file that could not + be found, defaults to None + :type path: Optional[PathLike], optional + :param relative_url: A relative URL of the file, defaults to None + :type relative_url: Optional[str], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ - def __init__(self, message, path, relative_url): - super().__init__(message) + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + path: Optional[PathLike] = None, + relative_url: Optional[str] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) self.path = path self.relative_url = relative_url -class RequestTimeout(SanicException): - """The Web server (running the Web site) thinks that there has been too +class RequestTimeout(HTTPException): + """ + The Web server (running the Web site) thinks that there has been too long an interval of time between 1) the establishment of an IP connection (socket) between the client and the server and 2) the receipt of any data on that socket, so the server has dropped the connection. The socket connection has actually been lost - the Web server has 'timed out' on that particular socket connection. + + This is an internal exception thrown by Sanic and should not be used + directly. """ status_code = 408 quiet = True -class PayloadTooLarge(SanicException): +class PayloadTooLarge(HTTPException): """ **Status**: 413 Payload Too Large + + This is an internal exception thrown by Sanic and should not be used + directly. """ status_code = 413 @@ -147,34 +384,126 @@ class PayloadTooLarge(SanicException): class HeaderNotFound(BadRequest): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ class InvalidHeader(BadRequest): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ -class RangeNotSatisfiable(SanicException): +class ContentRange(Protocol): + total: int + + +class RangeNotSatisfiable(HTTPException): """ **Status**: 416 Range Not Satisfiable + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Range Not Satisfiable' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param content_range: An object meeting the :class:`.ContentRange` protocol + that has a ``total`` property, defaults to None + :type content_range: Optional[ContentRange], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 416 quiet = True - def __init__(self, message, content_range): - super().__init__(message) - self.headers = {"Content-Range": f"bytes */{content_range.total}"} + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + content_range: Optional[ContentRange] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + if content_range is not None: + self.headers = { + **self.headers, + "Content-Range": f"bytes */{content_range.total}", + } ContentRangeError = RangeNotSatisfiable -class ExpectationFailed(SanicException): +class ExpectationFailed(HTTPException): """ **Status**: 417 Expectation Failed + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Expectation Failed' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 417 @@ -184,9 +513,25 @@ class ExpectationFailed(SanicException): HeaderExpectationFailed = ExpectationFailed -class Forbidden(SanicException): +class Forbidden(HTTPException): """ **Status**: 403 Forbidden + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Forbidden' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 403 @@ -202,20 +547,33 @@ class InvalidRangeType(RangeNotSatisfiable): quiet = True -class PyFileError(Exception): - def __init__(self, file): - super().__init__("could not execute config file %s", file) +class PyFileError(SanicException): + def __init__( + self, + file, + status_code: Optional[int] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + "could not execute config file %s" % file, + status_code=status_code, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) -class Unauthorized(SanicException): +class Unauthorized(HTTPException): """ **Status**: 401 Unauthorized - :param message: Message describing the exception. - :param status_code: HTTP Status code. - :param scheme: Name of the authentication scheme to be used. - - When present, kwargs is used to complete the WWW-Authentication header. + When present, additional keyword arguments may be used to complete + the WWW-Authentication header. Examples:: @@ -240,21 +598,58 @@ class Unauthorized(SanicException): raise Unauthorized("Auth required.", scheme="Bearer", realm="Restricted Area") + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param scheme: Name of the authentication scheme to be used. + :type scheme: Optional[str], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 401 quiet = True - def __init__(self, message, status_code=None, scheme=None, **kwargs): - super().__init__(message, status_code) + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + scheme: Optional[str] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + **challenges, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) # if auth-scheme is specified, set "WWW-Authenticate" header if scheme is not None: - values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()] + values = [ + '{!s}="{!s}"'.format(k, v) for k, v in challenges.items() + ] challenge = ", ".join(values) self.headers = { - "WWW-Authenticate": f"{scheme} {challenge}".rstrip() + **self.headers, + "WWW-Authenticate": f"{scheme} {challenge}".rstrip(), } diff --git a/sanic/handlers/content_range.py b/sanic/handlers/content_range.py index f3a23d1a..a95a677b 100644 --- a/sanic/handlers/content_range.py +++ b/sanic/handlers/content_range.py @@ -75,4 +75,4 @@ class ContentRangeHandler: } def __bool__(self): - return self.size > 0 + return hasattr(self, "size") and self.size > 0 diff --git a/sanic/http/http1.py b/sanic/http/http1.py index 304e5e5e..5884d0b4 100644 --- a/sanic/http/http1.py +++ b/sanic/http/http1.py @@ -428,7 +428,9 @@ class Http(Stream, metaclass=TouchUpMeta): if self.request is None: self.create_empty_request() - request_middleware = not isinstance(exception, ServiceUnavailable) + request_middleware = not isinstance( + exception, (ServiceUnavailable, RequestCancelled) + ) try: await app.handle_exception( self.request, exception, request_middleware diff --git a/sanic/http/tls/context.py b/sanic/http/tls/context.py index 98c090bb..b210530f 100644 --- a/sanic/http/tls/context.py +++ b/sanic/http/tls/context.py @@ -159,7 +159,7 @@ class CertSimple(SanicSSLContext): # try common aliases, rename to cert/key certfile = kw["cert"] = kw.pop("certificate", None) or cert keyfile = kw["key"] = kw.pop("keyfile", None) or key - password = kw.pop("password", None) + password = kw.get("password", None) if not certfile or not keyfile: raise ValueError("SSL dict needs filenames for cert and key.") subject = {} diff --git a/sanic/log.py b/sanic/log.py index f6781e6d..f234a0ef 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -62,13 +62,13 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov }, formatters={ "generic": { - "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", + "format": "%(asctime)s [%(process)s] [%(levelname)s] %(message)s", "datefmt": "[%Y-%m-%d %H:%M:%S %z]", "class": "logging.Formatter", }, "access": { "format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: " - + "%(request)s %(message)s %(status)d %(byte)d", + + "%(request)s %(message)s %(status)s %(byte)s", "datefmt": "[%Y-%m-%d %H:%M:%S %z]", "class": "logging.Formatter", }, diff --git a/sanic/mixins/startup.py b/sanic/mixins/startup.py index 4390196d..b5fc1f30 100644 --- a/sanic/mixins/startup.py +++ b/sanic/mixins/startup.py @@ -811,7 +811,7 @@ class StartupMixin(metaclass=SanicMeta): ssl = kwargs.get("ssl") if isinstance(ssl, SanicSSLContext): - kwargs["ssl"] = kwargs["ssl"].sanic + kwargs["ssl"] = ssl.sanic manager = WorkerManager( primary.state.workers, diff --git a/sanic/response/convenience.py b/sanic/response/convenience.py index 429b3214..d8a30597 100644 --- a/sanic/response/convenience.py +++ b/sanic/response/convenience.py @@ -148,7 +148,26 @@ async def validate_file( last_modified = datetime.fromtimestamp( float(last_modified), tz=timezone.utc ).replace(microsecond=0) - if last_modified <= if_modified_since: + + if ( + last_modified.utcoffset() is None + and if_modified_since.utcoffset() is not None + ): + logger.warning( + "Cannot compare tz-aware and tz-naive datetimes. To avoid " + "this conflict Sanic is converting last_modified to UTC." + ) + last_modified.replace(tzinfo=timezone.utc) + elif ( + last_modified.utcoffset() is not None + and if_modified_since.utcoffset() is None + ): + logger.warning( + "Cannot compare tz-aware and tz-naive datetimes. To avoid " + "this conflict Sanic is converting if_modified_since to UTC." + ) + if_modified_since.replace(tzinfo=timezone.utc) + if last_modified.timestamp() <= if_modified_since.timestamp(): return HTTPResponse(status=304) diff --git a/sanic/server/protocols/websocket_protocol.py b/sanic/server/protocols/websocket_protocol.py index ce9c6244..52a33bf8 100644 --- a/sanic/server/protocols/websocket_protocol.py +++ b/sanic/server/protocols/websocket_protocol.py @@ -10,7 +10,7 @@ except ImportError: # websockets >= 11.0 from websockets.typing import Subprotocol -from sanic.exceptions import ServerError +from sanic.exceptions import SanicException from sanic.log import logger from sanic.server import HttpProtocol @@ -123,7 +123,7 @@ class WebSocketProtocol(HttpProtocol): "Failed to open a WebSocket connection.\n" "See server log for more information.\n" ) - raise ServerError(msg, status_code=500) + raise SanicException(msg, status_code=500) if 100 <= resp.status_code <= 299: first_line = ( f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n" @@ -138,7 +138,7 @@ class WebSocketProtocol(HttpProtocol): rbody += b"\r\n\r\n" await super().send(rbody) else: - raise ServerError(resp.body, resp.status_code) + raise SanicException(resp.body, resp.status_code) self.websocket = WebsocketImplProtocol( ws_proto, ping_interval=self.websocket_ping_interval, diff --git a/sanic/worker/loader.py b/sanic/worker/loader.py index 344593db..d29f4c68 100644 --- a/sanic/worker/loader.py +++ b/sanic/worker/loader.py @@ -5,6 +5,7 @@ import sys from importlib import import_module from pathlib import Path +from ssl import SSLContext from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast from sanic.http.tls.context import process_to_context @@ -103,8 +104,16 @@ class CertLoader: "trustme": TrustmeCreator, } - def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]): + def __init__( + self, + ssl_data: Optional[ + Union[SSLContext, Dict[str, Union[str, os.PathLike]]] + ], + ): self._ssl_data = ssl_data + self._creator_class = None + if not ssl_data or not isinstance(ssl_data, dict): + return creator_name = cast(str, ssl_data.get("creator")) diff --git a/sanic/worker/serve.py b/sanic/worker/serve.py index 39c647b2..583d3eaf 100644 --- a/sanic/worker/serve.py +++ b/sanic/worker/serve.py @@ -73,8 +73,8 @@ def worker_serve( info.settings["app"] = a a.state.server_info.append(info) - if isinstance(ssl, dict): - cert_loader = CertLoader(ssl) + if isinstance(ssl, dict) or app.certloader_class is not CertLoader: + cert_loader = app.certloader_class(ssl or {}) ssl = cert_loader.load(app) for info in app.state.server_info: info.settings["ssl"] = ssl diff --git a/tests/certs/password/fullchain.pem b/tests/certs/password/fullchain.pem new file mode 100644 index 00000000..99232020 --- /dev/null +++ b/tests/certs/password/fullchain.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUa7OOlAGQfXOgUgRENJ9GbUgO7kwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDMyMDA3MzE1M1oXDTIzMDQx +OTA3MzE1M1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAn2/RqVpzO7GFrgVGiowR5CzcFzf1tSFti1K/WIGr/jsu +NP+1R3sim17pgg6SCOFnUMRS0KnDihkzoeP6z+0tFsrbCH4V1+fq0iud8WgYQrgD +3ttUcHrz04p7wsMoeqndUQoLbyJzP8MpA2XJsoacdIVkuLv2AESGXLhJym/e9HGN +g8bqdz25X0hVTczZW1FN9AZyWWVf9Go6jqC7LCaOnYXAnOkEy2/JHdkeNXYFZHB3 +71UemfkCjfp0vlRV8pVpkBGMhRNFphBTfxdqeWiGQwVqrhaJO4M7DJlQHCAPY16P +o9ywnhLDhFHD7KIfTih9XxrdgTowqcwyGX3e3aJpTwIDAQABo1MwUTAdBgNVHQ4E +FgQU5NogMq6mRBeGl4i6hIuUlcR2bVEwHwYDVR0jBBgwFoAU5NogMq6mRBeGl4i6 +hIuUlcR2bVEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYW34 +JY1kd0UO5HE41oxJD4PioQboXXX0al4RgKaUUsPykeHQbK0q0TSYAZLwRjooTVUO +Wvna5bU2mzyULqA2r/Cr/w4zb9xybO3SiHFHcU1RacouauHXROHwRm98i8A73xnH +vHws5BADr2ggnVcPNh4VOQ9ZvBlC7jhgpvMjqOEu5ZPCovhfZYfSsvBDHcD74ZYm +Di9DvqsJmrb23Dv3SUykm3W+Ql2q+JyjFj30rhD89CFwJ9iSlFwTYEwZLHA+mV6p +UKy3I3Fiht1Oc+nIivX5uhRSMbDVvDTVHbjjPujxxFjkiHXMjtwvwfg4Sb6du61q +AjBRFyXbNu4hZkkHOA== +-----END CERTIFICATE----- diff --git a/tests/certs/password/privkey.pem b/tests/certs/password/privkey.pem new file mode 100644 index 00000000..b6b828b9 --- /dev/null +++ b/tests/certs/password/privkey.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQI94UBqjaZlG4CAggA +MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBCvJhEy+3/+0Ec0gpd5dkP6BIIE +0E7rLplTe9rxK3sR9V0cx8Xn6V+uFhG3p7dzeMDCCKpGo9MEaacF5m+paGnBkMlH +Pz3rRoLA5jqzwXl4US/C5E1Or//2YBgF1XXKi3BPF/bVx/g6vR+xeobf9kQGbqQk +FNPYtP7mpg2dekp5BUsKSosIt8BkknWFvhBeNuGZT/zlMUuq1WpMe4KIh/W9IdNr +HolcuZJWBhQAwGPciWIZRyq48wKa++W7Jdg/aG8FviJQnjaAUv4CyZJHUJnaNwUx +iHOETpzIC+bhF2K+s4g5w68VCj6Jtz78sIBEZKzo7LI5QHdRHqYB5SJ/dGiV+h09 +R/rQ/M+24mwHDlRSCxxq0yuDwUuGBlHyATeDCFeE3L5OX8yTLuqYJ6vUa6UbzMYA +8H4l5zfu9RrAhKYa9tD+4ONxMmHziIgmn5zvSXeBwJKfeUbnN4IKWLsSoSVspBRh +zLl51DMAnem4NEjLfIW8WYjhsvSYwd9BYqxXaAiv4Wjx9ZV1yLqFICC7tejpVdRT +afI0qMOfWu4ma6xVBg1ezLgF1wHIPrq6euTvWdnifYQopVICALlltEo5oxQ2i/OM +NY8RyovWujiGNsa3pId9HmZXiLyLXjKPstGWRK4liMyc2EiP099gTdBvrb+VQp+I +EyPavmh3WNhgZGOh3qah39X8HrBprc0PPfSPlxpaWdNMIIMSbcIWWdJEA/e4tcy/ +uBaV4H3sNCtBApgrb6B9YUbS9CXNUburJo19T1sk2uCaO12qYfdu2IDEnFf8JiF3 +i7nyftotRuoKq2D+V8d0PeMi/vJSo6+eZIn7VNe6ejYf+w0s7sxlpiKVzkslyOhq +n0T4M3ZkSwGIETzgkRRuTY1OK7slhglMgXlQ2FuIUUo6CRg9WjRJvI5rujLzLWfB +hkgP8STirjTV0DUWPFGtUcenvEcZPkYIQcoPHxOJGNW3ZPXNpt4RjbvPLeVzDm0O +WJiay/qhag/bXGqKraO3b6Y7FOzJa8kG4G0XrcFY1s2oCXRqRqYJAtwaEeVCjCSJ +Qy0OZkqcJEU7pv98pLMpG9OWz4Gle77g4KoQUJjQGtmg0MUMoPd0iPRmvkxsYg8E +Q9uZS3m6PpWmmYDY0Ik1w/4avs3skl2mW3dqcZGLEepkjiQSnFABsuvxKd+uIEQy +lyf9FrynXVcUI87LUkuniLRKwZZzFALVuc+BwtO3SA5mvEK22ZEq9QOysbwlpN54 +G5xXJKJEeexUSjEUIij4J89RLsXldibhp7YYZ7rFviR6chIqC0V7G6VqAM9TOCrV +PWZXr3ZY5/pCZYs5DYKFJBFMSQ2UT/++VYxdZCeBH75vaxugbS8RdUM+iVDevWpQ +/AnP1FolNAgkVhi3Rw4L16SibkqpEzIi1svPWKMwXdvewA32UidLElhuTWWjI2Wm +veXhmEqwk/7ML4JMI7wHcDQdvSKen0mCL2J9tB7A/pewYyDE0ffIUmjxglOtw30f +ZOlQKhMaKJGXp00U2zsHA2NJRI/hThbJncsnZyvuLei0P42RrF+r64b/0gUH6IZ5 +wPUttT815KSNoy+XXXum9YGDYYFoAL+6WVEkl6dgo+X0hcH7DDf5Nkewiq8UcJGh +/69vFIfp+JlpicXzZ+R42LO3T3luC907aFBywF3pmi// +-----END ENCRYPTED PRIVATE KEY----- diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 6b6872ac..af44ead3 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -7,6 +7,9 @@ from unittest.mock import call import pytest import uvicorn +from httpx import Headers +from pytest import MonkeyPatch + from sanic import Sanic from sanic.application.state import Mode from sanic.asgi import ASGIApp, Lifespan, MockTransport @@ -626,3 +629,26 @@ async def test_error_on_lifespan_exception_stop(app: Sanic): ) ] ) + + +@pytest.mark.asyncio +async def test_asgi_headers_decoding(app: Sanic, monkeypatch: MonkeyPatch): + @app.get("/") + def handler(request: Request): + return text("") + + headers_init = Headers.__init__ + + def mocked_headers_init(self, *args, **kwargs): + if "encoding" in kwargs: + kwargs.pop("encoding") + headers_init(self, encoding="utf-8", *args, **kwargs) + + monkeypatch.setattr(Headers, "__init__", mocked_headers_init) + + message = "Header names can only contain US-ASCII characters" + with pytest.raises(BadRequest, match=message): + _, response = await app.asgi_client.get("/", headers={"😂": "😅"}) + + _, response = await app.asgi_client.get("/", headers={"Test-Header": "😅"}) + assert response.status_code == 200 diff --git a/tests/test_late_adds.py b/tests/test_late_adds.py new file mode 100644 index 00000000..f7281d38 --- /dev/null +++ b/tests/test_late_adds.py @@ -0,0 +1,54 @@ +import pytest + +from sanic import Sanic, text + + +@pytest.fixture +def late_app(app: Sanic): + app.config.TOUCHUP = False + app.get("/")(lambda _: text("")) + return app + + +def test_late_route(late_app: Sanic): + @late_app.before_server_start + async def late(app: Sanic): + @app.get("/late") + def handler(_): + return text("late") + + _, response = late_app.test_client.get("/late") + assert response.status_code == 200 + assert response.text == "late" + + +def test_late_middleware(late_app: Sanic): + @late_app.get("/late") + def handler(request): + return text(request.ctx.late) + + @late_app.before_server_start + async def late(app: Sanic): + @app.on_request + def handler(request): + request.ctx.late = "late" + + _, response = late_app.test_client.get("/late") + assert response.status_code == 200 + assert response.text == "late" + + +def test_late_signal(late_app: Sanic): + @late_app.get("/late") + def handler(request): + return text(request.ctx.late) + + @late_app.before_server_start + async def late(app: Sanic): + @app.signal("http.lifecycle.request") + def handler(request): + request.ctx.late = "late" + + _, response = late_app.test_client.get("/late") + assert response.status_code == 200 + assert response.text == "late" diff --git a/tests/test_response_file.py b/tests/test_response_file.py new file mode 100644 index 00000000..366e613e --- /dev/null +++ b/tests/test_response_file.py @@ -0,0 +1,55 @@ +from datetime import datetime, timezone +from logging import INFO + +import pytest + +from sanic.compat import Header +from sanic.response.convenience import validate_file + + +@pytest.mark.parametrize( + "ifmod,lastmod,expected", + ( + ("Sat, 01 Apr 2023 00:00:00 GMT", 1672524000, None), + ( + "Sat, 01 Apr 2023 00:00:00", + 1672524000, + "converting if_modified_since", + ), + ( + "Sat, 01 Apr 2023 00:00:00 GMT", + datetime(2023, 1, 1, 0, 0, 0), + "converting last_modified", + ), + ( + "Sat, 01 Apr 2023 00:00:00", + datetime(2023, 1, 1, 0, 0, 0), + None, + ), + ( + "Sat, 01 Apr 2023 00:00:00 GMT", + datetime(2023, 1, 1, 0, 0, 0).replace(tzinfo=timezone.utc), + None, + ), + ( + "Sat, 01 Apr 2023 00:00:00", + datetime(2023, 1, 1, 0, 0, 0).replace(tzinfo=timezone.utc), + "converting if_modified_since", + ), + ), +) +@pytest.mark.asyncio +async def test_file_timestamp_validation( + lastmod, ifmod, expected, caplog: pytest.LogCaptureFixture +): + headers = Header([["If-Modified-Since", ifmod]]) + + with caplog.at_level(INFO): + response = await validate_file(headers, lastmod) + assert response.status == 304 + records = caplog.records + if not expected: + assert len(records) == 0 + else: + record = records[0] + assert expected in record.message diff --git a/tests/test_tls.py b/tests/test_tls.py index db584a76..6d2cb981 100644 --- a/tests/test_tls.py +++ b/tests/test_tls.py @@ -12,7 +12,7 @@ from urllib.parse import urlparse import pytest -from sanic_testing.testing import HOST, PORT +from sanic_testing.testing import HOST, PORT, SanicTestClient import sanic.http.tls.creators @@ -29,16 +29,24 @@ from sanic.http.tls.creators import ( get_ssl_context, ) from sanic.response import text +from sanic.worker.loader import CertLoader current_dir = os.path.dirname(os.path.realpath(__file__)) localhost_dir = os.path.join(current_dir, "certs/localhost") +password_dir = os.path.join(current_dir, "certs/password") sanic_dir = os.path.join(current_dir, "certs/sanic.example") invalid_dir = os.path.join(current_dir, "certs/invalid.nonexist") localhost_cert = os.path.join(localhost_dir, "fullchain.pem") localhost_key = os.path.join(localhost_dir, "privkey.pem") sanic_cert = os.path.join(sanic_dir, "fullchain.pem") sanic_key = os.path.join(sanic_dir, "privkey.pem") +password_dict = { + "cert": os.path.join(password_dir, "fullchain.pem"), + "key": os.path.join(password_dir, "privkey.pem"), + "password": "password", + "names": ["localhost"], +} @pytest.fixture @@ -420,6 +428,29 @@ def test_no_certs_on_list(app): assert "No certificates" in str(excinfo.value) +def test_custom_cert_loader(): + class MyCertLoader(CertLoader): + def load(self, app: Sanic): + self._ssl_data = { + "key": localhost_key, + "cert": localhost_cert, + } + return super().load(app) + + app = Sanic("custom", certloader_class=MyCertLoader) + + @app.get("/test") + async def handler(request): + return text("ssl test") + + client = SanicTestClient(app, port=44556) + + request, response = client.get("https://localhost:44556/test") + assert request.scheme == "https" + assert response.status_code == 200 + assert response.text == "ssl test" + + def test_logger_vhosts(caplog): app = Sanic(name="test_logger_vhosts") @@ -677,3 +708,34 @@ def test_ssl_in_multiprocess_mode(app: Sanic, caplog): logging.INFO, "Goin' Fast @ https://127.0.0.1:8000", ) in caplog.record_tuples + + +@pytest.mark.skipif( + sys.platform not in ("linux", "darwin"), + reason="This test requires fork context", +) +def test_ssl_in_multiprocess_mode_password( + app: Sanic, caplog: pytest.LogCaptureFixture +): + event = Event() + + @app.main_process_start + async def main_start(app: Sanic): + app.shared_ctx.event = event + + @app.after_server_start + async def shutdown(app): + app.shared_ctx.event.set() + app.stop() + + assert not event.is_set() + with use_context("fork"): + with caplog.at_level(logging.INFO): + app.run(ssl=password_dict) + assert event.is_set() + + assert ( + "sanic.root", + logging.INFO, + "Goin' Fast @ https://127.0.0.1:8000", + ) in caplog.record_tuples