diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..21c125c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +.py text eol=lf +.rst text eol=lf +.txt text eol=lf +.yaml text eol=lf +.toml text eol=lf +.license text eol=lf +.md text eol=lf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e5fccc..ff19dde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,21 @@ -# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries # # SPDX-License-Identifier: Unlicense repos: - - repo: https://github.com/python/black - rev: 22.3.0 - hooks: - - id: black - - repo: https://github.com/fsfe/reuse-tool - rev: v0.14.0 - hooks: - - id: reuse - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.5.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/pycqa/pylint - rev: v2.15.5 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 hooks: - - id: pylint - name: pylint (library code) - types: [python] - args: - - --disable=consider-using-f-string - exclude: "^(docs/|examples/|tests/|setup.py$)" - - id: pylint - name: pylint (example code) - description: Run pylint rules on "examples/*.py" files - types: [python] - files: "^examples/" - args: - - --disable=missing-docstring,invalid-name,consider-using-f-string,duplicate-code - - id: pylint - name: pylint (test code) - description: Run pylint rules on "tests/*.py" files - types: [python] - files: "^tests/" - args: - - --disable=missing-docstring,consider-using-f-string,duplicate-code + - id: ruff-format + - id: ruff + args: ["--fix"] + - repo: https://github.com/fsfe/reuse-tool + rev: v3.0.1 + hooks: + - id: reuse diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 40208c3..0000000 --- a/.pylintrc +++ /dev/null @@ -1,399 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - -# Add files or directories to the ignore-list. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the ignore-list. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins=pylint.extensions.no_self_use - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# disable=import-error,raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,deprecated-str-translate-call -disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,import-error,pointless-string-statement,unspecified-encoding - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -# notes=FIXME,XXX,TODO -notes=FIXME,XXX - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=board - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -# expected-line-ending-format= -expected-line-ending-format=LF - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=yes - -# Minimum lines number of a similarity. -min-similarity-lines=12 - - -[BASIC] - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class names -# class-rgx=[A-Z_][a-zA-Z0-9]+$ -class-rgx=[A-Z_][a-zA-Z0-9_]+$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -# good-names=i,j,k,ex,Run,_ -good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_ - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Maximum number of attributes for a class (see R0902). -# max-attributes=7 -max-attributes=11 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b79ec5b..ee38fa0 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,8 +8,11 @@ # Required version: 2 +sphinx: + configuration: docs/conf.py + build: - os: ubuntu-20.04 + os: ubuntu-lts-latest tools: python: "3" diff --git a/README.rst b/README.rst index bb54843..5e884a5 100644 --- a/README.rst +++ b/README.rst @@ -17,18 +17,22 @@ Introduction :alt: Build Status -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - :alt: Code Style: Black +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Code Style: Ruff HTTP Server for CircuitPython. - Supports `socketpool` or `socket` as a source of sockets; can be used in CPython. - HTTP 1.1. - Serves files from a designated root. -- Routing for serving computed responses from handler. -- Gives access to request headers, query parameters, body and client's address, the one from which the request came. +- Routing for serving computed responses from handlers. +- Gives access to request headers, query parameters, form data, body and client's address (the one from which the request came). - Supports chunked transfer encoding. +- Supports URL parameters and wildcard URLs. +- Supports HTTP Basic and Bearer Authentication on both server and route per level. +- Supports Websockets and Server-Sent Events. +- Limited support for HTTPS (only on selected microcontrollers with enough memory e.g. ESP32-S3). Dependencies @@ -94,6 +98,13 @@ Or the following command to update an existing version: circup update +Security +======== + +The HTTP server implementation in this package is not robust and should only be deployed on trusted networks. +For instance, there are trivial denial of service attacks against adafruit_httpserver. +Pull requests that improve the server's security and robustness are of course welcome. + Contributing ============ diff --git a/adafruit_httpserver/__init__.py b/adafruit_httpserver/__init__.py index fb2966f..6c8c7a2 100644 --- a/adafruit_httpserver/__init__.py +++ b/adafruit_httpserver/__init__.py @@ -1,14 +1,14 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ `adafruit_httpserver` ================================================================================ -Simple HTTP Server for CircuitPython +Socket based HTTP Server for CircuitPython -* Author(s): Dan Halbert +* Author(s): Dan Halbert, Michał Pokusa Implementation Notes -------------------- @@ -21,3 +21,74 @@ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_HTTPServer.git" + + +from .authentication import ( + Basic, + Bearer, + Token, + check_authentication, + require_authentication, +) +from .exceptions import ( + AuthenticationError, + BackslashInPathError, + FileNotExistsError, + InvalidPathError, + ParentDirectoryReferenceError, + ServerStoppedError, + ServingFilesDisabledError, +) +from .headers import Headers +from .methods import ( + CONNECT, + DELETE, + GET, + HEAD, + OPTIONS, + PATCH, + POST, + PUT, + TRACE, +) +from .mime_types import MIMETypes +from .request import FormData, QueryParams, Request +from .response import ( + ChunkedResponse, + FileResponse, + JSONResponse, + Redirect, + Response, + SSEResponse, + Websocket, +) +from .route import Route, as_route +from .server import ( + CONNECTION_TIMED_OUT, + NO_REQUEST, + REQUEST_HANDLED_NO_RESPONSE, + REQUEST_HANDLED_RESPONSE_SENT, + Server, +) +from .status import ( + ACCEPTED_202, + BAD_REQUEST_400, + CREATED_201, + FORBIDDEN_403, + FOUND_302, + INTERNAL_SERVER_ERROR_500, + METHOD_NOT_ALLOWED_405, + MOVED_PERMANENTLY_301, + NO_CONTENT_204, + NOT_FOUND_404, + NOT_IMPLEMENTED_501, + OK_200, + PARTIAL_CONTENT_206, + PERMANENT_REDIRECT_308, + SERVICE_UNAVAILABLE_503, + SWITCHING_PROTOCOLS_101, + TEMPORARY_REDIRECT_307, + TOO_MANY_REQUESTS_429, + UNAUTHORIZED_401, + Status, +) diff --git a/adafruit_httpserver/authentication.py b/adafruit_httpserver/authentication.py new file mode 100644 index 0000000..73d72c4 --- /dev/null +++ b/adafruit_httpserver/authentication.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.authentication` +==================================================== +* Author(s): Michał Pokusa +""" + +try: + from typing import List, Union +except ImportError: + pass + +from binascii import b2a_base64 + +from .exceptions import AuthenticationError +from .request import Request + + +class Basic: + """Represents HTTP Basic Authentication.""" + + def __init__(self, username: str, password: str) -> None: + self._value = b2a_base64(f"{username}:{password}".encode()).decode().strip() + + def __str__(self) -> str: + return f"Basic {self._value}" + + +class Token: + """Represents HTTP Token Authentication.""" + + prefix = "Token" + + def __init__(self, token: str) -> None: + self._value = token + + def __str__(self) -> str: + return f"{self.prefix} {self._value}" + + +class Bearer(Token): + """Represents HTTP Bearer Token Authentication.""" + + prefix = "Bearer" + + +def check_authentication(request: Request, auths: List[Union[Basic, Token, Bearer]]) -> bool: + """ + Returns ``True`` if request is authorized by any of the authentications, ``False`` otherwise. + + Example:: + + check_authentication(request, [Basic("username", "password")]) + """ + + auth_header = request.headers.get_directive("Authorization") + + if auth_header is None: + return False + + return any(auth_header == str(auth) for auth in auths) + + +def require_authentication(request: Request, auths: List[Union[Basic, Token, Bearer]]) -> None: + """ + Checks if the request is authorized and raises ``AuthenticationError`` if not. + + If the error is not caught, the server will return ``401 Unauthorized``. + + Example:: + + require_authentication(request, [Basic("username", "password")]) + """ + + if not check_authentication(request, auths): + raise AuthenticationError( + "Request is not authenticated by any of the provided authentications" + ) diff --git a/adafruit_httpserver/exceptions.py b/adafruit_httpserver/exceptions.py new file mode 100644 index 0000000..13bba7e --- /dev/null +++ b/adafruit_httpserver/exceptions.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.exceptions` +==================================================== +* Author(s): Michał Pokusa +""" + + +class ServerStoppedError(Exception): + """ + Raised when ``.poll`` is called on a stopped ``Server``. + """ + + +class AuthenticationError(Exception): + """ + Raised by ``require_authentication`` when the ``Request`` is not authorized. + """ + + +class InvalidPathError(Exception): + """ + Parent class for all path related errors. + """ + + +class ParentDirectoryReferenceError(InvalidPathError): + """ + Path contains ``..``, a reference to the parent directory. + """ + + def __init__(self, path: str) -> None: + """Creates a new ``ParentDirectoryReferenceError`` for the ``path``.""" + super().__init__(f"Parent directory reference in path: {path}") + + +class BackslashInPathError(InvalidPathError): + """ + Backslash ``\\`` in path. + """ + + def __init__(self, path: str) -> None: + """Creates a new ``BackslashInPathError`` for the ``path``.""" + super().__init__(f"Backslash in path: {path}") + + +class ServingFilesDisabledError(Exception): + """ + Raised when ``root_path`` is not set and there is no handler for ``request``. + """ + + +class FileNotExistsError(Exception): + """ + Raised when a file does not exist. + """ + + def __init__(self, path: str) -> None: + """ + Creates a new ``FileNotExistsError`` for the file at ``path``. + """ + super().__init__(f"File does not exist: {path}") diff --git a/adafruit_httpserver/headers.py b/adafruit_httpserver/headers.py index cf9ea20..944c95b 100644 --- a/adafruit_httpserver/headers.py +++ b/adafruit_httpserver/headers.py @@ -1,19 +1,21 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.headers.HTTPHeaders` +`adafruit_httpserver.headers` ==================================================== * Author(s): Michał Pokusa """ try: - from typing import Dict, Tuple + from typing import Dict, List, Union except ImportError: pass +from .interfaces import _IFieldStorage -class HTTPHeaders: + +class Headers(_IFieldStorage): """ A dict-like class for storing HTTP headers. @@ -23,7 +25,9 @@ class HTTPHeaders: Examples:: - headers = HTTPHeaders({"Content-Type": "text/html", "Content-Length": "1024"}) + headers = Headers("Content-Type: text/html\\r\\nContent-Length: 1024\\r\\n") + # or + headers = Headers({"Content-Type": "text/html", "Content-Length": "1024"}) len(headers) # 2 @@ -45,61 +49,95 @@ class HTTPHeaders: # True """ - _storage: Dict[str, Tuple[str, str]] + _storage: Dict[str, List[str]] - def __init__(self, headers: Dict[str, str] = None) -> None: + def __init__(self, headers: Union[str, Dict[str, str]] = None) -> None: + self._storage = {} - headers = headers or {} + if isinstance(headers, str): + for header_line in headers.strip().splitlines(): + name, value = header_line.split(": ", 1) + self.add(name, value) + else: + for key, value in (headers or {}).items(): + self.add(key, value) - self._storage = {key.lower(): [key, value] for key, value in headers.items()} + def add(self, field_name: str, value: str): + """ + Adds a header with the given field name and value. + Allows adding multiple headers with the same name. + """ + self._add_field_value(field_name.lower(), value) - def get(self, name: str, default: str = None): + def get(self, field_name: str, default: str = None) -> Union[str, None]: """Returns the value for the given header name, or default if not found.""" - return self._storage.get(name.lower(), [None, default])[1] + return super().get(field_name.lower(), default) - def setdefault(self, name: str, default: str = None): - """Sets the value for the given header name if it does not exist.""" - return self._storage.setdefault(name.lower(), [name, default])[1] + def get_list(self, field_name: str) -> List[str]: + """Get the list of values of a field.""" + return super().get_list(field_name.lower()) + + def get_directive(self, name: str, default: str = None) -> Union[str, None]: + """ + Returns the main value (directive) for the given header name, or default if not found. + + Example:: + + headers = Headers({"Content-Type": "text/html; charset=utf-8"}) + headers.get_directive("Content-Type") + # 'text/html' + """ + + header_value = self.get(name) + if header_value is None: + return default + return header_value.split(";")[0].strip('" ') + + def get_parameter(self, name: str, parameter: str, default: str = None) -> Union[str, None]: + """ + Returns the value of the given parameter for the given header name, or default if not found. + + Example:: - def items(self): - """Returns a list of (name, value) tuples.""" - return dict(self._storage.values()).items() + headers = Headers({"Content-Type": "text/html; charset=utf-8"}) + headers.get_parameter("Content-Type", "charset") + # 'utf-8' + """ - def keys(self): - """Returns a list of header names.""" - return dict(self._storage.values()).keys() + header_value = self.get(name) + if header_value is None: + return default + for header_parameter in header_value.split(";"): + if header_parameter.strip().startswith(parameter): + return header_parameter.strip().split("=")[1].strip('" ') + return default - def values(self): - """Returns a list of header values.""" - return dict(self._storage.values()).values() + def set(self, name: str, value: str): + """Sets the value for the given header name.""" + self._storage[name.lower()] = [value] + + def setdefault(self, name: str, default: str = None): + """Sets the value for the given header name if it does not exist.""" + return self._storage.setdefault(name.lower(), [default]) def update(self, headers: Dict[str, str]): """Updates the headers with the given dict.""" - return self._storage.update( - {key.lower(): [key, value] for key, value in headers.items()} - ) + return self._storage.update({key.lower(): [value] for key, value in headers.items()}) def copy(self): """Returns a copy of the headers.""" - return HTTPHeaders(dict(self._storage.values())) + return Headers( + "\r\n".join(f"{key}: {value}" for key in self.fields for value in self.get_list(key)) + ) def __getitem__(self, name: str): - return self._storage[name.lower()][1] + return super().__getitem__(name.lower()) def __setitem__(self, name: str, value: str): - self._storage[name.lower()] = [name, value] + self._storage[name.lower()] = [value] def __delitem__(self, name: str): del self._storage[name.lower()] - def __iter__(self): - return iter(dict(self._storage.values())) - - def __len__(self): - return len(self._storage) - def __contains__(self, key: str): - return key.lower() in self._storage.keys() - - def __repr__(self): - return f"{self.__class__.__name__}({dict(self._storage.values())})" + return super().__contains__(key.lower()) diff --git a/adafruit_httpserver/interfaces.py b/adafruit_httpserver/interfaces.py new file mode 100644 index 0000000..efee790 --- /dev/null +++ b/adafruit_httpserver/interfaces.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 Michał Pokusa +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.interfaces` +==================================================== +* Author(s): Michał Pokusa +""" + +try: + from typing import Any, Dict, List, Tuple, Union +except ImportError: + pass + + +class _ISocket: + """A class for typing necessary methods for a socket object.""" + + def accept(self) -> Tuple["_ISocket", Tuple[str, int]]: ... + + def bind(self, address: Tuple[str, int]) -> None: ... + + def setblocking(self, flag: bool) -> None: ... + + def settimeout(self, value: "Union[float, None]") -> None: ... + + def setsockopt(self, level: int, optname: int, value: int) -> None: ... + + def listen(self, backlog: int) -> None: ... + + def send(self, data: bytes) -> int: ... + + def recv_into(self, buffer: memoryview, nbytes: int) -> int: ... + + def close(self) -> None: ... + + +class _ISocketPool: + """A class to typing necessary methods and properties for a socket pool object.""" + + AF_INET: int + SO_REUSEADDR: int + SOCK_STREAM: int + SOL_SOCKET: int + + def socket( + self, + family: int = ..., + type: int = ..., + proto: int = ..., + ) -> _ISocket: ... + + def getaddrinfo( + self, + host: str, + port: int, + family: int = ..., + type: int = ..., + proto: int = ..., + flags: int = ..., + ) -> Tuple[int, int, int, str, Tuple[str, int]]: ... + + +class _IFieldStorage: + """Interface with shared methods for QueryParams, FormData and Headers.""" + + _storage: Dict[str, List[Any]] + + def _add_field_value(self, field_name: str, value: Any) -> None: + if field_name not in self._storage: + self._storage[field_name] = [value] + else: + self._storage[field_name].append(value) + + def get(self, field_name: str, default: Any = None) -> Union[Any, None]: + """Get the value of a field.""" + return self._storage.get(field_name, [default])[0] + + def get_list(self, field_name: str) -> List[Any]: + """Get the list of values of a field.""" + return self._storage.get(field_name, []) + + @property + def fields(self): + """Returns a list of field names.""" + return list(self._storage.keys()) + + def items(self): + """Returns a list of (name, value) tuples.""" + return [(key, value) for key in self.fields for value in self.get_list(key)] + + def keys(self): + """Returns a list of header names.""" + return self.fields + + def values(self): + """Returns a list of header values.""" + return [value for key in self.keys() for value in self.get_list(key)] + + def __getitem__(self, field_name: str): + return self._storage[field_name][0] + + def __iter__(self): + return iter(self._storage) + + def __len__(self) -> int: + return len(self._storage) + + def __contains__(self, key: str) -> bool: + return key in self._storage + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {repr(self._storage)}>" + + +def _encode_html_entities(value: Union[str, None]) -> Union[str, None]: + """Encodes unsafe HTML characters that could enable XSS attacks.""" + if value is None: + return None + + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +class _IXSSSafeFieldStorage(_IFieldStorage): + def get(self, field_name: str, default: Any = None, *, safe=True) -> Union[Any, None]: + if safe: + return _encode_html_entities(super().get(field_name, default)) + + _debug_warning_nonencoded_output() + return super().get(field_name, default) + + def get_list(self, field_name: str, *, safe=True) -> List[Any]: + if safe: + return [_encode_html_entities(value) for value in super().get_list(field_name)] + + _debug_warning_nonencoded_output() + return super().get_list(field_name) + + +def _debug_warning_nonencoded_output(): + """Warns about XSS risks.""" + print( + "WARNING: Setting safe to False makes XSS vulnerabilities possible by " + "allowing access to raw untrusted values submitted by users. If this data is reflected " + "or shown within HTML without proper encoding it could enable Cross-Site Scripting." + ) diff --git a/adafruit_httpserver/methods.py b/adafruit_httpserver/methods.py index 319b631..ad58063 100644 --- a/adafruit_httpserver/methods.py +++ b/adafruit_httpserver/methods.py @@ -1,39 +1,26 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.methods.HTTPMethod` +`adafruit_httpserver.methods` ==================================================== * Author(s): Michał Pokusa """ +GET = "GET" -class HTTPMethod: # pylint: disable=too-few-public-methods - """Enum with HTTP methods.""" +POST = "POST" - GET = "GET" - """GET method.""" +PUT = "PUT" - POST = "POST" - """POST method.""" +DELETE = "DELETE" - PUT = "PUT" - """PUT method""" +PATCH = "PATCH" - DELETE = "DELETE" - """DELETE method""" +HEAD = "HEAD" - PATCH = "PATCH" - """PATCH method""" +OPTIONS = "OPTIONS" - HEAD = "HEAD" - """HEAD method""" +TRACE = "TRACE" - OPTIONS = "OPTIONS" - """OPTIONS method""" - - TRACE = "TRACE" - """TRACE method""" - - CONNECT = "CONNECT" - """CONNECT method""" +CONNECT = "CONNECT" diff --git a/adafruit_httpserver/mime_type.py b/adafruit_httpserver/mime_type.py deleted file mode 100644 index 39e592e..0000000 --- a/adafruit_httpserver/mime_type.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries -# -# SPDX-License-Identifier: MIT -""" -`adafruit_httpserver.mime_type.MIMEType` -==================================================== -* Author(s): Dan Halbert, Michał Pokusa -""" - - -class MIMEType: - """Common MIME types. - From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - """ - - TYPE_AAC = "audio/aac" - TYPE_ABW = "application/x-abiword" - TYPE_ARC = "application/x-freearc" - TYPE_AVI = "video/x-msvideo" - TYPE_AZW = "application/vnd.amazon.ebook" - TYPE_BIN = "application/octet-stream" - TYPE_BMP = "image/bmp" - TYPE_BZ = "application/x-bzip" - TYPE_BZ2 = "application/x-bzip2" - TYPE_CSH = "application/x-csh" - TYPE_CSS = "text/css" - TYPE_CSV = "text/csv" - TYPE_DOC = "application/msword" - TYPE_DOCX = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) - TYPE_EOT = "application/vnd.ms-fontobject" - TYPE_EPUB = "application/epub+zip" - TYPE_GZ = "application/gzip" - TYPE_GIF = "image/gif" - TYPE_HTML = "text/html" - TYPE_HTM = "text/html" - TYPE_ICO = "image/vnd.microsoft.icon" - TYPE_ICS = "text/calendar" - TYPE_JAR = "application/java-archive" - TYPE_JPEG = "image/jpeg" - TYPE_JPG = "image/jpeg" - TYPE_JS = "text/javascript" - TYPE_JSON = "application/json" - TYPE_JSONLD = "application/ld+json" - TYPE_MID = "audio/midi" - TYPE_MIDI = "audio/midi" - TYPE_MJS = "text/javascript" - TYPE_MP3 = "audio/mpeg" - TYPE_CDA = "application/x-cdf" - TYPE_MP4 = "video/mp4" - TYPE_MPEG = "video/mpeg" - TYPE_MPKG = "application/vnd.apple.installer+xml" - TYPE_ODP = "application/vnd.oasis.opendocument.presentation" - TYPE_ODS = "application/vnd.oasis.opendocument.spreadsheet" - TYPE_ODT = "application/vnd.oasis.opendocument.text" - TYPE_OGA = "audio/ogg" - TYPE_OGV = "video/ogg" - TYPE_OGX = "application/ogg" - TYPE_OPUS = "audio/opus" - TYPE_OTF = "font/otf" - TYPE_PNG = "image/png" - TYPE_PDF = "application/pdf" - TYPE_PHP = "application/x-httpd-php" - TYPE_PPT = "application/vnd.ms-powerpoint" - TYPE_PPTX = ( - "application/vnd.openxmlformats-officedocument.presentationml.presentation" - ) - TYPE_RAR = "application/vnd.rar" - TYPE_RTF = "application/rtf" - TYPE_SH = "application/x-sh" - TYPE_SVG = "image/svg+xml" - TYPE_SWF = "application/x-shockwave-flash" - TYPE_TAR = "application/x-tar" - TYPE_TIFF = "image/tiff" - TYPE_TIF = "image/tiff" - TYPE_TS = "video/mp2t" - TYPE_TTF = "font/ttf" - TYPE_TXT = "text/plain" - TYPE_VSD = "application/vnd.visio" - TYPE_WAV = "audio/wav" - TYPE_WEBA = "audio/webm" - TYPE_WEBM = "video/webm" - TYPE_WEBP = "image/webp" - TYPE_WOFF = "font/woff" - TYPE_WOFF2 = "font/woff2" - TYPE_XHTML = "application/xhtml+xml" - TYPE_XLS = "application/vnd.ms-excel" - TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - TYPE_XML = "application/xml" - TYPE_XUL = "application/vnd.mozilla.xul+xml" - TYPE_ZIP = "application/zip" - TYPE_7Z = "application/x-7z-compressed" - - @staticmethod - def from_file_name(filename: str): - """Return the mime type for the given filename. If not known, return "text/plain".""" - attr_name = "TYPE_" + filename.split(".")[-1].upper() - - return getattr(MIMEType, attr_name, MIMEType.TYPE_TXT) diff --git a/adafruit_httpserver/mime_types.py b/adafruit_httpserver/mime_types.py new file mode 100644 index 0000000..4a684e6 --- /dev/null +++ b/adafruit_httpserver/mime_types.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa +# +# SPDX-License-Identifier: MIT +""" +`adafruit_httpserver.mime_types` +==================================================== +* Author(s): Michał Pokusa +""" + +try: + from typing import Dict, List +except ImportError: + pass + + +class MIMETypes: + """ + Contains MIME types for common file extensions. + Allows to set default type for unknown files, unregister unused types and register new ones + using the ``MIMETypes.configure()``. + """ + + DEFAULT = "text/plain" + """ + Default MIME type for unknown files. + Can be changed using ``MIMETypes.configure(default_to=...)``. + """ + + REGISTERED = { + ".7z": "application/x-7z-compressed", + ".aac": "audio/aac", + ".abw": "application/x-abiword", + ".arc": "application/x-freearc", + ".avi": "video/x-msvideo", + ".azw": "application/vnd.amazon.ebook", + ".bin": "application/octet-stream", + ".bmp": "image/bmp", + ".bz": "application/x-bzip", + ".bz2": "application/x-bzip2", + ".cda": "application/x-cdf", + ".csh": "application/x-csh", + ".css": "text/css", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".eot": "application/vnd.ms-fontobject", + ".epub": "application/epub+zip", + ".gif": "image/gif", + ".gz": "application/gzip", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/vnd.microsoft.icon", + ".ics": "text/calendar", + ".jar": "application/java-archive", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript", + ".json": "application/json", + ".jsonld": "application/ld+json", + ".mid": "audio/midi", + ".midi": "audio/midi", + ".mjs": "text/javascript", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpkg": "application/vnd.apple.installer+xml", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".oga": "audio/ogg", + ".ogv": "video/ogg", + ".ogx": "application/ogg", + ".opus": "audio/opus", + ".otf": "font/otf", + ".pdf": "application/pdf", + ".php": "application/x-httpd-php", + ".png": "image/png", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".rar": "application/vnd.rar", + ".rtf": "application/rtf", + ".sh": "application/x-sh", + ".svg": "image/svg+xml", + ".swf": "application/x-shockwave-flash", + ".tar": "application/x-tar", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".ts": "video/mp2t", + ".ttf": "font/ttf", + ".txt": "text/plain", + ".vsd": "application/vnd.visio", + ".wav": "audio/wav", + ".weba": "audio/webm", + ".webm": "video/webm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xhtml": "application/xhtml+xml", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "application/xml", + ".xul": "application/vnd.mozilla.xul+xml", + ".zip": "application/zip", + } + + @staticmethod + def __check_all_start_with_dot(extensions: List[str]) -> None: + for extension in extensions: + if not extension.startswith("."): + raise ValueError( + f'Invalid extension: "{extension}". All extensions must start with a dot.' + ) + + @classmethod + def __check_all_are_registered(cls, extensions: List[str]) -> None: + registered_extensions = cls.REGISTERED.keys() + + for extension in extensions: + if not extension in registered_extensions: + raise ValueError(f'Extension "{extension}" is not registered. ') + + @classmethod + def _default_to(cls, mime_type: str) -> None: + """ + Set the default MIME type for unknown files. + + :param str mime_type: The MIME type to use for unknown files. + """ + cls.DEFAULT = mime_type + + @classmethod + def _keep_for(cls, extensions: List[str]) -> None: + """ + Unregisters all MIME types except the ones for the given extensions,\ + **decreasing overall memory usage**. + """ + + cls.__check_all_start_with_dot(extensions) + cls.__check_all_are_registered(extensions) + + current_extensions = iter(cls.REGISTERED.keys()) + + cls.REGISTERED = { + extension: cls.REGISTERED[extension] + for extension in current_extensions + if extension in extensions + } + + @classmethod + def _register(cls, mime_types: dict) -> None: + """ + Register multiple MIME types. + + :param dict mime_types: A dictionary mapping file extensions to MIME types. + """ + cls.__check_all_start_with_dot(mime_types.keys()) + cls.REGISTERED.update(mime_types) + + @classmethod + def configure( + cls, + default_to: str = None, + keep_for: List[str] = None, + register: Dict[str, str] = None, + ) -> None: + """ + Allows to globally configure the MIME types. + + It is recommended to **always** call this method before starting the ``Server``. + Unregistering unused MIME types will **decrease overall memory usage**. + + :param str default_to: The MIME type to use for unknown files. + :param List[str] keep_for: File extensions to keep. All other will be unregistered. + :param Dict[str, str] register: A dictionary mapping file extensions to MIME types. + + Example:: + + MIMETypes.configure( + default_to="text/plain", + keep_for=[".jpg", ".mp4", ".txt"], + register={".foo": "text/foo", ".bar": "text/bar", ".baz": "text/baz"}, + ) + """ + if default_to is not None: + cls._default_to(default_to) + if keep_for is not None: + cls._keep_for(keep_for) + if register is not None: + cls._register(register) + + @classmethod + def get_for_filename(cls, filename: str, default: str = None) -> str: + """ + Return the MIME type for the given file name. + If the file extension is not registered, ``default`` is returned. + + :param str filename: The file name to look up. + :param str default: Default MIME type to return if the file extension is not registered. + """ + if default is None: + default = cls.DEFAULT + + try: + extension = filename.rsplit(".", 1)[-1].lower() + return cls.REGISTERED.get(f".{extension}", default) + except IndexError: + return default diff --git a/adafruit_httpserver/request.py b/adafruit_httpserver/request.py index 9ee5fc0..0997e2d 100644 --- a/adafruit_httpserver/request.py +++ b/adafruit_httpserver/request.py @@ -1,31 +1,270 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.request.HTTPRequest` +`adafruit_httpserver.request` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Dict, Tuple, Union - from socket import socket - from socketpool import SocketPool + from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union + + if TYPE_CHECKING: + from .server import Server except ImportError: pass -from .headers import HTTPHeaders +import json + +from .headers import Headers +from .interfaces import _IFieldStorage, _ISocket, _IXSSSafeFieldStorage +from .methods import DELETE, PATCH, POST, PUT + + +class QueryParams(_IXSSSafeFieldStorage): + """ + Class for parsing and storing GET query parameters requests. + + Examples:: + + query_params = QueryParams("foo=bar&baz=qux&baz=quux") + # QueryParams({"foo": ["bar"], "baz": ["qux", "quux"]}) + + query_params.get("foo") # "bar" + query_params["foo"] # "bar" + query_params.get("non-existent-key") # None + query_params.get_list("baz") # ["qux", "quux"] + "unknown-key" in query_params # False + query_params.fields # ["foo", "baz"] + """ + + _storage: Dict[str, List[str]] + + def __init__(self, query_string: str) -> None: + self._storage = {} + + for query_param in query_string.split("&"): + if "=" in query_param: + key, value = query_param.split("=", 1) + self._add_field_value(key, value) + elif query_param: + self._add_field_value(query_param, "") + + def _add_field_value(self, field_name: str, value: str) -> None: + super()._add_field_value(field_name, value) + + def get(self, field_name: str, default: str = None, *, safe=True) -> Union[str, None]: + return super().get(field_name, default, safe=safe) + + def get_list(self, field_name: str, *, safe=True) -> List[str]: + return super().get_list(field_name, safe=safe) + + def __str__(self) -> str: + return "&".join( + f"{field_name}={value}" + for field_name in self.fields + for value in self.get_list(field_name) + ) + + +class File: + """ + Class representing a file uploaded via POST. + + Examples:: + + file = request.form_data.files.get("uploaded_file") + # File(filename="foo.txt", content_type="text/plain", size=14) + + file.content + # "Hello, world!\\n" + """ + + filename: str + """Filename of the file.""" + + content_type: str + """Content type of the file.""" + + content: Union[str, bytes] + """Content of the file.""" + + def __init__(self, filename: str, content_type: str, content: Union[str, bytes]) -> None: + self.filename = filename + self.content_type = content_type + self.content = content + + @property + def content_bytes(self) -> bytes: + """ + Content of the file as bytes. + It is recommended to use this instead of ``content`` as it will always return bytes. + + Example:: + + file = request.form_data.files.get("uploaded_file") + + with open(file.filename, "wb") as f: + f.write(file.content_bytes) + """ + return self.content.encode("utf-8") if isinstance(self.content, str) else self.content + + @property + def size(self) -> int: + """Length of the file content.""" + return len(self.content) + + def __repr__(self) -> str: + filename, content_type, size = ( + self.filename, + self.content_type, + self.size, + ) + return f"<{self.__class__.__name__} {filename=}, {content_type=}, {size=}>" + + +class Files(_IFieldStorage): + """Class for files uploaded via POST.""" + _storage: Dict[str, List[File]] -class HTTPRequest: + def __init__(self) -> None: + self._storage = {} + + def _add_field_value(self, field_name: str, value: File) -> None: + super()._add_field_value(field_name, value) + + def get(self, field_name: str, default: Any = None) -> Union[File, Any, None]: + return super().get(field_name, default) + + def get_list(self, field_name: str) -> List[File]: + return super().get_list(field_name) + + +class FormData(_IXSSSafeFieldStorage): + """ + Class for parsing and storing form data from POST requests. + + Supports ``application/x-www-form-urlencoded``, ``multipart/form-data`` and ``text/plain`` + content types. + + Examples:: + + form_data = FormData(b"foo=bar&baz=qux&baz=quuz", "application/x-www-form-urlencoded") + # or + form_data = FormData(b"foo=bar\\r\\nbaz=qux\\r\\nbaz=quux", "text/plain") + # FormData({"foo": ["bar"], "baz": ["qux", "quux"]}) + + form_data.get("foo") # "bar" + form_data["foo"] # "bar" + form_data.get("non-existent-key") # None + form_data.get_list("baz") # ["qux", "quux"] + "unknown-key" in form_data # False + form_data.fields # ["foo", "baz"] + """ + + _storage: Dict[str, List[Union[str, bytes]]] + files: Files + + @staticmethod + def _check_is_supported_content_type(content_type: str) -> None: + return content_type in { + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + } + + def __init__(self, data: bytes, headers: Headers, *, debug: bool = False) -> None: + self._storage = {} + self.files = Files() + + self.content_type = headers.get_directive("Content-Type") + content_length = int(headers.get("Content-Length", 0)) + + if debug and not self._check_is_supported_content_type(self.content_type): + _debug_unsupported_form_content_type(self.content_type) + + if self.content_type == "application/x-www-form-urlencoded": + self._parse_x_www_form_urlencoded(data[:content_length]) + + elif self.content_type == "multipart/form-data": + boundary = headers.get_parameter("Content-Type", "boundary") + self._parse_multipart_form_data(data[:content_length], boundary) + + elif self.content_type == "text/plain": + self._parse_text_plain(data[:content_length]) + + def _parse_x_www_form_urlencoded(self, data: bytes) -> None: + if not (decoded_data := data.decode("utf-8").strip("&")): + return + + for field_name, value in [ + key_value.split("=", 1) if "=" in key_value else (key_value, "") + for key_value in decoded_data.split("&") + ]: + self._add_field_value(field_name, value) + + def _parse_multipart_form_data(self, data: bytes, boundary: str) -> None: + blocks = data.split(b"--" + boundary.encode())[1:-1] + + for block in blocks: + header_bytes, content_bytes = block.split(b"\r\n\r\n", 1) + headers = Headers(header_bytes.decode("utf-8").strip()) + + field_name = headers.get_parameter("Content-Disposition", "name") + filename = headers.get_parameter("Content-Disposition", "filename") + content_type = headers.get_directive("Content-Type", "text/plain") + charset = headers.get_parameter("Content-Type", "charset", "utf-8") + + content = content_bytes[:-2] # remove trailing \r\n + value = content.decode(charset) if content_type == "text/plain" else content + + # TODO: Other text content types (e.g. application/json) should be decoded as well and + + if filename is not None: + self.files._add_field_value(field_name, File(filename, content_type, value)) + else: + self._add_field_value(field_name, value) + + def _parse_text_plain(self, data: bytes) -> None: + lines = data.decode("utf-8").split("\r\n")[:-1] + + for line in lines: + field_name, value = line.split("=", 1) + + self._add_field_value(field_name, value) + + def _add_field_value(self, field_name: str, value: Union[str, bytes]) -> None: + super()._add_field_value(field_name, value) + + def get( + self, field_name: str, default: Union[str, bytes] = None, *, safe=True + ) -> Union[str, bytes, None]: + return super().get(field_name, default, safe=safe) + + def get_list(self, field_name: str, *, safe=True) -> List[Union[str, bytes]]: + return super().get_list(field_name, safe=safe) + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + return f"<{class_name} {repr(self._storage)}, files={repr(self.files._storage)}>" + + +class Request: """ Incoming request, constructed from raw incoming bytes. - It is passed as first argument to route handlers. + It is passed as first argument to all route handlers. """ - connection: Union["SocketPool.Socket", "socket.socket"] + server: "Server" """ - Socket object usable to send and receive data on the connection. + Server object that received the request. + """ + + connection: _ISocket + """ + Socket object used to send and receive data on the connection. """ client_address: Tuple[str, int] @@ -34,119 +273,207 @@ class HTTPRequest: Example:: - request.client_address - # ('192.168.137.1', 40684) + request.client_address # ('192.168.137.1', 40684) """ method: str """Request method e.g. "GET" or "POST".""" path: str - """Path of the request.""" + """Path of the request, e.g. ``"/foo/bar"``.""" - query_params: Dict[str, str] + query_params: QueryParams """ Query/GET parameters in the request. Example:: - request = HTTPRequest(raw_request=b"GET /?foo=bar HTTP/1.1...") - request.query_params - # {"foo": "bar"} + request = Request(..., raw_request=b"GET /?foo=bar&baz=qux HTTP/1.1...") + + request.query_params # QueryParams({"foo": "bar"}) + request.query_params["foo"] # "bar" + request.query_params.get_list("baz") # ["qux"] """ http_version: str - """HTTP version, e.g. "HTTP/1.1".""" + """HTTP version, e.g. ``"HTTP/1.1"``.""" - headers: HTTPHeaders + headers: Headers """ Headers from the request. """ raw_request: bytes """ - Raw 'bytes' passed to the constructor and body 'bytes' received later. + Raw ``bytes`` that were received from the client. Should **not** be modified directly. """ def __init__( self, - connection: Union["SocketPool.Socket", "socket.socket"], + server: "Server", + connection: _ISocket, client_address: Tuple[str, int], raw_request: bytes = None, ) -> None: + self.server = server self.connection = connection self.client_address = client_address self.raw_request = raw_request + self._form_data = None + self._cookies = None if raw_request is None: raise ValueError("raw_request cannot be None") - header_bytes = self.header_body_bytes[0] - try: ( self.method, self.path, self.query_params, self.http_version, - ) = self._parse_start_line(header_bytes) - self.headers = self._parse_headers(header_bytes) + self.headers, + ) = self._parse_request_header(self._raw_header_bytes) except Exception as error: raise ValueError("Unparseable raw_request: ", raw_request) from error @property def body(self) -> bytes: """Body of the request, as bytes.""" - return self.header_body_bytes[1] + return self._raw_body_bytes @body.setter def body(self, body: bytes) -> None: - self.raw_request = self.header_body_bytes[0] + b"\r\n\r\n" + body + self.raw_request = self._raw_header_bytes + b"\r\n\r\n" + body + + @staticmethod + def _parse_cookies(cookie_header: str) -> None: + """Parse cookies from headers.""" + if cookie_header is None: + return {} + + return { + name: value.strip('"') + for name, value in [cookie.strip().split("=", 1) for cookie in cookie_header.split(";")] + } + + @property + def cookies(self) -> Dict[str, str]: + """ + Cookies sent with the request. + + Example:: + + request.headers["Cookie"] + # "foo=bar; baz=qux; foo=quux" + + request.cookies + # {"foo": "quux", "baz": "qux"} + """ + if self._cookies is None: + self._cookies = self._parse_cookies(self.headers.get("Cookie")) + return self._cookies + + @property + def form_data(self) -> Union[FormData, None]: + """ + POST data of the request. + + Example:: + + # application/x-www-form-urlencoded + request = Request(..., + raw_request=b\"\"\"... + foo=bar&baz=qux\"\"\" + ) + + # or + + # multipart/form-data + request = Request(..., + raw_request=b\"\"\"... + --boundary + Content-Disposition: form-data; name="foo" + + bar + --boundary + Content-Disposition: form-data; name="baz" + + qux + --boundary--\"\"\" + ) + + # or + + # text/plain + request = Request(..., + raw_request=b\"\"\"... + foo=bar + baz=qux + \"\"\" + ) + + request.form_data # FormData({'foo': ['bar'], 'baz': ['qux']}) + request.form_data["foo"] # "bar" + request.form_data.get_list("baz") # ["qux"] + """ + if self._form_data is None and self.method == "POST": + self._form_data = FormData(self.body, self.headers, debug=self.server.debug) + return self._form_data + + def json(self) -> Union[dict, None]: + """ + Body of the request, as a JSON-decoded dictionary. + Only available for POST, PUT, PATCH and DELETE requests. + """ + return ( + json.loads(self.body) + if (self.body and self.method in {POST, PUT, PATCH, DELETE}) + else None + ) @property - def header_body_bytes(self) -> Tuple[bytes, bytes]: - """Return tuple of header and body bytes.""" + def _raw_header_bytes(self) -> bytes: + """Returns headers bytes.""" + empty_line_index = self.raw_request.find(b"\r\n\r\n") + return self.raw_request[:empty_line_index] + + @property + def _raw_body_bytes(self) -> bytes: + """Returns body bytes.""" empty_line_index = self.raw_request.find(b"\r\n\r\n") - header_bytes = self.raw_request[:empty_line_index] - body_bytes = self.raw_request[empty_line_index + 4 :] - return header_bytes, body_bytes + return self.raw_request[empty_line_index + 4 :] @staticmethod - def _parse_start_line(header_bytes: bytes) -> Tuple[str, str, Dict[str, str], str]: + def _parse_request_header( + header_bytes: bytes, + ) -> Tuple[str, str, QueryParams, str, Headers]: """Parse HTTP Start line to method, path, query_params and http_version.""" - start_line = header_bytes.decode("utf8").splitlines()[0] + start_line, headers_string = header_bytes.decode("utf-8").strip().split("\r\n", 1) - method, path, http_version = start_line.split() - - if "?" not in path: - path += "?" + method, path, http_version = start_line.strip().split() + path = path if "?" in path else path + "?" path, query_string = path.split("?", 1) - query_params = {} - for query_param in query_string.split("&"): - if "=" in query_param: - key, value = query_param.split("=", 1) - query_params[key] = value - else: - query_params[query_param] = "" + query_params = QueryParams(query_string) + headers = Headers(headers_string) - return method, path, query_params, http_version + return method, path, query_params, http_version, headers - @staticmethod - def _parse_headers(header_bytes: bytes) -> HTTPHeaders: - """Parse HTTP headers from raw request.""" - header_lines = header_bytes.decode("utf8").splitlines()[1:] - - return HTTPHeaders( - { - name: value - for header_line in header_lines - for name, value in [header_line.split(": ", 1)] - } - ) + def __repr__(self) -> str: + path = self.path + (f"?{self.query_params}" if self.query_params else "") + return f'<{self.__class__.__name__} "{self.method} {path}">' + + +def _debug_unsupported_form_content_type(content_type: str) -> None: + """Warns when an unsupported form content type is used.""" + print( + f"WARNING: Unsupported Content-Type: {content_type}. " + "Only `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are " + "supported." + ) diff --git a/adafruit_httpserver/response.py b/adafruit_httpserver/response.py index c6cdcea..21aa217 100644 --- a/adafruit_httpserver/response.py +++ b/adafruit_httpserver/response.py @@ -1,249 +1,826 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.response.HTTPResponse` +`adafruit_httpserver.response` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Optional, Dict, Union, Tuple - from socket import socket - from socketpool import SocketPool + from typing import Any, Dict, Generator, Optional, Tuple, Union except ImportError: pass +import json import os -from errno import EAGAIN, ECONNRESET +from binascii import b2a_base64 +from errno import EAGAIN, ECONNRESET, ENOTCONN, ETIMEDOUT -from .mime_type import MIMEType -from .request import HTTPRequest -from .status import HTTPStatus, CommonHTTPStatus -from .headers import HTTPHeaders +try: + try: + import hashlib + except ImportError: + import adafruit_hashlib as hashlib +except ImportError: + print( + "WARNING: hashlib module not available and adafruit_hashlib not installed.", + "Websocket support will not work.", + ) + +from .exceptions import ( + BackslashInPathError, + FileNotExistsError, + ParentDirectoryReferenceError, +) +from .headers import Headers +from .interfaces import _ISocket +from .mime_types import MIMETypes +from .request import Request +from .status import ( + FOUND_302, + MOVED_PERMANENTLY_301, + OK_200, + PERMANENT_REDIRECT_308, + SWITCHING_PROTOCOLS_101, + TEMPORARY_REDIRECT_307, + Status, +) + + +class Response: + """ + Response to a given `Request`. Use in `Server.route` handler functions. + + Base class for all other response classes. + Example:: + + @server.route(path, method) + def route_func(request: Request): -class HTTPResponse: + return Response(request, body='Some content', content_type="text/plain") """ - Response to a given `HTTPRequest`. Use in `HTTPServer.route` decorator functions. - Example:: + def __init__( + self, + request: Request, + body: Union[str, bytes] = "", + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, + content_type: str = None, + ) -> None: + """ + :param Request request: Request that this is a response to. + :param str body: Body of response. Defaults to empty string. + :param Status status: Status code and text. Defaults to 200 OK. + :param Headers headers: Headers to include in response. Defaults to empty dict. + :param Dict[str, str] cookies: Cookies to be sent with the response. + :param str content_type: Content type of response. Defaults to None. + """ - # Response with 'Content-Length' header - @server.route(path, method) - def route_func(request): + self._request = request + self._body = body + self._status = status if isinstance(status, Status) else Status(*status) + self._headers = headers.copy() if isinstance(headers, Headers) else Headers(headers) + self._cookies = cookies.copy() if cookies else {} + self._content_type = content_type + self._size = 0 - response = HTTPResponse(request) - response.send("Some content", content_type="text/plain") + def _send_headers( + self, + content_length: Optional[int] = None, + content_type: str = None, + ) -> None: + headers = self._headers.copy() - # or + response_message_header = f"HTTP/1.1 {self._status.code} {self._status.text}\r\n" - response = HTTPResponse(request) - with response: - response.send(body='Some content', content_type="text/plain") + headers.setdefault("Content-Type", content_type or self._content_type or MIMETypes.DEFAULT) + headers.setdefault("Content-Length", content_length) + headers.setdefault("Connection", "close") - # or + for cookie_name, cookie_value in self._cookies.items(): + headers.add("Set-Cookie", f"{cookie_name}={cookie_value}") - with HTTPResponse(request) as response: - response.send("Some content", content_type="text/plain") + for header, value in headers.items(): + if value is not None: + response_message_header += f"{header}: {value}\r\n" + response_message_header += "\r\n" - # Response with 'Transfer-Encoding: chunked' header - @server.route(path, method) - def route_func(request): + self._send_bytes(self._request.connection, response_message_header.encode("utf-8")) - response = HTTPResponse(request, content_type="text/plain", chunked=True) - with response: - response.send_chunk("Some content") - response.send_chunk("Some more content") + def _send(self) -> None: + encoded_body = self._body.encode("utf-8") if isinstance(self._body, str) else self._body - # or + self._send_headers(len(encoded_body), self._content_type) + self._send_bytes(self._request.connection, encoded_body) + self._close_connection() - with HTTPResponse(request, content_type="text/plain", chunked=True) as response: - response.send_chunk("Some content") - response.send_chunk("Some more content") - """ + def _send_bytes( + self, + conn: _ISocket, + buffer: Union[bytes, bytearray, memoryview], + ): + bytes_sent: int = 0 + bytes_to_send = len(buffer) + view = memoryview(buffer) + while bytes_sent < bytes_to_send: + try: + bytes_sent += conn.send(view[bytes_sent:]) + except OSError as exc: + if exc.errno == EAGAIN: + continue + if exc.errno == ECONNRESET: + return + raise + self._size += bytes_sent + + def _close_connection(self) -> None: + try: + self._request.connection.close() + except (BrokenPipeError, OSError): + pass - request: HTTPRequest - """The request that this is a response to.""" - http_version: str - status: HTTPStatus - headers: HTTPHeaders - content_type: str +class FileResponse(Response): """ - Defaults to ``text/plain`` if not set. + Specialized version of `Response` class for sending files. + + Instead of ``body`` it takes ``filename`` and ``root_path`` arguments. + It is also possible to send only headers with ``head_only`` argument or modify ``buffer_size``. - Can be explicitly provided in the constructor, in `send()` or - implicitly determined from filename in `send_file()`. + If browsers should download the file instead of displaying it, use ``as_attachment`` and + ``download_filename`` arguments. - Common MIME types are defined in `adafruit_httpserver.mime_type.MIMEType`. + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return FileResponse(request, filename='index.html', root_path='/www') """ - def __init__( # pylint: disable=too-many-arguments + def __init__( self, - request: HTTPRequest, - status: Union[HTTPStatus, Tuple[int, str]] = CommonHTTPStatus.OK_200, - headers: Union[HTTPHeaders, Dict[str, str]] = None, + request: Request, + filename: str = "index.html", + root_path: str = None, + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, content_type: str = None, - http_version: str = "HTTP/1.1", - chunked: bool = False, + as_attachment: bool = False, + download_filename: str = None, + buffer_size: int = 1024, + head_only: bool = False, + safe: bool = True, ) -> None: """ - Creates an HTTP response. + :param Request request: Request that this is a response to. + :param str filename: Name of the file to send. + :param str root_path: Path to the root directory from which to serve files. Defaults to + server's ``root_path``. + :param Status status: Status code and text. Defaults to ``200 OK``. + :param Headers headers: Headers to include in response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + :param str content_type: Content type of response. + :param bool as_attachment: If ``True``, the file will be sent as an attachment. + :param str download_filename: Name of the file to send as an attachment. + :param int buffer_size: Size of the buffer used to send the file. Defaults to ``1024``. + :param bool head_only: If ``True``, only headers will be sent. Defaults to ``False``. + :param bool safe: If ``True``, checks if ``filename`` is valid. Defaults to ``True``. + """ + if safe: + self._verify_file_path_is_valid(filename) + + super().__init__( + request=request, + headers=headers, + cookies=cookies, + content_type=content_type, + status=status, + ) + self._filename = filename + "index.html" if filename.endswith("/") else filename + self._root_path = root_path or self._request.server.root_path - Sets `status`, ``headers`` and `http_version` - and optionally default ``content_type``. + if self._root_path is None: + raise ValueError("root_path must be provided in Server or in FileResponse") - To send the response, call `send` or `send_file`. - For chunked response use - ``with HTTPRequest(request, content_type=..., chunked=True) as r:`` and `send_chunk`. + self._full_file_path = self._combine_path(self._root_path, self._filename) + self._content_type = content_type or MIMETypes.get_for_filename(self._filename) + self._file_length = self._get_file_length(self._full_file_path) + + self._buffer_size = buffer_size + self._head_only = head_only + self._safe = safe + + if as_attachment: + self._headers.setdefault( + "Content-Disposition", + f"attachment; filename={download_filename or self._filename.split('/')[-1]}", + ) + + @staticmethod + def _verify_file_path_is_valid(file_path: str): """ - self.request = request - self.status = status if isinstance(status, HTTPStatus) else HTTPStatus(*status) - self.headers = ( - headers.copy() if isinstance(headers, HTTPHeaders) else HTTPHeaders(headers) - ) - self.content_type = content_type - self.http_version = http_version - self.chunked = chunked - self._response_already_sent = False + Verifies that ``file_path`` does not contain backslashes or parent directory references. - def _send_headers( + If not raises error corresponding to the problem. + """ + + # Check for backslashes + if "\\" in file_path: + raise BackslashInPathError(file_path) + + # Check each component of the path for parent directory references + for part in file_path.split("/"): + if part == "..": + raise ParentDirectoryReferenceError(file_path) + + @staticmethod + def _combine_path(root_path: str, filename: str) -> str: + """ + Combines ``root_path`` and ``filename`` into a single path. + """ + + if not root_path.endswith("/"): + root_path += "/" + if filename.startswith("/"): + filename = filename[1:] + + return root_path + filename + + @staticmethod + def _get_file_length(file_path: str) -> int: + """ + Tries to get the length of the file at ``file_path``. + Raises ``FileNotExistsError`` if file does not exist. + """ + try: + stat = os.stat(file_path) + st_mode, st_size = stat[0], stat[6] + assert (st_mode & 0o170000) == 0o100000 # Check if it is a regular file + return st_size + except (OSError, AssertionError): + raise FileNotExistsError(file_path) + + def _send(self) -> None: + self._send_headers(self._file_length, self._content_type) + + if not self._head_only: + with open(self._full_file_path, "rb") as file: + while bytes_read := file.read(self._buffer_size): + self._send_bytes(self._request.connection, bytes_read) + self._close_connection() + + +class ChunkedResponse(Response): + """ + Specialized version of `Response` class for sending data using chunked transfer encoding. + + Instead of requiring the whole content to be passed to the constructor, it expects + a **generator** that yields chunks of data. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + def body(): + yield "Some ch" + yield "unked co" + yield "ntent" + + return ChunkedResponse(request, body, content_type="text/plain") + """ + + def __init__( self, - content_length: Optional[int] = None, + request: Request, + body: Generator[Union[str, bytes], Any, Any], + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, content_type: str = None, ) -> None: """ - Sends headers. - Implicitly called by `send` and `send_file` and in - ``with HTTPResponse(request, chunked=True) as response:`` context manager. + :param Request request: Request object + :param Generator body: Generator that yields chunks of data. + :param Status status: Status object or tuple with code and message. + :param Headers headers: Headers to be sent with the response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + :param str content_type: Content type of the response. """ - headers = self.headers.copy() - response_message_header = ( - f"{self.http_version} {self.status.code} {self.status.text}\r\n" + super().__init__( + request=request, + headers=headers, + cookies=cookies, + status=status, + content_type=content_type, ) + self._headers.setdefault("Transfer-Encoding", "chunked") + self._body = body + + def _send_chunk(self, chunk: Union[str, bytes] = "") -> None: + encoded_chunk = chunk.encode("utf-8") if isinstance(chunk, str) else chunk + + self._send_bytes(self._request.connection, b"%x\r\n" % len(encoded_chunk)) + self._send_bytes(self._request.connection, encoded_chunk) + self._send_bytes(self._request.connection, b"\r\n") + + def _send(self) -> None: + self._send_headers() + + for chunk in self._body(): + if 0 < len(chunk): # Don't send empty chunks + self._send_chunk(chunk) + + # Empty chunk to indicate end of response + self._send_chunk() + self._close_connection() + + +class JSONResponse(Response): + """ + Specialized version of `Response` class for sending JSON data. - headers.setdefault( - "Content-Type", content_type or self.content_type or MIMEType.TYPE_TXT + Instead of requiring ``body`` to be passed to the constructor, it expects ``data`` to be passed + instead. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return JSONResponse(request, {"key": "value"}) + """ + + def __init__( + self, + request: Request, + data: Dict[Any, Any], + *, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, + status: Union[Status, Tuple[int, str]] = OK_200, + ) -> None: + """ + :param Request request: Request that this is a response to. + :param dict data: Data to be sent as JSON. + :param Headers headers: Headers to include in response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + :param Status status: Status code and text. Defaults to 200 OK. + """ + super().__init__( + request=request, + headers=headers, + cookies=cookies, + status=status, ) - headers.setdefault("Connection", "close") - if self.chunked: - headers.setdefault("Transfer-Encoding", "chunked") - else: - headers.setdefault("Content-Length", content_length) + self._data = data - for header, value in headers.items(): - response_message_header += f"{header}: {value}\r\n" - response_message_header += "\r\n" + def _send(self) -> None: + encoded_data = json.dumps(self._data).encode("utf-8") + + self._send_headers(len(encoded_data), "application/json") + self._send_bytes(self._request.connection, encoded_data) + self._close_connection() + + +class Redirect(Response): + """ + Specialized version of `Response` class for redirecting to another URL. + + Instead of requiring the body to be passed to the constructor, it expects a URL to redirect to. + + Example:: + + @server.route(path, method) + def route_func(request: Request): + + return Redirect(request, "https://www.example.com") + """ + + def __init__( + self, + request: Request, + url: str, + *, + permanent: bool = False, + preserve_method: bool = False, + status: Union[Status, Tuple[int, str]] = None, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, + ) -> None: + """ + By default uses ``permament`` and ``preserve_method`` to determine the ``status`` code to + use, but if you prefer you can specify it directly. + + Note that ``301 Moved Permanently`` and ``302 Found`` can change the method to ``GET`` + while ``307 Temporary Redirect`` and ``308 Permanent Redirect`` preserve the method. + + More information: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages + + :param Request request: Request that this is a response to. + :param str url: URL to redirect to. + :param bool permanent: Whether to use a permanent redirect or a temporary one. + :param bool preserve_method: Whether to preserve the method of the request. + :param Status status: Status object or tuple with code and message. + :param Headers headers: Headers to include in response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + """ - self._send_bytes( - self.request.connection, response_message_header.encode("utf-8") + if status is not None and (permanent or preserve_method): + raise ValueError("Cannot specify both status and permanent/preserve_method argument") + + if status is None: + if preserve_method: + status = PERMANENT_REDIRECT_308 if permanent else TEMPORARY_REDIRECT_307 + else: + status = MOVED_PERMANENTLY_301 if permanent else FOUND_302 + + super().__init__(request, status=status, headers=headers, cookies=cookies) + self._headers.update({"Location": url}) + + def _send(self) -> None: + self._send_headers() + self._close_connection() + + +class SSEResponse(Response): + """ + Specialized version of `Response` class for sending Server-Sent Events. + + Allows one way communication with the client using a persistent connection. + + Keep in mind, that in order to send events, the socket must be kept open. This means that you + have to store the response object somewhere, so you can send events to it and close it later. + + **It is very important to close the connection manually, it will not be done automatically.** + + Example:: + + sse = None + + @server.route(path, method) + def route_func(request: Request): + + # Store the response object somewhere in global scope + global sse + sse = SSEResponse(request) + + return sse + + ... + + # Later, when you want to send an event + sse.send_event("Simple message") + sse.send_event("Message", event="event_name", id=1, retry=5000) + + # Close the connection + sse.close() + """ + + def __init__( + self, + request: Request, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, + ) -> None: + """ + :param Request request: Request object + :param Headers headers: Headers to be sent with the response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + """ + super().__init__( + request=request, + headers=headers, + cookies=cookies, + content_type="text/event-stream", ) + self._headers.setdefault("Cache-Control", "no-cache") + self._headers.setdefault("Connection", "keep-alive") + + def _send(self) -> None: + self._send_headers() - def send( + def send_event( self, - body: str = "", - content_type: str = None, + data: str, + event: str = None, + id: int = None, + retry: int = None, + custom_fields: Dict[str, str] = None, ) -> None: """ - Sends response with content built from ``body``. - Implicitly calls ``_send_headers`` before sending the body. + Send event to the client. - Should be called **only once** per response. + :param str data: The data to be sent. + :param str event: (Optional) The name of the event. + :param int id: (Optional) The event ID. + :param int retry: (Optional) The time (in milliseconds) to wait before retrying the event. + :param Dict[str, str] custom_fields: (Optional) Custom fields to be sent with the event. + """ + message = f"data: {data}\n" + if event: + message += f"event: {event}\n" + if id: + message += f"id: {id}\n" + if retry: + message += f"retry: {retry}\n" + if custom_fields: + for field, value in custom_fields.items(): + message += f"{field}: {value}\n" + message += "\n" + + self._send_bytes(self._request.connection, message.encode("utf-8")) + + def close(self): """ - if self._response_already_sent: - raise RuntimeError("Response was already sent") + Close the connection. - if getattr(body, "encode", None): - encoded_response_message_body = body.encode("utf-8") - else: - encoded_response_message_body = body + **Always call this method when you are done sending events.** + """ + self._send_bytes(self._request.connection, b"event: close\n") + self._close_connection() - self._send_headers( - content_type=content_type or self.content_type, - content_length=len(encoded_response_message_body), - ) - self._send_bytes(self.request.connection, encoded_response_message_body) - self._response_already_sent = True - def send_file( +class Websocket(Response): + """ + Specialized version of `Response` class for creating a websocket connection. + + Allows two way communication between the client and the server. + + Keep in mind, that in order to send and receive messages, the socket must be kept open. + This means that you have to store the response object somewhere, so you can send events + to it and close it later. + + **It is very important to close the connection manually, it will not be done automatically.** + + Example:: + + ws = None + + @server.route(path, method) + def route_func(request: Request): + + # Store the response object somewhere in global scope + global ws + ws = Websocket(request) + + return ws + + ... + + # Receive message from client + message = ws.receive() + + # Later, when you want to send an event + ws.send_message("Simple message") + + # Close the connection + ws.close() + """ + + GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + FIN = 0b10000000 # FIN bit indicating the final fragment + + # opcodes + CONT = 0 # Continuation frame, TODO: Currently not supported + TEXT = 1 # Frame contains UTF-8 text + BINARY = 2 # Frame contains binary data + CLOSE = 8 # Frame closes the connection + PING = 9 # Frame is a ping, expecting a pong + PONG = 10 # Frame is a pong, in response to a ping + + @staticmethod + def _check_request_initiates_handshake(request: Request): + if not all( + [ + "websocket" in request.headers.get_directive("Upgrade", "").lower(), + "upgrade" in request.headers.get_directive("Connection", "").lower(), + "Sec-WebSocket-Key" in request.headers, + ] + ): + raise ValueError("Request does not initiate websocket handshake") + + @staticmethod + def _process_sec_websocket_key(request: Request) -> str: + key = request.headers.get_directive("Sec-WebSocket-Key") + + if key is None: + raise ValueError("Request does not have Sec-WebSocket-Key header") + + response_key = hashlib.new("sha1", key.encode()) + response_key.update(Websocket.GUID) + + return b2a_base64(response_key.digest()).strip().decode() + + def __init__( self, - filename: str = "index.html", - root_path: str = "./", + request: Request, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, buffer_size: int = 1024, ) -> None: """ - Send response with content of ``filename`` located in ``root_path``. - Implicitly calls ``_send_headers`` before sending the file content. - File is send split into ``buffer_size`` parts. - - Should be called **only once** per response. + :param Request request: Request object + :param Headers headers: Headers to be sent with the response. + :param Dict[str, str] cookies: Cookies to be sent with the response. + :param int buffer_size: Size of the buffer used to send and receive messages. """ - if self._response_already_sent: - raise RuntimeError("Response was already sent") + self._check_request_initiates_handshake(request) - if not root_path.endswith("/"): - root_path += "/" - try: - file_length = os.stat(root_path + filename)[6] - except OSError: - # If the file doesn't exist, return 404. - HTTPResponse(self.request, status=CommonHTTPStatus.NOT_FOUND_404).send() - return - - self._send_headers( - content_type=MIMEType.from_file_name(filename), - content_length=file_length, + sec_accept_key = self._process_sec_websocket_key(request) + + super().__init__( + request=request, + status=SWITCHING_PROTOCOLS_101, + headers=headers, + cookies=cookies, ) + self._headers.setdefault("Upgrade", "websocket") + self._headers.setdefault("Connection", "Upgrade") + self._headers.setdefault("Sec-WebSocket-Accept", sec_accept_key) + self._headers.setdefault("Content-Type", None) + self._buffer_size = buffer_size + self.closed = False - with open(root_path + filename, "rb") as file: - while bytes_read := file.read(buffer_size): - self._send_bytes(self.request.connection, bytes_read) - self._response_already_sent = True + request.connection.setblocking(False) - def send_chunk(self, chunk: str = "") -> None: - """ - Sends chunk of response. + @staticmethod + def _parse_frame_header(header): + fin = header[0] & Websocket.FIN + opcode = header[0] & 0b00001111 + has_mask = header[1] & 0b10000000 + length = header[1] & 0b01111111 - Should be used **only** inside - ``with HTTPResponse(request, chunked=True) as response:`` context manager. + if length == 0b01111110: + length = -2 + elif length == 0b01111111: + length = -8 - :param str chunk: String data to be sent. - """ - if getattr(chunk, "encode", None): - chunk = chunk.encode("utf-8") + return fin, opcode, has_mask, length + + def _read_frame(self): + buffer = bytearray(self._buffer_size) + + header_length = self._request.connection.recv_into(buffer, 2) + header_bytes = buffer[:header_length] + + fin, opcode, has_mask, length = self._parse_frame_header(header_bytes) - self._send_bytes(self.request.connection, b"%x\r\n" % len(chunk)) - self._send_bytes(self.request.connection, chunk) - self._send_bytes(self.request.connection, b"\r\n") + # TODO: Handle continuation frames, currently not supported + if fin != Websocket.FIN and opcode == Websocket.CONT: + return Websocket.CONT, None - def __enter__(self): - if self.chunked: - self._send_headers() - return self + payload = b"" + if fin == Websocket.FIN and opcode == Websocket.CLOSE: + return Websocket.CLOSE, payload - def __exit__(self, exception_type, exception_value, exception_traceback): - if exception_type is not None: - return False + if length < 0: + length = self._request.connection.recv_into(buffer, -length) + length = int.from_bytes(buffer[:length], "big") - if self.chunked: - self.send_chunk("") - return True + if has_mask: + mask_length = self._request.connection.recv_into(buffer, 4) + mask = buffer[:mask_length] + + while 0 < length: + payload_length = self._request.connection.recv_into(buffer, length) + payload += buffer[: min(payload_length, length)] + length -= min(payload_length, length) + + if has_mask: + payload = bytes(byte ^ mask[idx % 4] for idx, byte in enumerate(payload)) + + return opcode, payload + + def _handle_frame(self, opcode: int, payload: bytes) -> Union[str, bytes, None]: + # TODO: Handle continuation frames, currently not supported + if opcode == Websocket.CONT: + return None + + if opcode == Websocket.CLOSE: + self.close() + return None + + if opcode == Websocket.PONG: + return None + if opcode == Websocket.PING: + self.send_message(payload, Websocket.PONG) + return payload + + try: + payload = payload.decode() if opcode == Websocket.TEXT else payload + except UnicodeError: + pass + + return payload + + def receive(self, fail_silently: bool = False) -> Union[str, bytes, None]: + """ + Receive a message from the client. + + :param bool fail_silently: If True, no error will be raised if the connection is closed. + """ + if self.closed: + if fail_silently: + return None + raise RuntimeError("Websocket connection is closed, cannot receive messages") + + try: + opcode, payload = self._read_frame() + frame_data = self._handle_frame(opcode, payload) + + return frame_data + except OSError as error: + if error.errno == EAGAIN: # No messages available + return None + if error.errno == ETIMEDOUT: # Connection timed out + return None + if error.errno == ENOTCONN: # Client disconnected + self.close() + return None + raise error @staticmethod - def _send_bytes( - conn: Union["SocketPool.Socket", "socket.socket"], - buffer: Union[bytes, bytearray, memoryview], + def _prepare_frame(opcode: int, message: bytes) -> bytearray: + frame = bytearray() + + frame.append(Websocket.FIN | opcode) # Setting FIN bit + + payload_length = len(message) + + # Message under 126 bytes, use 1 byte for length + if payload_length < 126: + frame.append(payload_length) + + # Message between 126 and 65535 bytes, use 2 bytes for length + elif payload_length < 65536: + frame.append(126) + frame.extend(payload_length.to_bytes(2, "big")) + + # Message over 65535 bytes, use 8 bytes for length + else: + frame.append(127) + frame.extend(payload_length.to_bytes(8, "big")) + + frame.extend(message) + return frame + + def send_message( + self, + message: Union[str, bytes], + opcode: int = None, + fail_silently: bool = False, ): - bytes_sent = 0 - bytes_to_send = len(buffer) - view = memoryview(buffer) - while bytes_sent < bytes_to_send: - try: - bytes_sent += conn.send(view[bytes_sent:]) - except OSError as exc: - if exc.errno == EAGAIN: - continue - if exc.errno == ECONNRESET: - return + """ + Send a message to the client. + + :param str message: Message to be sent. + :param int opcode: Opcode of the message. Defaults to TEXT if message is a string and + BINARY for bytes. + :param bool fail_silently: If True, no error will be raised if the connection is closed. + """ + if self.closed: + if fail_silently: + return + raise RuntimeError("Websocket connection is closed, cannot send message") + + determined_opcode = opcode or ( + Websocket.TEXT if isinstance(message, str) else Websocket.BINARY + ) + + if determined_opcode == Websocket.TEXT: + message = message.encode() + + frame = self._prepare_frame(determined_opcode, message) + + try: + self._send_bytes(self._request.connection, frame) + except BrokenPipeError as error: + if fail_silently: + return + raise error + + def _send(self) -> None: + self._send_headers() + + def close(self): + """ + Close the connection. + + **Always call this method when you are done sending events.** + """ + if not self.closed: + self.send_message(b"", Websocket.CLOSE, fail_silently=True) + self._close_connection() + self.closed = True diff --git a/adafruit_httpserver/route.py b/adafruit_httpserver/route.py index 78d8c6c..2b66dd6 100644 --- a/adafruit_httpserver/route.py +++ b/adafruit_httpserver/route.py @@ -1,28 +1,195 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.route._HTTPRoute` +`adafruit_httpserver.route` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ -from .methods import HTTPMethod +try: + from typing import TYPE_CHECKING, Callable, Dict, Iterable, Literal, Tuple, Union + if TYPE_CHECKING: + from .response import Response +except ImportError: + pass -class _HTTPRoute: - """Route definition for different paths, see `adafruit_httpserver.server.HTTPServer.route`.""" +import re - def __init__(self, path: str = "", method: HTTPMethod = HTTPMethod.GET) -> None: +from .methods import GET + + +class Route: + """Route definition for different paths, see `adafruit_httpserver.server.Server.route`.""" + + @staticmethod + def _prepare_path_pattern(path: str, append_slash: bool) -> str: + # Escape all dots + path = re.sub(r"\.", r"\\.", path) + + # Replace url parameters with regex groups + path = re.sub(r"<\w+>", r"([^/]+)", path) + + # Replace wildcards with corresponding regex + path = path.replace(r"\.\.\.\.", r".+").replace(r"\.\.\.", r"[^/]+") + + # Add optional slash at the end if append_slash is True + if append_slash: + path += r"/?" + + # Add start and end of string anchors + return f"^{path}$" + + def __init__( + self, + path: str = "", + methods: Union[str, Iterable[str]] = GET, + handler: Callable = None, + *, + append_slash: bool = False, + ) -> None: + self._validate_path(path, append_slash) self.path = path - self.method = method + self.methods = set(methods) if isinstance(methods, (set, list, tuple)) else set([methods]) + self.handler = handler + self.parameters_names = [ + name[1:-1] for name in re.compile(r"/[^<>]*/?").split(path) if name != "" + ] + self.path_pattern = re.compile(self._prepare_path_pattern(path, append_slash)) + + @staticmethod + def _validate_path(path: str, append_slash: bool) -> None: + if not path.startswith("/"): + raise ValueError("Path must start with a slash.") + + if path.endswith("/") and append_slash: + raise ValueError("Cannot use append_slash=True when path ends with /") + + if "//" in path: + raise ValueError("Path cannot contain double slashes.") + + if "<>" in path: + raise ValueError("All URL parameters must be named.") + + if re.search(r"[^/]<[^/]+>|<[^/]+>[^/]", path): + raise ValueError("All URL parameters must be between slashes.") + + if re.search(r"[^/.]\.\.\.\.?|\.?\.\.\.[^/.]", path): + raise ValueError("... and .... must be between slashes") + + if "....." in path: + raise ValueError("Path cannot contain more than 4 dots in a row.") + + def matches( + self, method: str, path: str + ) -> Union[Tuple[Literal[False], None], Tuple[Literal[True], Dict[str, str]]]: + """ + Checks if the route matches given ``method`` and ``path``. + + If the route contains parameters, it will check if the ``path`` contains values for + them. + + Returns tuple of a boolean that indicates if the routes matches and a dict containing + values for url parameters. + If the route does not match ``path`` or ``method`` if will return ``None`` instead of dict. + + Examples:: + + route = Route("/example", GET, append_slash=True) + + route.matches(GET, "/example") # True, {} + route.matches(GET, "/example/") # True, {} + + route.matches(GET, "/other-example") # False, None + route.matches(POST, "/example/") # False, None + + ... + + route = Route("/example/", GET) - def __hash__(self) -> int: - return hash(self.method) ^ hash(self.path) + route.matches(GET, "/example/123") # True, {"parameter": "123"} - def __eq__(self, other: "_HTTPRoute") -> bool: - return self.method == other.method and self.path == other.path + route.matches(GET, "/other-example") # False, None + + ... + + route = Route("/example/.../something", GET) + route.matches(GET, "/example/123/something") # True, {} + + route = Route("/example/..../something", GET) + route.matches(GET, "/example/123/456/something") # True, {} + """ + + if method not in self.methods: + return False, None + + path_match = self.path_pattern.match(path) + if path_match is None: + return False, None + + url_parameters_values = path_match.groups() + + return True, dict(zip(self.parameters_names, url_parameters_values)) def __repr__(self) -> str: - return f"HTTPRoute(path={repr(self.path)}, method={repr(self.method)})" + path = self.path + methods = self.methods + handler = self.handler + + return f"" + + +def as_route( + path: str, + methods: Union[str, Iterable[str]] = GET, + *, + append_slash: bool = False, +) -> "Callable[[Callable[..., Response]], Route]": + """ + Decorator used to convert a function into a ``Route`` object. + + ``as_route`` can be only used once per function, because it replaces the function with + a ``Route`` object that has the same name as the function. + + Later it can be imported and registered in the ``Server``. + + :param str path: URL path + :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. + :param bool append_slash: If True, the route will be accessible with and without a + trailing slash + + Example:: + + # Converts a function into a Route object + @as_route("/example") + def some_func(request): + ... + + some_func # Route(path="/example", methods={"GET"}, handler=) + + # WRONG: as_route can be used only once per function + @as_route("/wrong-example1") + @as_route("/wrong-example2") + def wrong_func2(request): + ... + + # If a route is in another file, you can import it and register it to the server + + from .routes import some_func + + ... + + server.add_routes([ + some_func, + ]) + """ + + def route_decorator(func: Callable) -> Route: + if isinstance(func, Route): + raise ValueError("as_route can be used only once per function.") + + return Route(path, methods, func, append_slash=append_slash) + + return route_decorator diff --git a/adafruit_httpserver/server.py b/adafruit_httpserver/server.py index 0a41616..e15a428 100644 --- a/adafruit_httpserver/server.py +++ b/adafruit_httpserver/server.py @@ -1,102 +1,332 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.server.HTTPServer` +`adafruit_httpserver.server` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ try: - from typing import Callable, Protocol, Union - from socket import socket - from socketpool import SocketPool + from typing import Callable, Dict, Iterable, List, Tuple, Union except ImportError: pass from errno import EAGAIN, ECONNRESET, ETIMEDOUT +from sys import implementation +from time import monotonic, sleep +from traceback import print_exception + +from .authentication import Basic, Bearer, Token, require_authentication +from .exceptions import ( + AuthenticationError, + FileNotExistsError, + InvalidPathError, + ServerStoppedError, + ServingFilesDisabledError, +) +from .headers import Headers +from .interfaces import _ISocket, _ISocketPool +from .methods import GET, HEAD +from .request import Request +from .response import FileResponse, Response +from .route import Route +from .status import BAD_REQUEST_400, FORBIDDEN_403, NOT_FOUND_404, UNAUTHORIZED_401 + +try: + from ssl import SSLContext, create_default_context + + try: # ssl imports for C python + from ssl import ( + CERT_NONE, + Purpose, + SSLError, + ) + except ImportError: + pass + SSL_AVAILABLE = True +except ImportError: + SSL_AVAILABLE = False -from .methods import HTTPMethod -from .request import HTTPRequest -from .response import HTTPResponse -from .route import _HTTPRoute -from .status import CommonHTTPStatus +NO_REQUEST = "no_request" +CONNECTION_TIMED_OUT = "connection_timed_out" +REQUEST_HANDLED_NO_RESPONSE = "request_handled_no_response" +REQUEST_HANDLED_RESPONSE_SENT = "request_handled_response_sent" -class HTTPServer: +# CircuitPython does not have these error codes +MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE = -30592 + + +class Server: """A basic socket-based HTTP server.""" - def __init__(self, socket_source: Protocol) -> None: + host: str + """Host name or IP address the server is listening on. ``None`` if server is stopped.""" + + port: int + """Port the server is listening on. ``None`` if server is stopped.""" + + root_path: str + """Root directory to serve files from. ``None`` if serving files is disabled.""" + + @staticmethod + def _validate_https_cert_provided( + certfile: Union[str, None], keyfile: Union[str, None] + ) -> None: + if certfile is None or keyfile is None: + raise ValueError("Both certfile and keyfile must be specified for HTTPS") + + @staticmethod + def _create_circuitpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: + ssl_context = create_default_context() + + ssl_context.load_verify_locations(cadata="") + ssl_context.load_cert_chain(certfile, keyfile) + + return ssl_context + + @staticmethod + def _create_cpython_ssl_context(certfile: str, keyfile: str) -> SSLContext: + ssl_context = create_default_context(purpose=Purpose.CLIENT_AUTH) + + ssl_context.load_cert_chain(certfile, keyfile) + + ssl_context.verify_mode = CERT_NONE + ssl_context.check_hostname = False + + return ssl_context + + @classmethod + def _create_ssl_context(cls, certfile: str, keyfile: str) -> SSLContext: + return ( + cls._create_circuitpython_ssl_context(certfile, keyfile) + if implementation.name == "circuitpython" + else cls._create_cpython_ssl_context(certfile, keyfile) + ) + + def __init__( + self, + socket_source: _ISocketPool, + root_path: str = None, + *, + https: bool = False, + certfile: str = None, + keyfile: str = None, + debug: bool = False, + ) -> None: """Create a server, and get it ready to run. :param socket: An object that is a source of sockets. This could be a `socketpool` in CircuitPython or the `socket` module in CPython. + :param str root_path: Root directory to serve files from + :param bool debug: Enables debug messages useful during development + :param bool https: If True, the server will use HTTPS + :param str certfile: Path to the certificate file, required if ``https`` is True + :param str keyfile: Path to the private key file, required if ``https`` is True """ self._buffer = bytearray(1024) self._timeout = 1 - self.route_handlers = {} + + self._auths = [] + self._routes: "List[Route]" = [] + self.headers = Headers() + self._socket_source = socket_source self._sock = None - self.root_path = "/" - def route(self, path: str, method: HTTPMethod = HTTPMethod.GET): + self.host, self.port = None, None + self.root_path = root_path + self.https = https + + if https: + if not SSL_AVAILABLE: + raise NotImplementedError("SSL not available on this platform") + self._validate_https_cert_provided(certfile, keyfile) + self._ssl_context = self._create_ssl_context(certfile, keyfile) + else: + self._ssl_context = None + + if root_path in {"", "/"} and debug: + _debug_warning_exposed_files(root_path) + self.stopped = True + + self.debug = debug + + def route( + self, + path: str, + methods: Union[str, Iterable[str]] = GET, + *, + append_slash: bool = False, + ) -> Callable: """ Decorator used to add a route. - :param str path: filename path - :param HTTPMethod method: HTTP method: HTTPMethod.GET, HTTPMethod.POST, etc. + If request matches multiple routes, the first matched one added will be used. + + :param str path: URL path + :param str methods: HTTP method(s): ``"GET"``, ``"POST"``, ``["GET", "POST"]`` etc. + :param bool append_slash: If True, the route will be accessible with and without a + trailing slash Example:: - @server.route("/example", HTTPMethod.GET) + # Default method is GET + @server.route("/example") + def route_func(request): + ... + + # It is necessary to specify other methods like POST, PUT, etc. + @server.route("/example", POST) + def route_func(request): + ... + + # If you want to access URL with and without trailing slash, use append_slash=True + @server.route("/example-with-slash", append_slash=True) + # which is equivalent to + @server.route("/example-with-slash") + @server.route("/example-with-slash/") + def route_func(request): + ... + + # Multiple methods can be specified + @server.route("/example", [GET, POST]) + def route_func(request): + ... + + # URL parameters can be specified + @server.route("/example/", GET) e.g. /example/123 + def route_func(request, my_parameter): + ... + + # It is possible to use wildcard that can match any number of path segments + @server.route("/example/.../something", GET) # e.g. /example/123/something + @server.route("/example/..../something", GET) # e.g. /example/123/456/something def route_func(request): ... """ def route_decorator(func: Callable) -> Callable: - self.route_handlers[_HTTPRoute(path, method)] = func + self._routes.append(Route(path, methods, func, append_slash=append_slash)) return func return route_decorator - def serve_forever(self, host: str, port: int = 80, root_path: str = "") -> None: - """Wait for HTTP requests at the given host and port. Does not return. + def add_routes(self, routes: List[Route]) -> None: + """ + Add multiple routes at once. + + :param List[Route] routes: List of routes to add to the server + + Example:: + + from separate_file import external_route1, external_route2 + + ... + + server.add_routes([ + Route("/example", GET, route_func1, append_slash=True), + Route("/example/", GET, route_func2), + Route("/example/..../something", [GET, POST], route_func3), + external_route1, + external_route2, + ]} + """ + self._routes.extend(routes) + + def _verify_can_start(self, host: str, port: int) -> None: + """Check if the server can be successfully started. Raises RuntimeError if not.""" + + if host is None or port is None: + raise RuntimeError("Host and port cannot be None") + + try: + self._socket_source.getaddrinfo(host, port) + except OSError as error: + raise RuntimeError(f"Cannot start server on {host}:{port}") from error + + def serve_forever( + self, host: str = "0.0.0.0", port: int = 5000, *, poll_interval: float = 0.1 + ) -> None: + """ + Wait for HTTP requests at the given host and port. Does not return. + Ignores any exceptions raised by the handler function and continues to serve. + Returns only when the server is stopped by calling ``.stop()``. :param str host: host name or IP address :param int port: port - :param str root_path: root directory to serve files from + :param float poll_interval: interval between polls in seconds """ - self.start(host, port, root_path) + self.start(host, port) - while True: + while not self.stopped: try: - self.poll() - except OSError: - continue + if self.poll() == NO_REQUEST and poll_interval is not None: + sleep(poll_interval) + except KeyboardInterrupt: # Exit on Ctrl-C e.g. during development + self.stop() + return + except Exception: + pass # Ignore exceptions in handler function + + @staticmethod + def _create_server_socket( + socket_source: _ISocketPool, + ssl_context: "SSLContext | None", + host: str, + port: int, + ) -> _ISocket: + sock = socket_source.socket(socket_source.AF_INET, socket_source.SOCK_STREAM) + + # TODO: Temporary backwards compatibility, remove after CircuitPython 9.0.0 release + if implementation.version >= (9,) or implementation.name != "circuitpython": + sock.setsockopt(socket_source.SOL_SOCKET, socket_source.SO_REUSEADDR, 1) - def start(self, host: str, port: int = 80, root_path: str = "") -> None: + if ssl_context is not None: + sock = ssl_context.wrap_socket(sock, server_side=True) + + sock.bind((host, port)) + sock.listen(10) + sock.setblocking(False) # Non-blocking socket + + return sock + + def start(self, host: str = "0.0.0.0", port: int = 5000) -> None: """ Start the HTTP server at the given host and port. Requires calling - poll() in a while loop to handle incoming requests. + ``.poll()`` in a while loop to handle incoming requests. :param str host: host name or IP address :param int port: port - :param str root_path: root directory to serve files from """ - self.root_path = root_path + self._verify_can_start(host, port) - self._sock = self._socket_source.socket( - self._socket_source.AF_INET, self._socket_source.SOCK_STREAM - ) - self._sock.bind((host, port)) - self._sock.listen(10) - self._sock.setblocking(False) # non-blocking socket + self.host, self.port = host, port - def _receive_header_bytes( - self, sock: Union["SocketPool.Socket", "socket.socket"] - ) -> bytes: + self.stopped = False + self._sock = self._create_server_socket(self._socket_source, self._ssl_context, host, port) + + if self.debug: + _debug_started_server(self) + + def stop(self) -> None: + """ + Stops the server from listening for new connections and closes the socket. + Current requests will be processed. Server can be started again by calling ``.start()`` + or ``.serve_forever()``. + """ + self.host, self.port = None, None + + self.stopped = True + self._sock.close() + + if self.debug: + _debug_stopped_server(self) + + def _receive_header_bytes(self, sock: _ISocket) -> bytes: """Receive bytes until a empty line is received.""" - received_bytes = bytes() + received_bytes = b"" while b"\r\n\r\n" not in received_bytes: try: length = sock.recv_into(self._buffer, len(self._buffer)) @@ -104,13 +334,14 @@ def _receive_header_bytes( except OSError as ex: if ex.errno == ETIMEDOUT: break + raise except Exception as ex: raise ex return received_bytes def _receive_body_bytes( self, - sock: Union["SocketPool.Socket", "socket.socket"], + sock: _ISocket, received_body_bytes: bytes, content_length: int, ) -> bytes: @@ -122,73 +353,218 @@ def _receive_body_bytes( except OSError as ex: if ex.errno == ETIMEDOUT: break + raise except Exception as ex: raise ex return received_body_bytes[:content_length] - def poll(self): + def _receive_request( + self, + sock: _ISocket, + client_address: Tuple[str, int], + ) -> Request: + """Receive bytes from socket until the whole request is received.""" + + # Receiving data until empty line + header_bytes = self._receive_header_bytes(sock) + + # Return if no data received + if not header_bytes: + return None + + request = Request(self, sock, client_address, header_bytes) + + content_length = int(request.headers.get_directive("Content-Length", 0)) + received_body_bytes = request.body + + # Receiving remaining body bytes + request.body = self._receive_body_bytes(sock, received_body_bytes, content_length) + + return request + + def _find_handler(self, method: str, path: str) -> Union[Callable[..., "Response"], None]: """ - Call this method inside your main event loop to get the server to - check for new incoming client requests. When a request comes in, - the application callable will be invoked. + Finds a handler for a given route. + + If route used URL parameters, the handler will be wrapped to pass the parameters to the + handler. + + Example:: + + @server.route("/example/", GET) + def route_func(request, my_parameter): + ... + request.path == "/example/123" # True + my_parameter == "123" # True """ + for route in self._routes: + route_matches, url_parameters = route.matches(method, path) + + if route_matches: + + def wrapped_handler(request): + return route.handler(request, **url_parameters) + + return wrapped_handler + + return None + + def _handle_request( + self, request: Request, handler: Union[Callable, None] + ) -> Union[Response, None]: try: + # Check server authentications if necessary + if self._auths: + require_authentication(request, self._auths) + + # Handler for route exists and is callable + if handler is not None and callable(handler): + return handler(request) + + # No root_path, access to filesystem disabled, return 404. + if self.root_path is None: + raise ServingFilesDisabledError + + # Method is GET or HEAD, try to serve a file from the filesystem. + if request.method in {GET, HEAD}: + return FileResponse( + request, + filename=request.path, + head_only=request.method == HEAD, + ) + + return Response(request, status=BAD_REQUEST_400) + + except AuthenticationError: + return Response( + request, + status=UNAUTHORIZED_401, + headers={"WWW-Authenticate": 'Basic charset="UTF-8"'}, + ) + + except InvalidPathError as error: + return Response( + request, + str(error) if self.debug else "Invalid path", + status=FORBIDDEN_403, + ) + + except (FileNotExistsError, ServingFilesDisabledError) as error: + return Response( + request, + str(error) if self.debug else "File not found", + status=NOT_FOUND_404, + ) + + def _set_default_server_headers(self, response: Response) -> None: + for name, value in self.headers.items(): + response._headers.setdefault(name, value) + + def poll( + self, + ) -> str: + """ + Call this method inside your main loop to get the server to check for new incoming client + requests. When a request comes in, it will be handled by the handler function. + + Returns str representing the result of the poll + e.g. ``NO_REQUEST`` or ``REQUEST_HANDLED_RESPONSE_SENT``. + """ + if self.stopped: + raise ServerStoppedError + + conn = None + try: + if self.debug: + _debug_start_time = monotonic() + conn, client_address = self._sock.accept() - with conn: - conn.settimeout(self._timeout) + conn.settimeout(self._timeout) - # Receiving data until empty line - header_bytes = self._receive_header_bytes(conn) + # Receive the whole request + if (request := self._receive_request(conn, client_address)) is None: + conn.close() + return CONNECTION_TIMED_OUT - # Return if no data received - if not header_bytes: - return + # Find a route that matches the request's method and path and get its handler + handler = self._find_handler(request.method, request.path) - request = HTTPRequest(conn, client_address, header_bytes) + # Handle the request + response = self._handle_request(request, handler) - content_length = int(request.headers.get("Content-Length", 0)) - received_body_bytes = request.body + if response is None: + conn.close() + return REQUEST_HANDLED_NO_RESPONSE - # Receiving remaining body bytes - request.body = self._receive_body_bytes( - conn, received_body_bytes, content_length - ) + self._set_default_server_headers(response) - handler = self.route_handlers.get( - _HTTPRoute(request.path, request.method), None - ) + # Send the response + response._send() - # If a handler for route exists and is callable, call it. - if handler is not None and callable(handler): - output = handler(request) - # TODO: Remove this deprecation error in future - if isinstance(output, HTTPResponse): - raise RuntimeError( - "Returning an HTTPResponse from a route handler is deprecated." - ) - - # If no handler exists and request method is GET, try to serve a file. - elif handler is None and request.method == HTTPMethod.GET: - filename = "index.html" if request.path == "/" else request.path - HTTPResponse(request).send_file( - filename=filename, - root_path=self.root_path, - buffer_size=self.request_buffer_size, - ) - else: - HTTPResponse( - request, status=CommonHTTPStatus.BAD_REQUEST_400 - ).send() - - except OSError as ex: - # handle EAGAIN and ECONNRESET - if ex.errno == EAGAIN: - # there is no data available right now, try again later. - return - if ex.errno == ECONNRESET: - # connection reset by peer, try again later. - return - raise + if self.debug: + _debug_end_time = monotonic() + _debug_response_sent(response, _debug_end_time - _debug_start_time) + + return REQUEST_HANDLED_RESPONSE_SENT + + except Exception as error: + if isinstance(error, OSError): + # There is no data available right now, try again later. + if error.errno == EAGAIN: + return NO_REQUEST + # Connection reset by peer, try again later. + if error.errno == ECONNRESET: + return NO_REQUEST + # Handshake failed, try again later. + if error.errno == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE: + return NO_REQUEST + + # CPython specific SSL related errors + if implementation.name != "circuitpython" and isinstance(error, SSLError): + # Ignore unknown SSL certificate errors + if getattr(error, "reason", None) == "SSLV3_ALERT_CERTIFICATE_UNKNOWN": + return NO_REQUEST + + if self.debug: + _debug_exception_in_handler(error) + + if conn is not None: + conn.close() + raise error # Raise the exception again to be handled by the user. + + def require_authentication(self, auths: List[Union[Basic, Token, Bearer]]) -> None: + """ + Requires authentication for all routes and files in ``root_path``. + Any non-authenticated request will be rejected with a 401 status code. + + Example:: + + server = Server(pool, "/static") + server.require_authentication([Basic("username", "password")]) + """ + self._auths = auths + + @property + def headers(self) -> Headers: + """ + Headers to be sent with every response, without the need to specify them in each handler. + + If a header is specified in both the handler and the server, the handler's header will be + used. + + Example:: + + server = Server(pool, "/static") + server.headers = { + "X-Server": "Adafruit CircuitPython HTTP Server", + "Access-Control-Allow-Origin": "*", + } + """ + return self._headers + + @headers.setter + def headers(self, value: Union[Headers, Dict[str, str]]) -> None: + self._headers = value.copy() if isinstance(value, Headers) else Headers(value) @property def request_buffer_size(self) -> int: @@ -201,7 +577,7 @@ def request_buffer_size(self) -> int: Example:: - server = HTTPServer(pool) + server = Server(pool, "/static") server.request_buffer_size = 2048 server.serve_forever(str(wifi.radio.ipv4_address)) @@ -223,7 +599,7 @@ def socket_timeout(self) -> int: Example:: - server = HTTPServer(pool) + server = Server(pool, "/static") server.socket_timeout = 3 server.serve_forever(str(wifi.radio.ipv4_address)) @@ -235,6 +611,54 @@ def socket_timeout(self, value: int) -> None: if isinstance(value, (int, float)) and value > 0: self._timeout = value else: - raise ValueError( - "HTTPServer.socket_timeout must be a positive numeric value." - ) + raise ValueError("Server.socket_timeout must be a positive numeric value.") + + def __repr__(self) -> str: + host = self.host + port = self.port + root_path = self.root_path + + return f"" + + +def _debug_warning_exposed_files(root_path: str): + """Warns about exposing all files on the device.""" + print( + f"WARNING: Setting root_path to '{root_path}' will expose all files on your device " + "through the webserver, including potentially sensitive files like settings.toml. " + "Consider making a sub-directory on your device and using that for your root_path instead." + ) + + +def _debug_started_server(server: "Server"): + """Prints a message when the server starts.""" + scheme = "https" if server.https else "http" + host, port = server.host, server.port + + print(f"Started development server on {scheme}://{host}:{port}") + + +def _debug_response_sent(response: "Response", time_elapsed: float): + """Prints a message after a response is sent.""" + client_ip = response._request.client_address[0] + method = response._request.method + query_params = response._request.query_params + path = response._request.path + (f"?{query_params}" if query_params else "") + req_size = len(response._request.raw_request) + status = response._status + res_size = response._size + time_elapsed_ms = f"{round(time_elapsed*1000)}ms" + + print( + f'{client_ip} -- "{method} {path}" {req_size} -- "{status}" {res_size} -- {time_elapsed_ms}' + ) + + +def _debug_stopped_server(server: "Server"): + """Prints a message after the server stops.""" + print("Stopped development server") + + +def _debug_exception_in_handler(error: Exception): + """Prints a message when an exception is raised in a handler.""" + print_exception(error) diff --git a/adafruit_httpserver/status.py b/adafruit_httpserver/status.py index d32538c..8f40f07 100644 --- a/adafruit_httpserver/status.py +++ b/adafruit_httpserver/status.py @@ -1,18 +1,19 @@ -# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries, Michał Pokusa # # SPDX-License-Identifier: MIT """ -`adafruit_httpserver.status.HTTPStatus` +`adafruit_httpserver.status` ==================================================== * Author(s): Dan Halbert, Michał Pokusa """ -class HTTPStatus: # pylint: disable=too-few-public-methods - """HTTP status codes.""" +class Status: + """HTTP status code.""" def __init__(self, code: int, text: str): - """Define a status code. + """ + Define a status code. :param int code: Numeric value: 200, 404, etc. :param str text: Short phrase: "OK", "Not Found', etc. @@ -20,27 +21,53 @@ def __init__(self, code: int, text: str): self.code = code self.text = text - def __repr__(self): - return f'HTTPStatus({self.code}, "{self.text}")' + def __eq__(self, other: "Status"): + return self.code == other.code and self.text == other.text def __str__(self): return f"{self.code} {self.text}" - def __eq__(self, other: "HTTPStatus"): - return self.code == other.code and self.text == other.text + def __repr__(self): + code = self.code + text = self.text + + return f'' + + +SWITCHING_PROTOCOLS_101 = Status(101, "Switching Protocols") + +OK_200 = Status(200, "OK") + +CREATED_201 = Status(201, "Created") + +ACCEPTED_202 = Status(202, "Accepted") + +NO_CONTENT_204 = Status(204, "No Content") + +PARTIAL_CONTENT_206 = Status(206, "Partial Content") + +MOVED_PERMANENTLY_301 = Status(301, "Moved Permanently") + +FOUND_302 = Status(302, "Found") + +TEMPORARY_REDIRECT_307 = Status(307, "Temporary Redirect") + +PERMANENT_REDIRECT_308 = Status(308, "Permanent Redirect") + +BAD_REQUEST_400 = Status(400, "Bad Request") + +UNAUTHORIZED_401 = Status(401, "Unauthorized") + +FORBIDDEN_403 = Status(403, "Forbidden") +NOT_FOUND_404 = Status(404, "Not Found") -class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods - """Common HTTP status codes.""" +METHOD_NOT_ALLOWED_405 = Status(405, "Method Not Allowed") - OK_200 = HTTPStatus(200, "OK") - """200 OK""" +TOO_MANY_REQUESTS_429 = Status(429, "Too Many Requests") - BAD_REQUEST_400 = HTTPStatus(400, "Bad Request") - """400 Bad Request""" +INTERNAL_SERVER_ERROR_500 = Status(500, "Internal Server Error") - NOT_FOUND_404 = HTTPStatus(404, "Not Found") - """404 Not Found""" +NOT_IMPLEMENTED_501 = Status(501, "Not Implemented") - INTERNAL_SERVER_ERROR_500 = HTTPStatus(500, "Internal Server Error") - """500 Internal Server Error""" +SERVICE_UNAVAILABLE_503 = Status(503, "Service Unavailable") diff --git a/docs/api.rst b/docs/api.rst index 4615507..8c344ef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,6 +4,9 @@ .. If your library file(s) are nested in a directory (e.g. /adafruit_foo/foo.py) .. use this format as the module name: "adafruit_foo.foo" +API Reference +############# + .. automodule:: adafruit_httpserver :members: @@ -12,18 +15,20 @@ .. automodule:: adafruit_httpserver.request :members: + :inherited-members: .. automodule:: adafruit_httpserver.response :members: .. automodule:: adafruit_httpserver.headers :members: + :inherited-members: .. automodule:: adafruit_httpserver.status :members: -.. automodule:: adafruit_httpserver.methods +.. automodule:: adafruit_httpserver.mime_types :members: -.. automodule:: adafruit_httpserver.mime_type +.. automodule:: adafruit_httpserver.exceptions :members: diff --git a/docs/conf.py b/docs/conf.py index 5dda03c..479ad59 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,12 +1,10 @@ -# -*- coding: utf-8 -*- - # SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries # # SPDX-License-Identifier: MIT +import datetime import os import sys -import datetime sys.path.insert(0, os.path.abspath("..")) @@ -17,6 +15,7 @@ # ones. extensions = [ "sphinx.ext.autodoc", + "sphinxcontrib.jquery", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.todo", @@ -53,9 +52,7 @@ creation_year = "2022" current_year = str(datetime.datetime.now().year) year_duration = ( - current_year - if current_year == creation_year - else creation_year + " - " + current_year + current_year if current_year == creation_year else creation_year + " - " + current_year ) copyright = year_duration + " Dan Halbert" author = "Dan Halbert" @@ -112,19 +109,9 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - try: - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."] - except: - html_theme = "default" - html_theme_path = ["."] -else: - html_theme_path = ["."] +import sphinx_rtd_theme + +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/examples.rst b/docs/examples.rst index 6080b32..aa2a6d3 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,12 +1,68 @@ -Simple file serving -------------------- +.. note:: + All examples in this document are using ``Server`` in ``debug`` mode. + This mode is useful for development, but it is not recommended to use it in production. + More about Debug mode at the end of Examples section. + +Different ways of starting the server +------------------------------------- + +There are several ways to start the server on CircuitPython, mostly depending on the device you are using and +whether you have access to external network. + +Functionally, all of them are the same, not features of the server are limited or disabled in any way. + +Below you can find examples of different ways to start the server: + +.. toctree:: + + starting_methods + +CPython usage +-------------------- + +Library can also be used in CPython, no changes other than changing the ``socket_source`` are necessary. + +.. literalinclude:: ../examples/httpserver_cpython.py + :caption: examples/httpserver_cpython.py + :emphasize-lines: 5,10 + :linenos: + + +Serving static files +-------------------- + +It is possible to serve static files from the filesystem. +In this example we are serving files from the ``/static`` directory. + +In order to save memory, we are unregistering unused MIME types and registering additional ones. +`More about MIME types. `_ + +.. literalinclude:: ../examples/httpserver_static_files_serving.py + :caption: examples/httpserver_static_files_serving.py + :emphasize-lines: 11-17,22-25 + :linenos: + +You can also serve a specific file from the handler. +By default ``FileResponse`` looks for the file in the server's ``root_path`` directory +(``/default-static-directory`` in the example below), but you can change it manually in every ``FileResponse`` +(to e.g. ``/other-static-directory``, as in example below). + +By doing that, you can serve files from multiple directories, and decide exactly which files are accessible. -Serving the content of index.html from the filesystem. +.. literalinclude:: ../examples/httpserver_handler_serves_file.py + :caption: examples/httpserver_handler_serves_file.py + :emphasize-lines: 12,21 + :linenos: -.. literalinclude:: ../examples/httpserver_simple_serve.py - :caption: examples/httpserver_simple_serve.py +.. literalinclude:: ../examples/home.html + :language: html + :caption: www/home.html + :lines: 7- :linenos: +Tasks between requests +---------------------- + If you want your code to do more than just serve web pages, use the ``.start()``/``.poll()`` methods as shown in this example. @@ -14,51 +70,388 @@ Between calling ``.poll()`` you can do something useful, for example read a sensor and capture an average or a running total of the last 10 samples. -.. literalinclude:: ../examples/httpserver_simple_poll.py - :caption: examples/httpserver_simple_poll.py +``.poll()`` return value can be used to check if there was a request and if it was handled. + +.. literalinclude:: ../examples/httpserver_start_and_poll.py + :caption: examples/httpserver_start_and_poll.py + :emphasize-lines: 28,37 + :linenos: + + +If you need to perform some action periodically, or there are multiple tasks that need to be done, +it might be better to use ``asyncio`` module to handle them, which makes it really easy to add new tasks +without needing to manually manage the timing of each task. + +``asyncio`` **is not included in CircuitPython by default, it has to be installed separately.** + +.. literalinclude:: ../examples/httpserver_start_and_poll_asyncio.py + :caption: examples/httpserver_start_and_poll_asyncio.py + :emphasize-lines: 5,6,34,43,46,51,56-63 :linenos: Server with MDNS ---------------- -It is possible to use the MDNS protocol to make the server -accessible via a hostname in addition to an IP address. +It is possible to use the MDNS protocol to make the server accessible via a hostname in addition +to an IP address. It is worth noting that it takes a bit longer to get the response from the server +when accessing it via the hostname. -In this example, the server is accessible via ``http://custom-mdns-hostname/`` and ``http://custom-mdns-hostname.local/``. +In this example, the server is accessible via the IP and ``http://custom-mdns-hostname.local:5000/``. +On some routers it is also possible to use ``http://custom-mdns-hostname:5000/``, but **this is not guaranteed to work**. + +.. literalinclude:: ../examples/httpserver_mdns.py + :caption: examples/httpserver_mdns.py + :emphasize-lines: 11-13 + :linenos: + +Get CPU information +------------------- + +You can return data from sensors or any computed value as JSON. +That makes it easy to use the data in other applications. + +If you want to use the data in a web browser, it might be necessary to enable CORS. +More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS .. literalinclude:: ../examples/httpserver_cpu_information.py :caption: examples/httpserver_cpu_information.py + :emphasize-lines: 9,14-17,32 + :linenos: + +Handling different methods +--------------------------------------- + +On every ``server.route()`` call you can specify which HTTP methods are allowed. +By default, only ``GET`` method is allowed. + +You can pass a list of methods or a single method as a string. + +It is recommended to use the the values in ``adafruit_httpserver.methods`` module to avoid typos and for future proofness. + +If you want to route a given path with and without trailing slash, use ``append_slash=True`` parameter. + +In example below, handler for ``/api`` and ``/api/`` route will be called when any of ``GET``, ``POST``, ``PUT``, ``DELETE`` methods is used. + +.. literalinclude:: ../examples/httpserver_methods.py + :caption: examples/httpserver_methods.py + :emphasize-lines: 8,18,25,29,46 :linenos: Change NeoPixel color --------------------- -If you want your code to do more than just serve web pages, -use the start/poll methods as shown in this example. +There are several ways to pass data to the handler function: + +- In your handler function you can access the query/GET parameters using ``request.query_params`` +- You can also access the POST data directly using ``request.body`` or if you data is in JSON format, + you can use ``request.json()`` to parse it into a dictionary +- Alternatively for short pieces of data you can use URL parameters, which are described later in this document + For more complex data, it is recommended to use JSON format. -For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` you can change the color of the NeoPixel to red. +All of these approaches allow you to pass data to the handler function and use it in your code. + +For example by going to ``/change-neopixel-color?r=255&g=0&b=0`` or ``/change-neopixel-color/255/0/0`` +you can change the color of the NeoPixel to red. Tested on ESP32-S2 Feather. .. literalinclude:: ../examples/httpserver_neopixel.py :caption: examples/httpserver_neopixel.py + :emphasize-lines: 25-27,40,51,67,73 :linenos: -Get CPU information +Templates +--------- + +With the help of the ``adafruit_templateengine`` library, it is possible to achieve somewhat of a +server-side rendering of HTML pages. + +Instead of using string formatting, you can use templates, which can include more complex logic like loops and conditionals. +This makes it very easy to create dynamic pages, witout using JavaScript and exposing any API endpoints. + +Templates also allow splitting the code into multiple files, that can be reused in different places. +You can find more information about the template syntax in the +`adafruit_templateengine documentation `_. + +.. literalinclude:: ../examples/directory_listing.tpl.html + :caption: examples/directory_listing.tpl.html + :language: django + :lines: 9- + :emphasize-lines: 1-2,6,10,15-23,27 + :linenos: + +.. literalinclude:: ../examples/httpserver_templates.py + :caption: examples/httpserver_templates.py + :emphasize-lines: 12-15,51-59 + :linenos: + +Form data parsing --------------------- -You can return data from sensors or any computed value as JSON. -That makes it easy to use the data in other applications. +Another way to pass data to the handler function is to use form data. +Remember that it is only possible to use it with ``POST`` method. +`More about POST method. `_ -.. literalinclude:: ../examples/httpserver_cpu_information.py - :caption: examples/httpserver_cpu_information.py +It is important to use correct ``enctype``, depending on the type of data you want to send. + +- ``application/x-www-form-urlencoded`` - For sending simple text data without any special characters including spaces. + If you use it, values will be automatically parsed as strings, but special characters will be URL encoded + e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello+World%21+%5E-%24%25"`` +- ``multipart/form-data`` - For sending text with special characters and files + When used, non-file values will be automatically parsed as strings and non plain text files will be saved as ``bytes``. + e.g. ``"Hello World! ^-$%"`` will be saved as ``'Hello World! ^-$%'``, and e.g. a PNG file will be saved as ``b'\x89PNG\r\n\x1a\n\x00\...``. +- ``text/plain`` - For sending text data with special characters. + If used, values will be automatically parsed as strings, including special characters, emojis etc. + e.g. ``"Hello World! ^-$%"`` will be saved as ``"Hello World! ^-$%"``, this is the **recommended** option. + +If you pass multiple values with the same name, they will be saved as a list, that can be accessed using ``request.form_data.get_list()``. +Even if there is only one value, it will still get a list, and if there multiple values, but you use ``request.form_data.get()`` it will +return only the first one. + +.. literalinclude:: ../examples/httpserver_form_data.py + :caption: examples/httpserver_form_data.py + :emphasize-lines: 31,46,49 :linenos: -Chunked response +Cookies --------------------- -Library supports chunked responses. This is useful for streaming data. -To use it, you need to set the ``chunked=True`` when creating a ``HTTPResponse`` object. +You can use cookies to store data on the client side, that will be sent back to the server with every request. +They are often used to store authentication tokens, session IDs, but also user preferences e.g. theme. + +To access cookies, use ``request.cookies`` dictionary. +In order to set cookies, pass ``cookies`` dictionary to ``Response`` constructor or manually add ``Set-Cookie`` header. + +.. literalinclude:: ../examples/httpserver_cookies.py + :caption: examples/httpserver_cookies.py + :emphasize-lines: 69,73-74,81 + :linenos: + +Chunked response +---------------- + +Library supports chunked responses. This is useful for streaming large amounts of data. +In order to use it, you need pass a generator that yields chunks of data to a ``ChunkedResponse`` +constructor. .. literalinclude:: ../examples/httpserver_chunked.py :caption: examples/httpserver_chunked.py + :emphasize-lines: 8,20-25,27 + :linenos: + +URL parameters and wildcards +---------------------------- + +Alternatively to using query parameters, you can use URL parameters. +They are a better choice when you want to perform different actions based on the URL. +Query/GET parameters are better suited for modifying the behaviour of the handler function. + +Of course it is only a suggestion, you can use them interchangeably and/or both at the same time. + +In order to use URL parameters, you need to wrap them inside with angle brackets in ``Server.route``, e.g. ````. + +All URL parameters values are **passed as keyword arguments** to the handler function. + +Notice how the handler function in example below accepts two additional arguments : ``device_id`` and ``action``. + +If you specify multiple routes for single handler function and they have different number of URL parameters, +make sure to add default values for all the ones that might not be passed. +In the example below the second route has only one URL parameter, so the ``action`` parameter has a default value. + +Keep in mind that URL parameters are always passed as strings, so you need to convert them to the desired type. +Also note that the names of the function parameters **have to match** with the ones used in route, but they **do not have to** be in the same order. + +Alternatively you can use e.g. ``**params`` to get all the parameters as a dictionary and access them using ``params['parameter_name']``. + +It is also possible to specify a wildcard route: + +- ``...`` - matches one path segment, e.g ``/api/...`` will match ``/api/123``, but **not** ``/api/123/456`` +- ``....`` - matches multiple path segments, e.g ``/api/....`` will match ``/api/123`` and ``/api/123/456`` + +In both cases, wildcards will not match empty path segment, so ``/api/.../users`` will match ``/api/v1/users``, but not ``/api//users`` or ``/api/users``. + +.. literalinclude:: ../examples/httpserver_url_parameters.py + :caption: examples/httpserver_url_parameters.py + :emphasize-lines: 29-31,48-49,60-61 + :linenos: + +Authentication +-------------- + +In order to increase security of your server, you can use ``Basic`` and ``Bearer`` authentication. +Remember that it is **not a replacement for HTTPS**, traffic is still sent **in plain text**, but it can be used to protect your server from unauthorized access. + +If you want to apply authentication to the whole server, you need to call ``.require_authentication`` on ``Server`` instance. + +.. literalinclude:: ../examples/httpserver_authentication_server.py + :caption: examples/httpserver_authentication_server.py + :emphasize-lines: 8,10-15,19 + :linenos: + +On the other hand, if you want to apply authentication to a set of routes, you need to call ``require_authentication`` function. +In both cases you can check if ``request`` is authenticated by calling ``check_authentication`` on it. + +.. literalinclude:: ../examples/httpserver_authentication_handlers.py + :caption: examples/httpserver_authentication_handlers.py + :emphasize-lines: 9-16,21-26,34,48,60 + :linenos: + +Redirects +--------- + +Sometimes you might want to redirect the user to a different URL, either on the same server or on a different one. + +You can do that by returning ``Redirect`` from your handler function. + +You can specify wheter the redirect is permanent or temporary by passing ``permanent=...`` to ``Redirect``. +If you need the redirect to preserve the original request method, you can set ``preserve_method=True``. + +Alternatively, you can pass a ``status`` object directly to ``Redirect`` constructor. + +.. literalinclude:: ../examples/httpserver_redirects.py + :caption: examples/httpserver_redirects.py + :emphasize-lines: 21-25,31,37,49,61 + :linenos: + +Server-Sent Events +------------------ + +All types of responses until now were synchronous, meaning that the response was sent immediately after the handler function returned. +However, sometimes you might want to send data to the client at a later time, e.g. when some event occurs. +This can be overcomed by periodically polling the server, but it is not an elegant solution. Instead, you can use Server-Sent Events (SSE). + +Response is initialized on ``return``, events can be sent using ``.send_event()`` method. Due to the nature of SSE, it is necessary to store the +response object somewhere, so that it can be accessed later. + + +.. warning:: + Because of the limited number of concurrently open sockets, it is **not possible to process more than one SSE response at the same time**. + This might change in the future, but for now, it is recommended to use SSE **only with one client at a time**. + +.. literalinclude:: ../examples/httpserver_sse.py + :caption: examples/httpserver_sse.py + :emphasize-lines: 11,17,46-53,63 + :linenos: + +Websockets +---------- + +Although SSE provide a simple way to send data from the server to the client, they are not suitable for sending data the other way around. + +For that purpose, you can use Websockets. They are more complex than SSE, but they provide a persistent two-way communication channel between +the client and the server. + +Remember, that because Websockets also receive data, you have to explicitly call ``.receive()`` on the ``Websocket`` object to get the message. +This is anologous to calling ``.poll()`` on the ``Server`` object. + +The following example uses ``asyncio``, which has to be installed separately. It is not necessary to use ``asyncio`` to use Websockets, +but it is recommended as it makes it easier to handle multiple tasks. It can be used in any of the examples, but here it is particularly useful. + +.. warning:: + Because of the limited number of concurrently open sockets, it is **not possible to process more than one Websocket response at the same time**. + This might change in the future, but for now, it is recommended to use Websocket **only with one client at a time**. + +.. literalinclude:: ../examples/httpserver_websocket.py + :caption: examples/httpserver_websocket.py + :emphasize-lines: 14,21,66-73,89,100 + :linenos: + +Custom response types e.g. video streaming +------------------------------------------ + +The built-in response types may not always meet your specific requirements. In such cases, you can define custom response types and implement +the necessary logic. + +The example below demonstrates a ``XMixedReplaceResponse`` class, which uses the ``multipart/x-mixed-replace`` content type to stream video frames +from a camera, similar to a CCTV system. + +To ensure the server remains responsive, a global list of open connections is maintained. By running tasks asynchronously, the server can stream +video to multiple clients while simultaneously handling other requests. + +.. literalinclude:: ../examples/httpserver_video_stream.py + :caption: examples/httpserver_video_stream.py + :emphasize-lines: 30-72,87 + :linenos: + +HTTPS +----- + +.. warning:: + HTTPS on CircuitPython **works only on boards with enough memory e.g. ESP32-S3**. + +When you want to expose your server to the internet or an untrusted network, it is recommended to use HTTPS. +Together with authentication, it provides a relatively secure way to communicate with the server. + +.. note:: + Using HTTPS slows down the server, because of additional work with encryption and decryption. + +Enabling HTTPS is straightforward and comes down to passing the path to the certificate and key files to the ``Server`` constructor +and setting ``https=True``. + +.. literalinclude:: ../examples/httpserver_https.py + :caption: examples/httpserver_https.py + :emphasize-lines: 14-16 + :linenos: + + +To create your own certificate, you can use the following command: + +.. code-block:: bash + + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem + +You might have to change permissions of the files, so that the server can read them. + +Multiple servers +---------------- + +Although it is not the primary use case, it is possible to run multiple servers at the same time. +In order to do that, you need to create multiple ``Server`` instances and call ``.start()`` and ``.poll()`` on each of them. +Using ``.serve_forever()`` for this is not possible because of it's blocking behaviour. + +Each server **must have a different port number**. + +To distinguish between responses from different servers a 'X-Server' header is added to each response. +**This is an optional step**, both servers will work without it. + +In combination with separate authentication and diffrent ``root_path`` this allows creating moderately complex setups. +You can share same handler functions between servers or use different ones for each server. + +.. literalinclude:: ../examples/httpserver_multiple_servers.py + :caption: examples/httpserver_multiple_servers.py + :emphasize-lines: 12-13,15-16,19,27,35-36,47-48,53-54 :linenos: + +Debug mode +---------------- + +It is highly recommended to **disable debug mode in production**. + +During development it is useful to see the logs from the server. +You can enable debug mode by setting ``debug=True`` on ``Server`` instance or in constructor, +it is disabled by default. + +Debug mode prints messages on server startup, after sending a response to a request and if exception +occurs during handling of the request in ``.serve_forever()``. + +This is how the logs might look like when debug mode is enabled:: + + Started development server on http://192.168.0.100:5000 + 192.168.0.101 -- "GET /" 194 -- "200 OK" 154 -- 96ms + 192.168.0.101 -- "GET /example" 134 -- "404 Not Found" 172 -- 123ms + 192.168.0.102 -- "POST /api" 1241 -- "401 Unauthorized" 95 -- 64ms + Traceback (most recent call last): + ... + File "code.py", line 55, in example_handler + KeyError: non_existent_key + 192.168.0.103 -- "GET /index.html" 242 -- "200 OK" 154 -- 182ms + Stopped development server + +This is the default format of the logs:: + + {client_ip} -- "{request_method} {path}" {request_size} -- "{response_status}" {response_size} -- {elapsed_ms} + +If you need more information about the server or request, or you want it in a different format you can modify +functions at the bottom of ``adafruit_httpserver/server.py`` that start with ``_debug_...``. + +.. note:: + This is an advanced usage that might change in the future. It is not recommended to modify other parts of the code. diff --git a/docs/requirements.txt b/docs/requirements.txt index 88e6733..979f568 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,6 @@ # # SPDX-License-Identifier: Unlicense -sphinx>=4.0.0 +sphinx +sphinxcontrib-jquery +sphinx-rtd-theme diff --git a/docs/starting_methods.rst b/docs/starting_methods.rst new file mode 100644 index 0000000..1b3a289 --- /dev/null +++ b/docs/starting_methods.rst @@ -0,0 +1,80 @@ + +Manual WiFi +----------- + +This is the minimal example of using the library with CircuitPython. +This example is serving a simple static text message. + +It also manually connects to the WiFi network. SSID and password are stored in the code, but they +can as well be stored in the ``settings.toml`` file, and then read from there using ``os.getenv()``. + +.. literalinclude:: ../examples/httpserver_simpletest_manual_wifi.py + :caption: examples/httpserver_simpletest_manual_wifi.py + :emphasize-lines: 10-17 + :linenos: + +Manual AP (access point) +------------------------ + +If there is no external network available, it is possible to create an access point (AP) and run a server on it. +It is important to note that only devices connected to the AP will be able to access the server and depending on the device, +it may not be able to access the internet. + +.. literalinclude:: ../examples/httpserver_simpletest_manual_ap.py + :caption: examples/httpserver_simpletest_manual_ap.py + :emphasize-lines: 10-15,29 + :linenos: + +Manual Ethernet +--------------- + +Ethernet can also be used to connect to the location network. + +.. literalinclude:: ../examples/httpserver_simpletest_manual_ethernet.py + :caption: examples/httpserver_simpletest_manual_ethernet.py + :emphasize-lines: 11-20 + :linenos: + +Automatic WiFi using ``settings.toml`` +-------------------------------------- + +From the version 8.0.0 of CircuitPython, +`it is possible to use the environment variables `_ +defined in ``settings.toml`` file to store secrets and configure the WiFi network +using the ``CIRCUITPY_WIFI_SSID`` and ``CIRCUITPY_WIFI_PASSWORD`` variables. + +By default the library uses ``0.0.0.0`` and port ``5000`` for the server, as port ``80`` is reserved for the CircuitPython Web Workflow. +If you want to use port ``80`` , you need to set ``CIRCUITPY_WEB_API_PORT`` to any other port, and then set ``port`` parameter in ``Server`` constructor to ``80`` . + +This is the same example as above, but it uses the ``settings.toml`` file to configure the WiFi network. + +.. note:: + From now on, all the examples will use the ``settings.toml`` file to configure the WiFi network. + +.. literalinclude:: ../examples/settings.toml + :caption: settings.toml + :lines: 5- + :linenos: + +Note that we still need to import ``socketpool`` and ``wifi`` modules. + +.. literalinclude:: ../examples/httpserver_simpletest_auto_settings_toml.py + :caption: examples/httpserver_simpletest_auto_settings_toml.py + :emphasize-lines: 10 + :linenos: + + +Helper for socket pool using ``adafruit_connection_manager`` +------------------------------------------------------------ + +If you do not want to configure the socket pool manually, you can use the ``adafruit_connection_manager`` library, +which provides helpers for getting socket pool and SSL context for common boards. + +Note that it is not installed by default. +You can read `more about it here `_. + + +.. literalinclude:: ../examples/httpserver_simpletest_auto_connection_manager.py + :caption: examples/httpserver_simpletest_auto_connection_manager.py + :emphasize-lines: 6,10 + :linenos: diff --git a/docs/starting_methods.rst.license b/docs/starting_methods.rst.license new file mode 100644 index 0000000..11055fd --- /dev/null +++ b/docs/starting_methods.rst.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 Michał Pokusa + +SPDX-License-Identifier: MIT diff --git a/examples/directory_listing.tpl.html b/examples/directory_listing.tpl.html new file mode 100644 index 0000000..026e2d4 --- /dev/null +++ b/examples/directory_listing.tpl.html @@ -0,0 +1,56 @@ + + + + +{% exec path = context.get("path") %} +{% exec items = context.get("items") %} + + + + Directory listing for /{{ path }} + + + +

Directory listing for /{{ path }}

+ + + +
    + {# Going to parent directory if not alredy in #} + {% if path %} +
  • ..
  • + {% endif %} + + {# Listing items #} + {% for item in items %} +
  • {{ item }}
  • + {% endfor %} + +
+ + {# Script for filtering items #} + + + + diff --git a/examples/home.html b/examples/home.html new file mode 100644 index 0000000..a403688 --- /dev/null +++ b/examples/home.html @@ -0,0 +1,17 @@ + + + + + + + + Adafruit HTTPServer + + +

Hello from the CircuitPython HTTP Server!

+ + diff --git a/examples/httpserver_authentication_handlers.py b/examples/httpserver_authentication_handlers.py new file mode 100644 index 0000000..3cddc91 --- /dev/null +++ b/examples/httpserver_authentication_handlers.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import UNAUTHORIZED_401, Request, Response, Server +from adafruit_httpserver.authentication import ( + AuthenticationError, + Basic, + Bearer, + Token, + check_authentication, + require_authentication, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +# Create a list of available authentication methods. +auths = [ + Basic("user", "password"), + Token("2db53340-4f9c-4f70-9037-d25bee77eca6"), + Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), +] + + +@server.route("/check") +def check_if_authenticated(request: Request): + """ + Check if the request is authenticated and return a appropriate response. + """ + is_authenticated = check_authentication(request, auths) + + return Response( + request, + body="Authenticated" if is_authenticated else "Not authenticated", + content_type="text/plain", + ) + + +@server.route("/require-or-401") +def require_authentication_or_401(request: Request): + """ + Require authentication and return a default server 401 response if not authenticated. + """ + require_authentication(request, auths) + + return Response(request, body="Authenticated", content_type="text/plain") + + +@server.route("/require-or-handle") +def require_authentication_or_manually_handle(request: Request): + """ + Require authentication and manually handle request if not authenticated. + """ + + try: + require_authentication(request, auths) + + return Response(request, body="Authenticated", content_type="text/plain") + + except AuthenticationError: + return Response( + request, + body="Not authenticated - Manually handled", + content_type="text/plain", + status=UNAUTHORIZED_401, + ) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_authentication_server.py b/examples/httpserver_authentication_server.py new file mode 100644 index 0000000..9492073 --- /dev/null +++ b/examples/httpserver_authentication_server.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Basic, Bearer, Request, Response, Server, Token + +# Create a list of available authentication methods. +auths = [ + Basic("user", "password"), + Token("2db53340-4f9c-4f70-9037-d25bee77eca6"), + Bearer("642ec696-2a79-4d60-be3a-7c9a3164d766"), +] + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) +server.require_authentication(auths) + + +@server.route("/implicit-require") +def implicit_require_authentication(request: Request): + """ + Implicitly require authentication because of the server.require_authentication() call. + """ + + return Response(request, body="Authenticated", content_type="text/plain") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_chunked.py b/examples/httpserver_chunked.py index ae519ec..298e795 100644 --- a/examples/httpserver_chunked.py +++ b/examples/httpserver_chunked.py @@ -1,40 +1,30 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense -import secrets # pylint: disable=no-name-in-module - import socketpool import wifi -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) +from adafruit_httpserver import ChunkedResponse, Request, Server pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) +server = Server(pool, debug=True) @server.route("/chunked") -def chunked(request: HTTPRequest): +def chunked(request: Request): """ Return the response with ``Transfer-Encoding: chunked``. """ - with HTTPResponse(request, chunked=True) as response: - response.send_chunk("Adaf") - response.send_chunk("ruit") - response.send_chunk(" Indus") - response.send_chunk("tr") - response.send_chunk("ies") + def body(): + yield "Adaf" + yield b"ruit" # Data chunk can be bytes or str. + yield " Indus" + yield b"tr" + yield "ies" + + return ChunkedResponse(request, body) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_cookies.py b/examples/httpserver_cookies.py new file mode 100644 index 0000000..d5bcfd0 --- /dev/null +++ b/examples/httpserver_cookies.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import GET, Headers, Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +THEMES = { + "dark": { + "background-color": "#1c1c1c", + "color": "white", + "button-color": "#181818", + }, + "light": { + "background-color": "white", + "color": "#1c1c1c", + "button-color": "white", + }, +} + + +def themed_template(user_preferred_theme: str): + theme = THEMES[user_preferred_theme] + + return f""" + + + Cookie Example + + + + + +
+

+ After changing the theme, close the tab and open again. + Notice that theme stays the same. +

+ + + """ + + +@server.route("/", GET) +def themed_from_cookie(request: Request): + """ + Serve a simple themed page, based on the user's cookie. + """ + + user_theme = request.cookies.get("theme", "light") + wanted_theme = request.query_params.get("theme", user_theme) + + headers = Headers() + headers.add("Set-Cookie", "cookie1=value1") + headers.add("Set-Cookie", "cookie2=value2") + + return Response( + request, + themed_template(wanted_theme), + content_type="text/html", + headers=headers, + cookies={} if user_theme == wanted_theme else {"theme": wanted_theme}, + ) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_cpu_information.py b/examples/httpserver_cpu_information.py index cf3d13b..c296bbb 100644 --- a/examples/httpserver_cpu_information.py +++ b/examples/httpserver_cpu_information.py @@ -2,31 +2,23 @@ # # SPDX-License-Identifier: Unlicense -import secrets # pylint: disable=no-name-in-module - -import json import microcontroller import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) +from adafruit_httpserver import JSONResponse, Request, Server pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) +server = Server(pool, debug=True) + +# (Optional) Allow cross-origin requests. +server.headers = { + "Access-Control-Allow-Origin": "*", +} -@server.route("/cpu-information") -def cpu_information_handler(request: HTTPRequest): +@server.route("/cpu-information", append_slash=True) +def cpu_information_handler(request: Request): """ Return the current CPU temperature, frequency, and voltage as JSON. """ @@ -37,9 +29,7 @@ def cpu_information_handler(request: HTTPRequest): "voltage": microcontroller.cpu.voltage, } - with HTTPResponse(request, content_type=MIMEType.TYPE_JSON) as response: - response.send(json.dumps(data)) + return JSONResponse(request, data) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_cpython.py b/examples/httpserver_cpython.py new file mode 100644 index 0000000..854812c --- /dev/null +++ b/examples/httpserver_cpython.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2024 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socket + +from adafruit_httpserver import Request, Response, Server + +pool = socket +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +# Ports below 1024 are reserved for root user only. +# If you want to run this example on a port below 1024, you need to run it as root (or with `sudo`). +server.serve_forever("0.0.0.0", 5000) diff --git a/examples/httpserver_form_data.py b/examples/httpserver_form_data.py new file mode 100644 index 0000000..9ed4d90 --- /dev/null +++ b/examples/httpserver_form_data.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import GET, POST, Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +FORM_HTML_TEMPLATE = """ + + + Form with {enctype} enctype + + + + +
+ + +
+ + +
+ +

Form with {enctype} enctype

+
+ + +
+ {submitted_value} + + +""" + + +@server.route("/form", [GET, POST]) +def form(request: Request): + """ + Serve a form with the given enctype, and display back the submitted value. + """ + enctype = request.query_params.get("enctype", "text/plain") + + if request.method == POST: + posted_value = request.form_data.get("something") + + return Response( + request, + FORM_HTML_TEMPLATE.format( + enctype=enctype, + submitted_value=( + f"

Enctype: {enctype}

\n

Submitted form value: {posted_value}

" + if request.method == POST + else "" + ), + ), + content_type="text/html", + ) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_handler_serves_file.py b/examples/httpserver_handler_serves_file.py new file mode 100644 index 0000000..bc0c930 --- /dev/null +++ b/examples/httpserver_handler_serves_file.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries, Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + + +import socketpool +import wifi + +from adafruit_httpserver import FileResponse, Request, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/default-static-folder", debug=True) + + +@server.route("/home") +def home(request: Request): + """ + Serves the file /other-static-folder/home.html. + """ + + return FileResponse(request, "home.html", "/other-static-folder") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_https.py b/examples/httpserver_https.py new file mode 100644 index 0000000..d3641f1 --- /dev/null +++ b/examples/httpserver_https.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2024 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server( + pool, + root_path="/static", + https=True, + certfile="cert.pem", + keyfile="key.pem", + debug=True, +) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTPS Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address), 443) diff --git a/examples/httpserver_mdns.py b/examples/httpserver_mdns.py index d2228c9..b62b65e 100644 --- a/examples/httpserver_mdns.py +++ b/examples/httpserver_mdns.py @@ -1,41 +1,28 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense -import secrets # pylint: disable=no-name-in-module - import mdns import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) +from adafruit_httpserver import FileResponse, Request, Server mdns_server = mdns.Server(wifi.radio) mdns_server.hostname = "custom-mdns-hostname" -mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=80) +mdns_server.advertise_service(service_type="_http", protocol="_tcp", port=5000) pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) +server = Server(pool, "/static", debug=True) @server.route("/") -def base(request: HTTPRequest): +def base(request: Request): """ Serve the default index.html file. """ - with HTTPResponse(request, content_type=MIMEType.TYPE_HTML) as response: - response.send_file("index.html") + + return FileResponse(request, "index.html", "/www") -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_methods.py b/examples/httpserver_methods.py new file mode 100644 index 0000000..67a5d40 --- /dev/null +++ b/examples/httpserver_methods.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import DELETE, GET, POST, PUT, JSONResponse, Request, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +objects = [ + {"id": 1, "name": "Object 1"}, +] + + +@server.route("/api", [GET, POST, PUT, DELETE], append_slash=True) +def api(request: Request): + """ + Performs different operations depending on the HTTP method. + """ + + # Get objects + if request.method == GET: + return JSONResponse(request, objects) + + # Upload or update objects + if request.method in {POST, PUT}: + uploaded_object = request.json() + + # Find object with same ID + for i, obj in enumerate(objects): + if obj["id"] == uploaded_object["id"]: + objects[i] = uploaded_object + + return JSONResponse( + request, {"message": "Object updated", "object": uploaded_object} + ) + + # If not found, add it + objects.append(uploaded_object) + return JSONResponse(request, {"message": "Object added", "object": uploaded_object}) + + # Delete objects + if request.method == DELETE: + deleted_object = request.json() + + # Find object with same ID + for i, obj in enumerate(objects): + if obj["id"] == deleted_object["id"]: + del objects[i] + + return JSONResponse( + request, {"message": "Object deleted", "object": deleted_object} + ) + + # If not found, return error + return JSONResponse(request, {"message": "Object not found", "object": deleted_object}) + + # If we get here, something went wrong + return JSONResponse(request, {"message": "Something went wrong"}) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_multiple_servers.py b/examples/httpserver_multiple_servers.py new file mode 100644 index 0000000..a793954 --- /dev/null +++ b/examples/httpserver_multiple_servers.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) + +bedroom_server = Server(pool, "/bedroom", debug=True) +bedroom_server.headers["X-Server"] = "Bedroom" + +office_server = Server(pool, "/office", debug=True) +office_server.headers["X-Server"] = "Office" + + +@bedroom_server.route("/bedroom") +def bedroom(request: Request): + """ + This route is registered only on ``bedroom_server``. + """ + return Response(request, "Hello from the bedroom!") + + +@office_server.route("/office") +def office(request: Request): + """ + This route is registered only on ``office_server``. + """ + return Response(request, "Hello from the office!") + + +@bedroom_server.route("/home") +@office_server.route("/home") +def home(request: Request): + """ + This route is registered on both servers. + """ + return Response(request, "Hello from home!") + + +ip_address = str(wifi.radio.ipv4_address) + +# Start the servers. +bedroom_server.start(ip_address, 5000) +office_server.start(ip_address, 8000) + +while True: + try: + # Process any waiting requests for both servers. + bedroom_server.poll() + office_server.poll() + except OSError as error: + print(error) + continue diff --git a/examples/httpserver_neopixel.py b/examples/httpserver_neopixel.py index ab7dabd..5c6cf58 100644 --- a/examples/httpserver_neopixel.py +++ b/examples/httpserver_neopixel.py @@ -1,46 +1,93 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# SPDX-FileCopyrightText: 2022 Michał Pokusa # # SPDX-License-Identifier: Unlicense -import secrets # pylint: disable=no-name-in-module - import board import neopixel import socketpool import wifi -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer +from adafruit_httpserver import GET, POST, Request, Response, Route, Server, as_route + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) +# This is the simplest way to register a route. It uses the Server object in current scope. +@server.route("/change-neopixel-color", GET) +def change_neopixel_color_handler_query_params(request: Request): + """Changes the color of the built-in NeoPixel using query/GET params.""" -pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) + # e.g. /change-neopixel-color?r=255&g=0&b=0 -pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) + r = request.query_params.get("r") or 0 + g = request.query_params.get("g") or 0 + b = request.query_params.get("b") or 0 + + pixel.fill((int(r), int(g), int(b))) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + +# This is another way to register a route. It uses the decorator that converts the function into +# a Route object that can be imported and registered later. +@as_route("/change-neopixel-color/form-data", POST) +def change_neopixel_color_handler_post_form_data(request: Request): + """Changes the color of the built-in NeoPixel using POST form data.""" + + data = request.form_data # e.g. r=255&g=0&b=0 or r=255\r\nb=0\r\ng=0 + r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) + + pixel.fill((int(r), int(g), int(b))) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + +def change_neopixel_color_handler_post_json(request: Request): + """Changes the color of the built-in NeoPixel using JSON POST body.""" + + data = request.json() # e.g {"r": 255, "g": 0, "b": 0} + r, g, b = data.get("r", 0), data.get("g", 0), data.get("b", 0) + + pixel.fill((r, g, b)) + + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") + + +# You can always manually create a Route object and import or register it later. +# Using this approach you can also use the same handler for multiple routes. +post_json_route = Route( + "/change-neopixel-color/json", POST, change_neopixel_color_handler_post_json +) + + +def change_neopixel_color_handler_url_params( + request: Request, r: str = "0", g: str = "0", b: str = "0" +): + """Changes the color of the built-in NeoPixel using URL params.""" + + # e.g. /change-neopixel-color/255/0/0 + pixel.fill((int(r), int(g), int(b))) -@server.route("/change-neopixel-color") -def change_neopixel_color_handler(request: HTTPRequest): - """ - Changes the color of the built-in NeoPixel. - """ - r = request.query_params.get("r") - g = request.query_params.get("g") - b = request.query_params.get("b") + return Response(request, f"Changed NeoPixel to color ({r}, {g}, {b})") - pixel.fill((int(r or 0), int(g or 0), int(b or 0))) - with HTTPResponse(request, content_type=MIMEType.TYPE_TXT) as response: - response.send(f"Changed NeoPixel to color ({r}, {g}, {b})") +# Registering Route objects +server.add_routes( + [ + change_neopixel_color_handler_post_form_data, + post_json_route, + # You can also register a inline created Route object + Route( + path="/change-neopixel-color///", + methods=GET, + handler=change_neopixel_color_handler_url_params, + ), + ] +) -print(f"Listening on http://{wifi.radio.ipv4_address}:80") server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_redirects.py b/examples/httpserver_redirects.py new file mode 100644 index 0000000..1e9ec76 --- /dev/null +++ b/examples/httpserver_redirects.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import ( + MOVED_PERMANENTLY_301, + NOT_FOUND_404, + POST, + Redirect, + Request, + Response, + Server, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +REDIRECTS = { + "google": "https://www.google.com", + "adafruit": "https://www.adafruit.com", + "circuitpython": "https://circuitpython.org", +} + + +@server.route("/blinka") +def redirect_blinka(request: Request): + """Always redirect to a Blinka page as permanent redirect.""" + return Redirect(request, "https://circuitpython.org/blinka", permanent=True) + + +@server.route("/adafruit") +def redirect_adafruit(request: Request): + """Permanent redirect to Adafruit website with explicitly set status code.""" + return Redirect(request, "https://www.adafruit.com/", status=MOVED_PERMANENTLY_301) + + +@server.route("/fake-login", POST) +def fake_login(request: Request): + """Fake login page.""" + return Response(request, "Fake login page with POST data preserved.") + + +@server.route("/login", POST) +def temporary_login_redirect(request: Request): + """Temporary moved login page with preserved POST data.""" + return Redirect(request, "/fake-login", preserve_method=True) + + +@server.route("/") +def redirect_other(request: Request, slug: str = None): + """ + Redirect to a URL based on the slug. + """ + + if slug is None or slug not in REDIRECTS: + return Response(request, "Unknown redirect", status=NOT_FOUND_404) + + return Redirect(request, REDIRECTS.get(slug)) + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simple_poll.py b/examples/httpserver_simple_poll.py deleted file mode 100644 index db876c4..0000000 --- a/examples/httpserver_simple_poll.py +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -import secrets # pylint: disable=no-name-in-module - -import socketpool -import wifi - -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) - -pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) - - -@server.route("/") -def base(request: HTTPRequest): - """ - Serve the default index.html file. - """ - with HTTPResponse(request, content_type=MIMEType.TYPE_HTML) as response: - response.send_file("index.html") - - -print(f"Listening on http://{wifi.radio.ipv4_address}:80") - -# Start the server. -server.start(str(wifi.radio.ipv4_address)) - -while True: - try: - # Do something useful in this section, - # for example read a sensor and capture an average, - # or a running total of the last 10 samples - - # Process any waiting requests - server.poll() - except OSError as error: - print(error) - continue diff --git a/examples/httpserver_simple_serve.py b/examples/httpserver_simple_serve.py deleted file mode 100644 index 632c234..0000000 --- a/examples/httpserver_simple_serve.py +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries -# -# SPDX-License-Identifier: Unlicense - -import secrets # pylint: disable=no-name-in-module - -import socketpool -import wifi - -from adafruit_httpserver.mime_type import MIMEType -from adafruit_httpserver.request import HTTPRequest -from adafruit_httpserver.response import HTTPResponse -from adafruit_httpserver.server import HTTPServer - - -ssid, password = secrets.WIFI_SSID, secrets.WIFI_PASSWORD # pylint: disable=no-member - -print("Connecting to", ssid) -wifi.radio.connect(ssid, password) -print("Connected to", ssid) - -pool = socketpool.SocketPool(wifi.radio) -server = HTTPServer(pool) - - -@server.route("/") -def base(request: HTTPRequest): - """ - Serve the default index.html file. - """ - with HTTPResponse(request, content_type=MIMEType.TYPE_HTML) as response: - response.send_file("index.html") - - -print(f"Listening on http://{wifi.radio.ipv4_address}:80") -server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_auto_connection_manager.py b/examples/httpserver_simpletest_auto_connection_manager.py new file mode 100644 index 0000000..81b1900 --- /dev/null +++ b/examples/httpserver_simpletest_auto_connection_manager.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# +# SPDX-License-Identifier: MIT + +import wifi +from adafruit_connection_manager import get_radio_socketpool + +from adafruit_httpserver import Request, Response, Server + +pool = get_radio_socketpool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_auto_settings_toml.py b/examples/httpserver_simpletest_auto_settings_toml.py new file mode 100644 index 0000000..946ee80 --- /dev/null +++ b/examples/httpserver_simpletest_auto_settings_toml.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_simpletest_manual_ap.py b/examples/httpserver_simpletest_manual_ap.py new file mode 100644 index 0000000..8a8b638 --- /dev/null +++ b/examples/httpserver_simpletest_manual_ap.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2024 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +AP_SSID = "..." +AP_PASSWORD = "..." + +print("Creating access point...") +wifi.radio.start_ap(ssid=AP_SSID, password=AP_PASSWORD) +print(f"Created access point {AP_SSID}") + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address_ap)) diff --git a/examples/httpserver_simpletest_manual_ethernet.py b/examples/httpserver_simpletest_manual_ethernet.py new file mode 100644 index 0000000..80ce1af --- /dev/null +++ b/examples/httpserver_simpletest_manual_ethernet.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2023 Tim C for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +import adafruit_connection_manager +import board +import digitalio +from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K + +from adafruit_httpserver import Request, Response, Server + +# For Adafruit Ethernet FeatherWing +cs = digitalio.DigitalInOut(board.D10) + +# For Particle Ethernet FeatherWing +# cs = digitalio.DigitalInOut(board.D5) + +spi_bus = board.SPI() + +# Initialize ethernet interface with DHCP +eth = WIZNET5K(spi_bus, cs) + +pool = adafruit_connection_manager.get_radio_socketpool(eth) + +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(eth.pretty_ip(eth.ip_address))) diff --git a/examples/httpserver_simpletest_manual_wifi.py b/examples/httpserver_simpletest_manual_wifi.py new file mode 100644 index 0000000..cbf2605 --- /dev/null +++ b/examples/httpserver_simpletest_manual_wifi.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +WIFI_SSID = "..." +WIFI_PASSWORD = "..." + +print(f"Connecting to {WIFI_SSID}...") +wifi.radio.connect(WIFI_SSID, WIFI_PASSWORD) +print(f"Connected to {WIFI_SSID}") + +pool = socketpool.SocketPool(wifi.radio) + +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve a default static plain text message. + """ + return Response(request, "Hello from the CircuitPython HTTP Server!") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_sse.py b/examples/httpserver_sse.py new file mode 100644 index 0000000..9cfafa2 --- /dev/null +++ b/examples/httpserver_sse.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +from time import monotonic + +import microcontroller +import socketpool +import wifi + +from adafruit_httpserver import GET, Request, Response, Server, SSEResponse + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +sse_response: SSEResponse = None +next_event_time = monotonic() + +HTML_TEMPLATE = """ + + + Server-Sent Events Client + + +

CPU temperature: -°C

+ + + +""" + + +@server.route("/client", GET) +def client(request: Request): + return Response(request, HTML_TEMPLATE, content_type="text/html") + + +@server.route("/connect-client", GET) +def connect_client(request: Request): + global sse_response + + if sse_response is not None: + sse_response.close() # Close any existing connection + + sse_response = SSEResponse(request) + + return sse_response + + +server.start(str(wifi.radio.ipv4_address)) +while True: + server.poll() + + # Send an event every second + if sse_response is not None and next_event_time < monotonic(): + cpu_temp = round(microcontroller.cpu.temperature, 2) + sse_response.send_event(str(cpu_temp)) + next_event_time = monotonic() + 1 diff --git a/examples/httpserver_start_and_poll.py b/examples/httpserver_start_and_poll.py new file mode 100644 index 0000000..787f0be --- /dev/null +++ b/examples/httpserver_start_and_poll.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2022 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import ( + REQUEST_HANDLED_RESPONSE_SENT, + FileResponse, + Request, + Server, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve the default index.html file. + """ + return FileResponse(request, "index.html") + + +# Start the server. +server.start(str(wifi.radio.ipv4_address)) + +while True: + try: + # Do something useful in this section, + # for example read a sensor and capture an average, + # or a running total of the last 10 samples + + # Process any waiting requests + pool_result = server.poll() + + if pool_result == REQUEST_HANDLED_RESPONSE_SENT: + # Do something only after handling a request + pass + + # If you want you can stop the server by calling server.stop() anywhere in your code + except OSError as error: + print(error) + continue diff --git a/examples/httpserver_start_and_poll_asyncio.py b/examples/httpserver_start_and_poll_asyncio.py new file mode 100644 index 0000000..d88ebe3 --- /dev/null +++ b/examples/httpserver_start_and_poll_asyncio.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +from asyncio import create_task, gather, run +from asyncio import sleep as async_sleep + +import socketpool +import wifi + +from adafruit_httpserver import ( + REQUEST_HANDLED_RESPONSE_SENT, + FileResponse, + Request, + Server, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + + +@server.route("/") +def base(request: Request): + """ + Serve the default index.html file. + """ + return FileResponse(request, "index.html") + + +# Start the server. +server.start(str(wifi.radio.ipv4_address)) + + +async def handle_http_requests(): + while True: + # Process any waiting requests + pool_result = server.poll() + + if pool_result == REQUEST_HANDLED_RESPONSE_SENT: + # Do something only after handling a request + pass + + await async_sleep(0) + + +async def do_something_useful(): + while True: + # Do something useful in this section, + # for example read a sensor and capture an average, + # or a running total of the last 10 samples + await async_sleep(1) + + # If you want you can stop the server by calling server.stop() anywhere in your code + + +async def main(): + await gather( + create_task(handle_http_requests()), + create_task(do_something_useful()), + ) + + +run(main()) diff --git a/examples/httpserver_static_files_serving.py b/examples/httpserver_static_files_serving.py new file mode 100644 index 0000000..ca00340 --- /dev/null +++ b/examples/httpserver_static_files_serving.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + + +import socketpool +import wifi + +from adafruit_httpserver import MIMETypes, Server + +MIMETypes.configure( + default_to="text/plain", + # Unregistering unnecessary MIME types can save memory + keep_for=[".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico"], + # If you need to, you can add additional MIME types + register={".foo": "text/foo", ".bar": "text/bar"}, +) + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + +# You don't have to add any routes, by default the server will serve files +# from it's root_path, which is set to "/static" in this example. + +# If you don't set a root_path, the server will not serve any files. + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_templates.py b/examples/httpserver_templates.py new file mode 100644 index 0000000..043242c --- /dev/null +++ b/examples/httpserver_templates.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2023 Michal Pokusa +# +# SPDX-License-Identifier: Unlicense +import os +import re + +import socketpool +import wifi + +from adafruit_httpserver import FileResponse, Request, Response, Server + +try: + from adafruit_templateengine import render_template +except ImportError as e: + raise ImportError("This example requires adafruit_templateengine library.") from e + + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, "/static", debug=True) + +# Create /static directory if it doesn't exist +try: + os.listdir("/static") +except OSError as e: + raise OSError("Please create a /static directory on the CIRCUITPY drive.") from e + + +def is_file(path: str): + return (os.stat(path.rstrip("/"))[0] & 0b_11110000_00000000) == 0b_10000000_00000000 + + +@server.route("/") +def directory_listing(request: Request): + path = request.query_params.get("path", "").replace("%20", " ") + + # Preventing path traversal by removing all ../ from path + path = re.sub(r"\/(\.\.)\/|\/(\.\.)|(\.\.)\/", "/", path).strip("/") + + # If path is a file, return it as a file response + if is_file(f"/static/{path}"): + return FileResponse(request, path) + + items = sorted( + [ + item + ("" if is_file(f"/static/{path}/{item}") else "/") + for item in os.listdir(f"/static/{path}") + ], + key=lambda item: not item.endswith("/"), + ) + + # Otherwise, return a directory listing + return Response( + request, + render_template( + "directory_listing.tpl.html", + context={"path": path, "items": items}, + ), + content_type="text/html", + ) + + +# Start the server. +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_url_parameters.py b/examples/httpserver_url_parameters.py new file mode 100644 index 0000000..2831f82 --- /dev/null +++ b/examples/httpserver_url_parameters.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +import socketpool +import wifi + +from adafruit_httpserver import Request, Response, Server + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +class Device: + def turn_on(self): + print("Turning on device.") + + def turn_off(self): + print("Turning off device.") + + +def get_device(device_id: str) -> Device: + """ + This is a **made up** function that returns a `Device` object. + """ + return Device() + + +@server.route("/device//action/") +@server.route("/device/emergency-power-off/") +def perform_action(request: Request, device_id: str, action: str = "emergency_power_off"): + """ + Performs an "action" on a specified device. + """ + + device = get_device(device_id) + + if action in {"turn_on"}: + device.turn_on() + elif action in {"turn_off", "emergency_power_off"}: + device.turn_off() + else: + return Response(request, f"Unknown action ({action})") + + return Response(request, f"Action ({action}) performed on device with ID: {device_id}") + + +@server.route("/device//status/") +def device_status_on_date(request: Request, **params: dict): + """ + Return the status of a specified device between two dates. + """ + + device_id = params.get("device_id") + date = params.get("date") + + return Response(request, f"Status of {device_id} on {date}: ...") + + +@server.route("/device/.../status", append_slash=True) +@server.route("/device/....", append_slash=True) +def device_status(request: Request): + """ + Returns the status of all devices no matter what their ID is. + Unknown commands also return the status of all devices. + """ + + return Response(request, "Status of all devices: ...") + + +server.serve_forever(str(wifi.radio.ipv4_address)) diff --git a/examples/httpserver_video_stream.py b/examples/httpserver_video_stream.py new file mode 100644 index 0000000..644ef39 --- /dev/null +++ b/examples/httpserver_video_stream.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2024 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +try: + from typing import Dict, List, Tuple, Union +except ImportError: + pass + +from asyncio import create_task, gather, run, sleep +from random import choice + +import socketpool +import wifi +from adafruit_pycamera import PyCamera + +from adafruit_httpserver import OK_200, Headers, Request, Response, Server, Status + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + + +camera = PyCamera() +camera.display.brightness = 0 +camera.mode = 0 # JPEG, required for `capture_into_jpeg()` +camera.resolution = "1280x720" +camera.effect = 0 # No effect + + +class XMixedReplaceResponse(Response): + def __init__( + self, + request: Request, + frame_content_type: str, + *, + status: Union[Status, Tuple[int, str]] = OK_200, + headers: Union[Headers, Dict[str, str]] = None, + cookies: Dict[str, str] = None, + ) -> None: + super().__init__( + request=request, + headers=headers, + cookies=cookies, + status=status, + ) + self._boundary = self._get_random_boundary() + self._headers.setdefault( + "Content-Type", f"multipart/x-mixed-replace; boundary={self._boundary}" + ) + self._frame_content_type = frame_content_type + + @staticmethod + def _get_random_boundary() -> str: + symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return "--" + "".join([choice(symbols) for _ in range(16)]) + + def send_frame(self, frame: Union[str, bytes] = "") -> None: + encoded_frame = bytes(frame.encode("utf-8") if isinstance(frame, str) else frame) + + self._send_bytes(self._request.connection, bytes(f"{self._boundary}\r\n", "utf-8")) + self._send_bytes( + self._request.connection, + bytes(f"Content-Type: {self._frame_content_type}\r\n\r\n", "utf-8"), + ) + self._send_bytes(self._request.connection, encoded_frame) + self._send_bytes(self._request.connection, bytes("\r\n", "utf-8")) + + def _send(self) -> None: + self._send_headers() + + def close(self) -> None: + self._close_connection() + + +stream_connections: List[XMixedReplaceResponse] = [] + + +@server.route("/frame") +def frame_handler(request: Request): + frame = camera.capture_into_jpeg() + + return Response(request, body=frame, content_type="image/jpeg") + + +@server.route("/stream") +def stream_handler(request: Request): + response = XMixedReplaceResponse(request, frame_content_type="image/jpeg") + stream_connections.append(response) + + return response + + +async def send_stream_frames(): + while True: + await sleep(0.1) + + frame = camera.capture_into_jpeg() + + for connection in iter(stream_connections): + try: + connection.send_frame(frame) + except BrokenPipeError: + connection.close() + stream_connections.remove(connection) + + +async def handle_http_requests(): + server.start(str(wifi.radio.ipv4_address)) + + while True: + await sleep(0) + + server.poll() + + +async def main(): + await gather( + create_task(send_stream_frames()), + create_task(handle_http_requests()), + ) + + +run(main()) diff --git a/examples/httpserver_websocket.py b/examples/httpserver_websocket.py new file mode 100644 index 0000000..e693bc1 --- /dev/null +++ b/examples/httpserver_websocket.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +from asyncio import create_task, gather, run +from asyncio import sleep as async_sleep + +import board +import microcontroller +import neopixel +import socketpool +import wifi + +from adafruit_httpserver import GET, Request, Response, Server, Websocket + +pool = socketpool.SocketPool(wifi.radio) +server = Server(pool, debug=True) + +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) + +websocket: Websocket = None + +HTML_TEMPLATE = """ + + + Websocket Client + + +

CPU temperature: -°C

+

NeoPixel Color:

+ + + +""" + + +@server.route("/client", GET) +def client(request: Request): + return Response(request, HTML_TEMPLATE, content_type="text/html") + + +@server.route("/connect-websocket", GET) +def connect_client(request: Request): + global websocket + + if websocket is not None: + websocket.close() # Close any existing connection + + websocket = Websocket(request) + + return websocket + + +server.start(str(wifi.radio.ipv4_address)) + + +async def handle_http_requests(): + while True: + server.poll() + + await async_sleep(0) + + +async def handle_websocket_requests(): + while True: + if websocket is not None: + if (data := websocket.receive(fail_silently=True)) is not None: + r, g, b = int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16) + pixel.fill((r, g, b)) + + await async_sleep(0) + + +async def send_websocket_messages(): + while True: + if websocket is not None: + cpu_temp = round(microcontroller.cpu.temperature, 2) + websocket.send_message(str(cpu_temp), fail_silently=True) + + await async_sleep(1) + + +async def main(): + await gather( + create_task(handle_http_requests()), + create_task(handle_websocket_requests()), + create_task(send_websocket_messages()), + ) + + +run(main()) diff --git a/examples/settings.toml b/examples/settings.toml new file mode 100644 index 0000000..e99fb9c --- /dev/null +++ b/examples/settings.toml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2023 Michał Pokusa +# +# SPDX-License-Identifier: Unlicense + +# Setting these variables will automatically connect board to WiFi on boot +CIRCUITPY_WIFI_SSID="Your WiFi SSID Here" +CIRCUITPY_WIFI_PASSWORD="Your WiFi Password Here" diff --git a/pyproject.toml b/pyproject.toml index 55effb7..af10ecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ classifiers = [ dynamic = ["dependencies", "optional-dependencies"] [tool.setuptools] -py-modules = ["adafruit_httpserver"] +packages = ["adafruit_httpserver"] [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4e5df57 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +target-version = "py38" +line-length = 100 + +[lint] +preview = true +select = ["I", "PL", "UP"] + +extend-select = [ + "D419", # empty-docstring + "E501", # line-too-long + "W291", # trailing-whitespace + "PLC0414", # useless-import-alias + "PLC2401", # non-ascii-name + "PLC2801", # unnecessary-dunder-call + "PLC3002", # unnecessary-direct-lambda-call + "E999", # syntax-error + "PLE0101", # return-in-init + "F706", # return-outside-function + "F704", # yield-outside-function + "PLE0116", # continue-in-finally + "PLE0117", # nonlocal-without-binding + "PLE0241", # duplicate-bases + "PLE0302", # unexpected-special-method-signature + "PLE0604", # invalid-all-object + "PLE0605", # invalid-all-format + "PLE0643", # potential-index-error + "PLE0704", # misplaced-bare-raise + "PLE1141", # dict-iter-missing-items + "PLE1142", # await-outside-async + "PLE1205", # logging-too-many-args + "PLE1206", # logging-too-few-args + "PLE1307", # bad-string-format-type + "PLE1310", # bad-str-strip-call + "PLE1507", # invalid-envvar-value + "PLE2502", # bidirectional-unicode + "PLE2510", # invalid-character-backspace + "PLE2512", # invalid-character-sub + "PLE2513", # invalid-character-esc + "PLE2514", # invalid-character-nul + "PLE2515", # invalid-character-zero-width-space + "PLR0124", # comparison-with-itself + "PLR0202", # no-classmethod-decorator + "PLR0203", # no-staticmethod-decorator + "UP004", # useless-object-inheritance + "PLR0206", # property-with-parameters + "PLR0904", # too-many-public-methods + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0914", # too-many-locals + "PLR0915", # too-many-statements + "PLR0916", # too-many-boolean-expressions + "PLR1702", # too-many-nested-blocks + "PLR1704", # redefined-argument-from-local + "PLR1711", # useless-return + "C416", # unnecessary-comprehension + "PLR1733", # unnecessary-dict-index-lookup + "PLR1736", # unnecessary-list-index-lookup + + # ruff reports this rule is unstable + #"PLR6301", # no-self-use + + "PLW0108", # unnecessary-lambda + "PLW0120", # useless-else-on-loop + "PLW0127", # self-assigning-variable + "PLW0129", # assert-on-string-literal + "B033", # duplicate-value + "PLW0131", # named-expr-without-context + "PLW0245", # super-without-brackets + "PLW0406", # import-self + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "PLW0604", # global-at-module-level + + # fails on the try: import typing used by libraries + #"F401", # unused-import + + "F841", # unused-variable + "E722", # bare-except + "PLW0711", # binary-op-exception + "PLW1501", # bad-open-mode + "PLW1508", # invalid-envvar-default + "PLW1509", # subprocess-popen-preexec-fn + "PLW2101", # useless-with-lock + "PLW3301", # nested-min-max +] + +ignore = [ + "PLR2004", # magic-value-comparison + "UP030", # format literals + "PLW1514", # unspecified-encoding + "PLR0913", # too-many-arguments + "PLR0915", # too-many-statements + "PLR0917", # too-many-positional-arguments + "PLR0904", # too-many-public-methods + "PLR0912", # too-many-branches + "PLR0916", # too-many-boolean-expressions + "PLR6301", # could-be-static no-self-use + "PLC0415", # import outside toplevel + "PLC2701", # private import + "PLR0911", # too many return + "PLW1641", # object not implement hash + "PLW0603", # global statement + "PLC1901", # string falsey simplified +] + +[format] +line-ending = "lf"