diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c07183 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +#!/usr/bin/make -f +# +# SPDX-FileCopyrightText: 2023 Charles Crighton +# +# SPDX-License-Identifier: MIT + +SHELL := /bin/bash +.ONESHELL: +.DEFAULT_GOAL:=help +.PHONY: help dist dist-build install-local +.SILENT: help + +UID := $(shell id -u) +PWD := $(shell pwd) + +PORT ?= /dev/ttyUSB0 +VENV ?= ~/.virtualenvs/phew + +help: ## Display this help + $(info Phew build and flash targets) + $(info ) + fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\:.*##/:/' | sed -e 's/##//' + +dist: ## Package phew python distribution + rm -rf dist + python -m build + +publish-testpypi: ## Publish distribution file to TestPyPI + python3 -m twine upload --repository testpypi dist/* + +publish-pypi: ## Publish distribution file to PyPI + python3 -m twine upload --repository pypi dist/* + +install-local: ## Install package from local dist + pipkin install --no-index --find-links dist --force-reinstall micropython-ccrighton-phew diff --git a/README.md b/README.md index 90b8458..0678d06 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,26 @@ using the [Raspberry Pi Pico W](https://shop.pimoroni.com/products/raspberry-pi- - [What **phew!** does:](#what-phew-does) - [How to use](#how-to-use) - [Basic example](#basic-example) + - [Running multiple web applications](#running-multiple-web-applications) + - [Transport Layer Security (TLS)](#transport-layer-security-tls) + - [Generating a Key and Certificate](#generating-a-key-and-cert) + - [Sessions](#sessions) + - [Session Authentication](#session-authentication) - [Function reference](#function-reference) - [server module](#server-module) - [add\_route](#add_route) - [set\_catchall](#set_catchall) - [run](#run) + - [Session interface](#session-interface) + - [create_session](#create_session) + - [remove_session](#remove_session) + - [login_required](#login_required) + - [login_catchall](#login_catchall) - [Types](#types) - [Request](#request) - [Response](#response) - [Shorthand](#shorthand) + - [Session](#session) - [Templates](#templates) - [render\_template](#render_template) - [Template expressions](#template-expressions) @@ -74,31 +85,159 @@ from phew import server, connect_to_wifi connect_to_wifi("", "") -@server.route("/random", methods=["GET"]) +phew_app = server.Phew() + +@phew_app.route("/random", methods=["GET"]) def random_number(request): import random min = int(request.query.get("min", 0)) max = int(request.query.get("max", 100)) return str(random.randint(min, max)) -@server.catchall() +@phew_app.catchall() def catchall(request): return "Not found", 404 -server.run() +phew_app.run() ``` **phew** is designed specifically with performance and minimal resource use in mind. -Generally this means it will prioritise doing as little work as possible including +Generally this means it will prioritise doing as little work as possible including assuming the correctness of incoming requests. +--- + +## Running multiple web applications + +A device may require multiple web apps. For instance, a setup web app for the access point +and a configuration web app for normal operation. Phew supports the creation of many apps +with registration of routes per app. To create a new app, just create another ```server.Phew``` +instance. + +For concurrent execute of apps, each must be configured to connect to a different port and be +run in the same uasyncio loop as tasks. + +```python +import uasyncio +from phew import server + +phew_app1 = server.Phew() +phew_app2 = server.Phew() + +# route methods declared here for both apps + +loop = uasyncio.get_event_loop() +phew_app1.run_as_task(loop, host="0.0.0.0", port=80) +phew_app2.run_as_task(loop, host="0.0.0.0", port=8080) +loop.run_forever() +``` + +## Transport Layer Security (TLS) + +Phew supports Transport Layer Security (TLS) by exposing the capabilities of +asynicio. + +TLS is enabled by providing an ```ssl.SSLContext``` to either ```Phew.run``` +or ```Phew.run_as_task```. + +TLS setup introduces about a 5 second delay on a request. This time depends on +the key algorithm used. + +```python +import ssl + +# set up TLS +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain("cert.der", "key.der") + +# start the webserver +phew_app.run(host='0.0.0.0', port=443, ssl=ctx) +``` + +See [auth example app](https://github.com/ccrighton/phew/tree/main/examples/auth) +for a working implementation. + +Configuration options for the ```ssl.SSLContext``` are documented at [Micropython SSL/TLS module](https://docs.micropython.org/en/latest/library/ssl.html). + +### Generating a key and cert + +Generate the key. +```commandline +openssl ecparam -name prime256v1 -genkey -noout -out key.der -outform DER +``` + +Generate the self-signed certificate. +```commandline +openssl req -new -x509 -key key.der -out cert.der -outform DER -days 365 -node +``` + +List the certificate details: +```commandline +openssl x509 -in cert.der -text +``` + +Copy the key and cert files to the device. +```commandline +mpremote cp cert.der key.der : +``` + +## Sessions + +Phew provides a simple session api to support authenticated session establishment. ```Phew.create_session()``` is +called to set up a new session. The session returned contains the session id and max age values to be used in the +cookie exchange. + +The following code creates the response to set the session cookie. This needs to be run only if the client has provided +valid credentials. For instance the client may do a POST request of a username and password as form data. + +```python +session = phew_app.create_session() +return server.Response("OK", status=302, + headers={"Content-Type": "text/html", + "Set-Cookie": f"sessionid={session.session_id}; Max-Age={session.max_age}; Secure; HttpOnly", + "Location": "/"}) +``` + +To ensure that the session id is sent only on TLS sessions, please ensure that the ```Secure``` parameter is set in the +```Set-Cookie``` header. + +Once the session is established, all route handlers that have the ```login_required()``` annotation will check that +the request contains a cookie with the valid session id set. + +```python +@phew_app.route("/", methods=["GET"]) +@phew_app.login_required() +def index(request): + return render_template("index.html", text="Hello World") +``` + +Add the ```login_required()``` annotation to all route handlers that need authentication. However, do not add it to +the login route handler as this will prevent the establishment of a session. + +```Phew.remove_session(request)``` is called to end the session. For example, a logout route handler will call it to +log the session out. + +```python +@phew_app.route("/logout", methods=["GET"]) +def logout(request): + phew_app.remove_session(request) + return render_template("logout.html") +``` + +### Session Authentication + +The method used for session authentication is within the control of the application using the Phew library. + +In the example provided a login form is used that provides username and password in form data. + + --- ## Function reference ### server module -The `server` module provides all functionality for running a web server with +The `server` module provides all functionality for running a web server with route handlers. #### add_route @@ -114,13 +253,13 @@ that contains details about the request. def my_handler(request): return "I got it!", 200 -server.add_route("/testpath", my_handler, methods=["GET"]) +phew_app.add_route("/testpath", my_handler, methods=["GET"]) ``` Or, alternatively, using a decorator: ```python -@server.route("/testpath", methods=["GET"]) +@phew_app.route("/testpath", methods=["GET"]) def my_handler(request): return "I got it!", 200 ``` @@ -128,7 +267,7 @@ def my_handler(request): #### set_catchall ```python -server.set_catchall(handler) +phew_app.set_catchall(handler) ``` Provide a catchall method for requests that didn't match a route. @@ -137,13 +276,13 @@ Provide a catchall method for requests that didn't match a route. def my_catchall(request): return "No matching route", 404 -server.set_catchall(my_catchall) +phew_app.set_catchall(my_catchall) ``` Or, alternatively, using a decorator: ```python -@server.catchall() +@phew_app.catchall() def my_catchall(request): return "No matching route", 404 ``` @@ -151,16 +290,53 @@ def my_catchall(request): #### run ```python -server.run(host="0.0.0.0", port=80) +phew_app.run(host="0.0.0.0", port=80) +``` + +Starts up the web server and begins handling incoming requests. + +```python +phew_app.run() ``` -Starts up the web server and begins handling incoming requests. +### Session interface + +#### create_session +Create a new session that provides the parameters needed for cookie based session establishment. ```python -server.run() +session = phew_app.create_session() ``` -### Types +#### remove_session + +Remove an existing session, resulting in that session no longer being active so the user is logged out. +```python +phew_app.remove_session(request) +``` + +#### login_required + +A decorator for a route handler that redirects and requests to decorated routes to the login handler. + +```python +@phew_app.route("/hello", methods=["GET"]) +@phew_app.login_required() +def hello(request, name): + return render_template("index.html") +``` + +#### login_catchall + +Decorator that sets the handler for the login page. DO NOT decorate with ```login_required```. + +```python +@phew_app.login_catchall() +def redirect_to_login(request): + return server.redirect("/login", status=302) +``` + +### Types #### Request @@ -168,7 +344,7 @@ The `Request` object contains all of the information that was parsed out of the incoming request including form data, query string parameters, HTTP method, path, and more. -Handler functions provided to `add_route` and `set_catchall` will recieve a +Handler functions provided to `add_route` and `set_catchall` will recieve a `Request` object as their first parameter. |member|example|type|description| @@ -185,14 +361,14 @@ Handler functions provided to `add_route` and `set_catchall` will recieve a At the time your route handler is being called the request has been fully parsed and you can access any properties that are relevant to the request (e.g. the `form` dictionary for a `multipart/form-data` request) any irrelevant properties will be set to `None`. ```python -@server.route("/login", ["POST"]) +@phew_app.route("/login", ["POST"]) def login_form(request): username = request.form.get("username", None) password = request.form.get("password", None) # check the user credentials with your own code - # for example: - # + # for example: + # # logged_in = authenticate_user(username, password) if not logged_in: @@ -217,7 +393,7 @@ of shorthand forms to avoid writing the boilerplate needed. |body|`"this is the response body"`|string or generator|the content to be returned| ```python -@server.route("/greeting/", ["GET"]) +@phew_app.route("/greeting/", ["GET"]) def user_details(request): return Response(f"Hello, {name}", status=200, {"Content-Type": "text/html"}) ``` @@ -234,18 +410,35 @@ one and three values: For example: ```python -@server.route("/greeting/", ["GET"]) +@phew_app.route("/greeting/", ["GET"]) def user_details(request, name): return f"Hello, {name}", 200 ``` +#### Session + +The `Session` object contains the attributes of a session. It is returned by the `create_session` function. + +| member | example | type | description | +|------------|------------------------------------|------|-----------------------------------------| +| session_id | `5146c4a8b8a3c83e54b5c06ce009988c` | str | 128 bit hex encoded session identifier | +| max_age | `86400` | int | seconds from session creation to expiry | +| expires | `1609563847` | int | seconds from epoch to session expiry | + +The `Session` object provides a convenience function for checking expiry: + +```python +Session.expired() +``` +Return boolean. + ### Templates -A web server isn't much use without something to serve. While it's straightforward +A web server isn't much use without something to serve. While it's straightforward to serve the contents of a file or some generated JSON things get more complicated when we want to present a dynamically generated web page to the user. -**phew!** provides a templating engine which allows you to write normal HTML with +**phew!** provides a templating engine which allows you to write normal HTML with fragments of Python code embedded to output variable values, parse input, or dynamically load assets. @@ -255,7 +448,7 @@ load assets. render_template(template, param1="foo", param2="bar", ...): ``` -The `render_template` method takes a path to a template file on the filesystem and +The `render_template` method takes a path to a template file on the filesystem and a list of named paramaters which will be passed into the template when parsing. The method is a generator which yields the parsing result in chunks, minimising the @@ -268,22 +461,34 @@ of your handler methods. #### Template expressions Templates are not much use if you can't inject dynamic data into them. With **phew!** -you can embed Python expressions with `{{}}` which will be evaluated +you can embed Python expressions with `{{}}` which will be evaluated during parsing. ##### Variables -In the simplest form you can embed a simple value by just enclosing it in double curly braces. +In the simplest form you can embed a simple value by just enclosing it in double curly braces. It's also possible to perform more complicated transformations using any built in Python method. ```html
{{name}}
{{name.upper()}}
- +
{{"/".join(name.split(" "))}}
``` +##### Lists + +It is possible to perform operations on lists using str.join and list comprehension as in the following table example. + +```html + +{{"".join([f"\r\n" for item in mylist])}} +
item
+``` + + + ##### Conditional display If you want to show a value only if some other condition is met then you can use the @@ -396,7 +601,7 @@ warn("> turned upside down") Will automatically truncate the log file to `truncate_to` bytes long when it reaches `truncate_at` bytes in length. ```python -# automatically truncate when we're closed to the +# automatically truncate when we're closed to the # filesystem block size to keep to a single block set_truncate_thresholds(3.5 * 1024, 2 * 1.024) ``` @@ -425,7 +630,7 @@ Pass in the IP address of your device once in access point mode. connect_to_wifi(ssid, password, timeout=30) ``` -Connects to the network specified by `ssid` with the provided password. +Connects to the network specified by `ssid` with the provided password. Returns the device IP address on success or `None` on failure. @@ -459,4 +664,4 @@ Here are some Phew! community projects and guides that you might find useful. No - :link: [Hacking Big Mouth Billy Bass](https://www.youtube.com/watch?v=dOEjfBplueM) - :link: [How to set up a Phew! Access Point](https://www.kevsrobots.com/blog/phew-access-point.html) -- :link: [Wireless Networking Setup Example for Raspberry Pi Pico W](https://github.com/simonprickett/phewap) \ No newline at end of file +- :link: [Wireless Networking Setup Example for Raspberry Pi Pico W](https://github.com/simonprickett/phewap) diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..22a1950 --- /dev/null +++ b/changelog.md @@ -0,0 +1,50 @@ + + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [Unreleased] + +## [0.0.5] - 2023-06-26 + +### Added +- Support for TLS + - Added example [ssl](/example/ssl) +- Session based authentication support using cookies + - Added @login_required decorator + - Added example [auth](/examples/auth) + + +## [0.0.4] - 2023-10-04 + +### Added +- Refactored Phew as a class to support multiple apps with asyncio + +### Changed +- Document approach to processing list in templates +- Additional common mime types mapped to extensions + +### Fixed +- Ensure that all form data is read to content-length + + +[Unreleased]: https://github.com/ccrighton/phew/compare/v0.0.5...HEAD + +[0.0.5]: https://github.com/ccrighton/phew/releases/tag/v0.0.5 +[0.0.4]: https://github.com/ccrighton/phew/releases/tag/v0.0.4 diff --git a/examples/auth/404.html b/examples/auth/404.html new file mode 100644 index 0000000..d0cc843 --- /dev/null +++ b/examples/auth/404.html @@ -0,0 +1,18 @@ + + + + + + +

SSL and Session Authentication Example.

+

{{text}}

+
+ +

+
+ + diff --git a/examples/auth/cert.der b/examples/auth/cert.der new file mode 100644 index 0000000..089c9ed Binary files /dev/null and b/examples/auth/cert.der differ diff --git a/examples/auth/cert.der.license b/examples/auth/cert.der.license new file mode 100644 index 0000000..214fd7b --- /dev/null +++ b/examples/auth/cert.der.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 Charles Crighton + +SPDX-License-Identifier: MIT diff --git a/examples/auth/developer_board.svg b/examples/auth/developer_board.svg new file mode 100644 index 0000000..472f054 --- /dev/null +++ b/examples/auth/developer_board.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/auth/developer_board.svg.license b/examples/auth/developer_board.svg.license new file mode 100644 index 0000000..f70e44e --- /dev/null +++ b/examples/auth/developer_board.svg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 Material Design icons by Google + +SPDX-License-Identifier: Apache-2.0 diff --git a/examples/auth/index.html b/examples/auth/index.html new file mode 100644 index 0000000..60d6ff6 --- /dev/null +++ b/examples/auth/index.html @@ -0,0 +1,21 @@ + + + + + + +

SSL and Session Authentication Example

+

{{text}}

+
+ +

+

+

+

+
+ + diff --git a/examples/auth/key.der b/examples/auth/key.der new file mode 100644 index 0000000..64e67b3 Binary files /dev/null and b/examples/auth/key.der differ diff --git a/examples/auth/key.der.license b/examples/auth/key.der.license new file mode 100644 index 0000000..214fd7b --- /dev/null +++ b/examples/auth/key.der.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2024 Charles Crighton + +SPDX-License-Identifier: MIT diff --git a/examples/auth/login.html b/examples/auth/login.html new file mode 100644 index 0000000..d3d7f93 --- /dev/null +++ b/examples/auth/login.html @@ -0,0 +1,25 @@ + + + + + + + +
+
+

{{error if error else ""}}

+ + + + + +

+ +

+ +
+ diff --git a/examples/auth/logout.html b/examples/auth/logout.html new file mode 100644 index 0000000..443ef8c --- /dev/null +++ b/examples/auth/logout.html @@ -0,0 +1,18 @@ + + + + + + +

SSL and Session Authentication Example

+

Logged out.

+
+ +

+
+ + diff --git a/examples/auth/main.py b/examples/auth/main.py new file mode 100644 index 0000000..b97bb0d --- /dev/null +++ b/examples/auth/main.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2024 Charles Crighton +# SPDX-FileCopyrightText: 2022 Pimoroni +# +# SPDX-License-Identifier: MIT + +# example script to show how uri routing and parameters work +# +# create a file called secrets.py alongside this one and add the +# following two lines to it: +# +# WIFI_SSID = "" +# WIFI_PASSWORD = "" +# +# with your wifi details instead of and . + +import binascii +import ssl +from phew import server, connect_to_wifi +from phew.template import render_template + +import secrets + +ipaddress = connect_to_wifi(secrets.WIFI_SSID, secrets.WIFI_PASSWORD) + +print(f"Connected to wifi on {ipaddress}") + +phew_app = server.Phew() + +@phew_app.route("/login", methods=["GET", "POST"]) +def login(request): + if request.method == "GET": + return render_template("login.html", error="") + + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + # TODO: username and password handling + if username == "admin" and password == "admin": + session = phew_app.create_session() + return server.Response("", status=302, + headers={"Content-Type": "text/html", + "Set-Cookie": f"sessionid={session.session_id}; Max-Age={session.max_age}; Secure; HttpOnly", + "Location": "/"}) + + return render_template("login.html", error="Login failure") + +@phew_app.route("/logout", methods=["GET"]) +def logout(request): + if phew_app.active_session(request): + phew_app.remove_session(request) + return server.Response(render_template("logout.html"), status=200, + headers={"Content-Type": "text/html", + "Set-Cookie": f"sessionid=deleted; expires=Thu, 01 Jan 1970 00:00:00 GMT"}) + else: + return render_template("logout.html") + +# basic response with status code and content type +@phew_app.route("/", methods=["GET"]) +@phew_app.login_required() +def index(request): + return render_template("index.html", text="Hello World") + +# login catchall handler. Redirect to /login +@phew_app.login_catchall() +def redirect_to_login(request): + return server.redirect("/login", status=302) + + +# basic response with status code and content type +@phew_app.route("/basic", methods=["GET"]) +@phew_app.login_required() +def basic(request): + return render_template("index.html", text="Gosh, a request") + +# basic response with status code and content type +@phew_app.route("/status-code", methods=["GET"]) +@phew_app.login_required() +def status_code(request): + return render_template("index.html", text="Here, have a status code 200") + +# url parameter and template render +@phew_app.route("/hello/", methods=["GET"]) +@phew_app.login_required() +def hello(request, name): + return render_template("index.html", text=name) + + +# query string example +@phew_app.route("/random", methods=["GET"]) +@phew_app.login_required() +def random_number(request): + import random + min = int(request.query.get("min", 0)) + max = int(request.query.get("max", 100)) + return render_template("index.html", text=str(random.randint(min, max))) + +@phew_app.route("/favicon.ico", methods=["GET"]) +def status_code(request): + return server.serve_file("/developer_board.svg") + + +# catchall example +@phew_app.catchall() +@phew_app.login_required() +def catchall(request): + return render_template("404.html", text=f"{request.path} not found") + + +# set up TLS +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain("cert.der", "key.der") + +# start the webserver +phew_app.run(host='0.0.0.0', port=443, ssl=ctx) diff --git a/examples/auth/style.css b/examples/auth/style.css new file mode 100644 index 0000000..39d51bc --- /dev/null +++ b/examples/auth/style.css @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2024 2024 Charles Crighton + * SPDX-FileCopyrightText: 2024 Charles Crighton + * + * SPDX-License-Identifier: MIT + */ + +@media (prefers-color-scheme: dark) { + body { + color: #eee; + background: #121212; + } + a { + color: #809fff; + } + textarea { + color: forestgreen; + background: #121212; + } + label { + color: black; + } +} +html{font-family: Helvetica; display:inline-block; margin: 0px auto; text-align: center;} +p{font-size: 1.0rem;} +.button{border:0;border-radius:0.3rem;background:#1fa3ec;color:#ffffff;line-height:2.4rem;padding: 4px 8px; +font-size:1.2rem;-webkit-transition-duration:0.4s;transition-duration:0.4s;cursor:pointer;} +.button:hover{background:#0e70a4;} +.b2{background-color: #2E8B57;} +.b2:hover{background:#008000;} +.b3{background-color: #FF0000;} +.b3:hover{background:#B22222;} +.center { margin-left: auto; margin-right: auto;} +div.table { display: table; text-align: left;} +div.mqttform { display: table-row; } +label, input.mqttform { display: table-cell; margin-bottom: 10px; vertical-align: top; text-align: left;} +label { padding-right: 10px; padding-bottom: 5px; padding-top: 5px} +logtext { center; font-family: 'Courier New', monospace; } +#configtable { center; margin-left: auto; margin-right: auto; text-align: left;} +#configtable td, # configtable th { text-align: left; } +.cell-highlight { font-weight: bold; } +.line { + width: 300px; + height: 0; + border: 1px solid #C4C4C4; + margin: 3px; + display:inline-block; +} + +.container { + width: 500px; + margin: 0 auto; + background-color: #fff; + border-radius: 5px; + box-shadow: 0px 0px 5px #666; + padding: 20px; + margin-top: 50px; +} + +input[type="text"], input[type="password"] { + padding: 10px; + border-radius: 5px; + border: 1px solid #ccc; + width: 100%; + box-sizing: border-box; +} diff --git a/examples/main.py b/examples/main.py index 68b555f..68336a2 100644 --- a/examples/main.py +++ b/examples/main.py @@ -15,33 +15,35 @@ connect_to_wifi(secrets.WIFI_SSID, secrets.WIFI_PASSWORD) +phew_app = server.Phew() + # basic response with status code and content type -@server.route("/basic", methods=["GET", "POST"]) +@phew_app.route("/basic", methods=["GET", "POST"]) def basic(request): return "Gosh, a request", 200, "text/html" # basic response with status code and content type -@server.route("/status-code", methods=["GET", "POST"]) +@phew_app.route("/status-code", methods=["GET", "POST"]) def status_code(request): return "Here, have a status code", 200, "text/html" # url parameter and template render -@server.route("/hello/", methods=["GET"]) +@phew_app.route("/hello/", methods=["GET"]) def hello(request, name): return await render_template("example.html", name=name) # response with custom status code -@server.route("/are/you/a/teapot", methods=["GET"]) +@phew_app.route("/are/you/a/teapot", methods=["GET"]) def teapot(request): return "Yes", 418 # custom response object -@server.route("/response", methods=["GET"]) +@phew_app.route("/response", methods=["GET"]) def response_object(request): return server.Response("test body", status=302, content_type="text/html", headers={"Cache-Control": "max-age=3600"}) # query string example -@server.route("/random", methods=["GET"]) +@phew_app.route("/random", methods=["GET"]) def random_number(request): import random min = int(request.query.get("min", 0)) @@ -49,9 +51,9 @@ def random_number(request): return str(random.randint(min, max)) # catchall example -@server.catchall() +@phew_app.catchall() def catchall(request): return "Not found", 404 # start the webserver -server.run() +phew_app.run() diff --git a/examples/ssl/example.html b/examples/ssl/example.html new file mode 100644 index 0000000..0f7f3be --- /dev/null +++ b/examples/ssl/example.html @@ -0,0 +1,14 @@ + + + + + + +Hello {{name}}! + +{{render_template("include.html", name=name)}} + \ No newline at end of file diff --git a/examples/ssl/include.html b/examples/ssl/include.html new file mode 100644 index 0000000..0b6d606 --- /dev/null +++ b/examples/ssl/include.html @@ -0,0 +1,7 @@ + + +Hello again {{name}}! \ No newline at end of file diff --git a/examples/ssl/main.py b/examples/ssl/main.py new file mode 100644 index 0000000..465354e --- /dev/null +++ b/examples/ssl/main.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2024 Charles Crighton +# SPDX-FileCopyrightText: 2022 Pimoroni +# +# SPDX-License-Identifier: MIT + +# example script to show how uri routing and parameters work +# +# create a file called secrets.py alongside this one and add the +# following two lines to it: +# +# WIFI_SSID = "" +# WIFI_PASSWORD = "" +# +# with your wifi details instead of and . + +import binascii +import ssl +from phew import server, connect_to_wifi +from phew.template import render_template + +import secrets + + +key = binascii.unhexlify( + b"3082013b020100024100cc20643fd3d9c21a0acba4f48f61aadd675f52175a9dcf07fbef" + b"610a6a6ba14abb891745cd18a1d4c056580d8ff1a639460f867013c8391cdc9f2e573b0f" + b"872d0203010001024100bb17a54aeb3dd7ae4edec05e775ca9632cf02d29c2a089b563b0" + b"d05cdf95aeca507de674553f28b4eadaca82d5549a86058f9996b07768686a5b02cb240d" + b"d9f1022100f4a63f5549e817547dca97b5c658038e8593cb78c5aba3c4642cc4cd031d86" + b"8f022100d598d870ffe4a34df8de57047a50b97b71f4d23e323f527837c9edae88c79483" + b"02210098560c89a70385c36eb07fd7083235c4c1184e525d838aedf7128958bedfdbb102" + b"2051c0dab7057a8176ca966f3feb81123d4974a733df0f958525f547dfd1c271f9022044" + b"6c2cafad455a671a8cf398e642e1be3b18a3d3aec2e67a9478f83c964c4f1f" +) +cert = binascii.unhexlify( + b"308201d53082017f020203e8300d06092a864886f70d01010505003075310b3009060355" + b"0406130258583114301206035504080c0b54686550726f76696e63653110300e06035504" + b"070c075468654369747931133011060355040a0c0a436f6d70616e7958595a3113301106" + b"0355040b0c0a436f6d70616e7958595a3114301206035504030c0b546865486f73744e61" + b"6d65301e170d3139313231383033333935355a170d3239313231353033333935355a3075" + b"310b30090603550406130258583114301206035504080c0b54686550726f76696e636531" + b"10300e06035504070c075468654369747931133011060355040a0c0a436f6d70616e7958" + b"595a31133011060355040b0c0a436f6d70616e7958595a3114301206035504030c0b5468" + b"65486f73744e616d65305c300d06092a864886f70d0101010500034b003048024100cc20" + b"643fd3d9c21a0acba4f48f61aadd675f52175a9dcf07fbef610a6a6ba14abb891745cd18" + b"a1d4c056580d8ff1a639460f867013c8391cdc9f2e573b0f872d0203010001300d06092a" + b"864886f70d0101050500034100b0513fe2829e9ecbe55b6dd14c0ede7502bde5d46153c8" + b"e960ae3ebc247371b525caeb41bbcf34686015a44c50d226e66aef0a97a63874ca5944ef" + b"979b57f0b3" +) + + +ipaddress = connect_to_wifi(secrets.WIFI_SSID, secrets.WIFI_PASSWORD) + +print(f"Connected to wifi on {ipaddress}") + +phew_app = server.Phew() + +# basic response with status code and content type +@phew_app.route("/basic", methods=["GET", "POST"]) +def basic(request): + return "Gosh, a request", 200, "text/html" + +# basic response with status code and content type +@phew_app.route("/status-code", methods=["GET", "POST"]) +def status_code(request): + return "Here, have a status code", 200, "text/html" + +# url parameter and template render +@phew_app.route("/hello/", methods=["GET"]) +def hello(request, name): + return await render_template("example.html", name=name) + +# response with custom status code +@phew_app.route("/are/you/a/teapot", methods=["GET"]) +def teapot(request): + return "Yes", 418 + +# custom response object +@phew_app.route("/response", methods=["GET"]) +def response_object(request): + return server.Response("test body", status=302, content_type="text/html", headers={"Cache-Control": "max-age=3600"}) + +# query string example +@phew_app.route("/random", methods=["GET"]) +def random_number(request): + import random + min = int(request.query.get("min", 0)) + max = int(request.query.get("max", 100)) + return str(random.randint(min, max)) + +# catchall example +@phew_app.catchall() +def catchall(request): + return "Not found", 404 + +# set up TLS +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(cert, key) + +# start the webserver +phew_app.run(host='0.0.0.0', port=443, ssl=ctx) diff --git a/main.py b/main.py index 68b555f..68336a2 100644 --- a/main.py +++ b/main.py @@ -15,33 +15,35 @@ connect_to_wifi(secrets.WIFI_SSID, secrets.WIFI_PASSWORD) +phew_app = server.Phew() + # basic response with status code and content type -@server.route("/basic", methods=["GET", "POST"]) +@phew_app.route("/basic", methods=["GET", "POST"]) def basic(request): return "Gosh, a request", 200, "text/html" # basic response with status code and content type -@server.route("/status-code", methods=["GET", "POST"]) +@phew_app.route("/status-code", methods=["GET", "POST"]) def status_code(request): return "Here, have a status code", 200, "text/html" # url parameter and template render -@server.route("/hello/", methods=["GET"]) +@phew_app.route("/hello/", methods=["GET"]) def hello(request, name): return await render_template("example.html", name=name) # response with custom status code -@server.route("/are/you/a/teapot", methods=["GET"]) +@phew_app.route("/are/you/a/teapot", methods=["GET"]) def teapot(request): return "Yes", 418 # custom response object -@server.route("/response", methods=["GET"]) +@phew_app.route("/response", methods=["GET"]) def response_object(request): return server.Response("test body", status=302, content_type="text/html", headers={"Cache-Control": "max-age=3600"}) # query string example -@server.route("/random", methods=["GET"]) +@phew_app.route("/random", methods=["GET"]) def random_number(request): import random min = int(request.query.get("min", 0)) @@ -49,9 +51,9 @@ def random_number(request): return str(random.randint(min, max)) # catchall example -@server.catchall() +@phew_app.catchall() def catchall(request): return "Not found", 404 # start the webserver -server.run() +phew_app.run() diff --git a/phew/server.py b/phew/server.py index 60a3113..4c445be 100644 --- a/phew/server.py +++ b/phew/server.py @@ -1,10 +1,10 @@ +import binascii +import gc +import random + import uasyncio, os, time from . import logging -_routes = [] -catchall_handler = None -loop = uasyncio.get_event_loop() - def file_exists(filename): try: @@ -87,6 +87,10 @@ def __str__(self): "css": "text/css", "js": "text/javascript", "csv": "text/csv", + "txt": "text/plain", + "bin": "application/octet-stream", + "xml": "application/xml", + "gif": "image/gif", } @@ -162,13 +166,6 @@ async def _parse_headers(reader): return headers -# returns the route matching the supplied path or None -def _match_route(request): - for route in _routes: - if route.matches(request): - return route - return None - # if the content type is multipart/form-data then parse the fields async def _parse_form_data(reader, headers): @@ -223,136 +220,287 @@ async def _parse_json_body(reader, headers): 500: "Internal Server Error", 501: "Not Implemented" } +class Session: -# handle an incoming request to the web server -async def _handle_request(reader, writer): - response = None + ''' + Session class used to store all the attributes of a session. + ''' - request_start_time = time.ticks_ms() + def __init__(self, max_age=86400): + # create a 128 bit session id encoded in hex + n = [] + for i in range(4): + n.append(random.getrandbits(32).to_bytes(4,'big')) + self.session_id = binascii.hexlify(bytearray().join(n)).decode() + self.expires = time.time() + max_age + self.max_age = max_age - request_line = await reader.readline() - try: - method, uri, protocol = request_line.decode().split() - except Exception as e: - logging.error(e) - return - - request = Request(method, uri, protocol) - request.headers = await _parse_headers(reader) - if "content-length" in request.headers and "content-type" in request.headers: - if request.headers["content-type"].startswith("multipart/form-data"): - request.form = await _parse_form_data(reader, request.headers) - if request.headers["content-type"].startswith("application/json"): - request.data = await _parse_json_body(reader, request.headers) - if request.headers["content-type"].startswith("application/x-www-form-urlencoded"): - form_data = await reader.read(int(request.headers["content-length"])) - request.form = _parse_query_string(form_data.decode()) - - route = _match_route(request) - if route: - response = route.call_handler(request) - elif catchall_handler: - response = catchall_handler(request) - - # if shorthand body generator only notation used then convert to tuple - if type(response).__name__ == "generator": - response = (response,) - - # if shorthand body text only notation used then convert to tuple - if isinstance(response, str): - response = (response,) - - # if shorthand tuple notation used then build full response object - if isinstance(response, tuple): - body = response[0] - status = response[1] if len(response) >= 2 else 200 - content_type = response[2] if len(response) >= 3 else "text/html" - response = Response(body, status=status) - response.add_header("Content-Type", content_type) - if hasattr(body, '__len__'): - response.add_header("Content-Length", len(body)) - - # write status line - status_message = status_message_map.get(response.status, "Unknown") - writer.write(f"HTTP/1.1 {response.status} {status_message}\r\n".encode("ascii")) - - # write headers - for key, value in response.headers.items(): - writer.write(f"{key}: {value}\r\n".encode("ascii")) - - # blank line to denote end of headers - writer.write("\r\n".encode("ascii")) - - if isinstance(response, FileResponse): - # file - with open(response.file, "rb") as f: - while True: - chunk = f.read(1024) - if not chunk: - break + def expired(self): + return self.expires < time.time() + + +class Phew: + + def __init__(self): + self._routes = [] + self._login_required = set() + self.catchall_handler = None + self._login_catchall = None + self.loop = uasyncio.get_event_loop() + self.sessions = [] + + # handle an incoming request to the web server + async def _handle_request(self, reader, writer): + + # Do a GC collect before handling the request + gc.collect() + + response = None + + request_start_time = time.ticks_ms() + + request_line = await reader.readline() + try: + method, uri, protocol = request_line.decode().split() + except Exception as e: + logging.error(e) + return + + request = Request(method, uri, protocol) + request.headers = await _parse_headers(reader) + if "content-length" in request.headers and "content-type" in request.headers: + if request.headers["content-type"].startswith("multipart/form-data"): + request.form = await _parse_form_data(reader, request.headers) + if request.headers["content-type"].startswith("application/json"): + request.data = await _parse_json_body(reader, request.headers) + if request.headers["content-type"].startswith("application/x-www-form-urlencoded"): + form_data = b"" + content_length = int(request.headers["content-length"]) + while content_length > 0: + data = await reader.read(content_length) + if len(data) == 0: + break + content_length -= len(data) + form_data += data + request.form = _parse_query_string(form_data.decode()) + + route = self._match_route(request) + if route and self._login_catchall and self.is_login_required(route.handler) and not self.active_session(request): + response = self._login_catchall(request) + elif route: + response = route.call_handler(request) + elif self.catchall_handler: + if self.is_login_required(self.catchall_handler) and not self.active_session(request): + # handle the case that the catchall handler is annotated with @login_required() + response = self._login_catchall(request) + else: + response = self.catchall_handler(request) + + # if shorthand body generator only notation used then convert to tuple + if type(response).__name__ == "generator": + response = (response,) + + # if shorthand body text only notation used then convert to tuple + if isinstance(response, str): + response = (response,) + + # if shorthand tuple notation used then build full response object + if isinstance(response, tuple): + body = response[0] + status = response[1] if len(response) >= 2 else 200 + content_type = response[2] if len(response) >= 3 else "text/html" + response = Response(body, status=status) + response.add_header("Content-Type", content_type) + if hasattr(body, '__len__'): + response.add_header("Content-Length", len(body)) + + # write status line + status_message = status_message_map.get(response.status, "Unknown") + writer.write(f"HTTP/1.1 {response.status} {status_message}\r\n".encode("ascii")) + + # write headers + for key, value in response.headers.items(): + writer.write(f"{key}: {value}\r\n".encode("ascii")) + + # blank line to denote end of headers + writer.write("\r\n".encode("ascii")) + + if isinstance(response, FileResponse): + # file + with open(response.file, "rb") as f: + while True: + chunk = f.read(1024) + if not chunk: + break + writer.write(chunk) + await writer.drain() + elif type(response.body).__name__ == "generator": + # generator + for chunk in response.body: writer.write(chunk) await writer.drain() - elif type(response.body).__name__ == "generator": - # generator - for chunk in response.body: - writer.write(chunk) + else: + # string/bytes + writer.write(response.body) await writer.drain() - else: - # string/bytes - writer.write(response.body) - await writer.drain() - - writer.close() - await writer.wait_closed() - - processing_time = time.ticks_ms() - request_start_time - logging.info(f"> {request.method} {request.path} ({response.status} {status_message}) [{processing_time}ms]") - - -# adds a new route to the routing table -def add_route(path, handler, methods=["GET"]): - global _routes - _routes.append(Route(path, handler, methods)) - # descending complexity order so most complex routes matched first - _routes = sorted(_routes, key=lambda route: len(route.path_parts), reverse=True) + + writer.close() + await writer.wait_closed() + + processing_time = time.ticks_ms() - request_start_time + logging.info(f"> {request.method} {request.path} ({response.status} {status_message}) [{processing_time}ms]") + + + # adds a new route to the routing table + def add_route(self, path, handler, methods=["GET"]): + self._routes.append(Route(path, handler, methods)) + # descending complexity order so most complex routes matched first + self._routes = sorted(self._routes, key=lambda route: len(route.path_parts), reverse=True) + + + def set_callback(self, handler): + self.catchall_handler = handler + + + # decorator shorthand for adding a route + def route(self, path, methods=["GET"]): + def _route(f): + self.add_route(path, f, methods=methods) + return f + return _route + + + # add the handler to the _login_required list + def add_login_required(self, handler): + self._login_required.add(handler) + + + def is_login_required(self, handler): + return handler in self._login_required + + + # decorator indicating that authentication is required for a handler + def login_required(self): + def _login_required(f): + self.add_login_required(f) + return f + return _login_required + + + def set_login_catchall(self, handler): + self._login_catchall = handler + + + # decorator for adding login_handler route + def login_catchall(self): + def _login_catchall(f): + self.set_login_catchall(f) + return f + return _login_catchall + + + # decorator for adding catchall route + def catchall(self): + def _catchall(f): + self.set_callback(f) + return f + return _catchall + + def redirect(self, url, status = 301): + return Response("", status, {"Location": url}) + + def serve_file(self, file): + return FileResponse(file) + + # returns the route matching the supplied path or None + def _match_route(self, request): + for route in self._routes: + if route.matches(request): + return route + return None + + def run_as_task(self, loop, host = "0.0.0.0", port = 80, ssl=None): + loop.create_task(uasyncio.start_server(self._handle_request, host, port, ssl=ssl)) + + def run(self, host = "0.0.0.0", port = 80, ssl=None): + logging.info("> starting web server on port {}".format(port)) + self.loop.create_task(uasyncio.start_server(self._handle_request, host, port, ssl=ssl)) + self.loop.run_forever() + + def stop(self): + self.loop.stop() + + def close(self): + self.loop.close() + + def create_session(self, max_age=86400): + session = Session(max_age=max_age) + self.sessions.append(session) + return session + + def get_session(self, request): + session = None + name = None + value = None + if "cookie" in request.headers: + cookie = request.headers["cookie"] + if cookie: + name, value = cookie.split("=") + if name == "sessionid": + # find session + for s in self.sessions: + if s.session_id == value: + session = s + return session + def remove_session(self, request): + session = self.get_session(request) + if session is not None: + self.sessions.remove(session) + + def active_session(self, request): + session = self.get_session(request) + return session is not None and not session.expired() + +# Compatibility methods +default_phew_app = None + + +def default_phew(): + global default_phew_app + if not default_phew_app: + default_phew_app = Phew() + return default_phew_app def set_callback(handler): - global catchall_handler - catchall_handler = handler + default_phew().set_callback(handler) # decorator shorthand for adding a route def route(path, methods=["GET"]): - def _route(f): - add_route(path, f, methods=methods) - return f - return _route + return default_phew().route(path, methods) # decorator for adding catchall route def catchall(): - def _catchall(f): - set_callback(f) - return f - return _catchall - + return default_phew().catchall() -def redirect(url, status = 301): - return Response("", status, {"Location": url}) + +def redirect(url, status=301): + return default_phew().redirect(url, status) def serve_file(file): - return FileResponse(file) + return default_phew().serve_file(file) + +def run(host="0.0.0.0", port=80): + default_phew().run(host, port) -def run(host = "0.0.0.0", port = 80): - logging.info("> starting web server on port {}".format(port)) - loop.create_task(uasyncio.start_server(_handle_request, host, port)) - loop.run_forever() def stop(): - loop.stop() + default_phew().stop() + def close(): - loop.close() \ No newline at end of file + default_phew().close() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4cea49c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2023 Charles Crighton +# +# SPDX-License-Identifier: MIT + +[build-system] +requires = ["setuptools", "minify-html", "python-minifier"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c2dcb6e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2023 Charles Crighton +# +# SPDX-License-Identifier: MIT + +build +esptool +keyring +minify-html +mpremote +pipkin +pre-commit +pypi_json +python_minifier +reuse +setuptools +twine diff --git a/setup.py b/setup.py index 206a7c1..be1af61 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,103 @@ from setuptools import setup +# SPDX-FileCopyrightText: 2023 Charles Crighton +# +# SPDX-License-Identifier: MIT +import os + +import minify_html +import python_minifier +from setuptools import Command +from setuptools import find_packages +from setuptools import setup +from setuptools.command.sdist import sdist + + +def minify_py_dir(directory): + """ Minify all the python files in directory. """ + + files = [directory + '/' + f for f in os.listdir(directory) + if os.path.isfile(directory + '/' + f) and f.endswith('py')] + + total_size = 0 + total_minified_size = 0 + + for filename in files: + size = os.stat(filename).st_size + minified_size = 0 + minified = None + with open(filename) as f: + minified = python_minifier.minify(f.read()) + minified_size = len(minified) + total_size += size + with open(filename, 'w') as f: + f.write(minified) + total_minified_size += minified_size + print(f"{filename}: Size: {total_size}, minified size: {total_minified_size}, " + f"%{total_minified_size / total_size * 100:.0f}") + + +def minify_html_css_js_file(filename): + """ Minify a file. Must be a html, css or js. """ + + with open(filename, 'r') as f: + minified = minify_html.minify(f.read(), minify_js=True, remove_processing_instructions=True) + minified_size = len(minified) + with open(filename, 'w') as f: + f.seek(0) + f.write(minified) + return minified_size + + +def minify_html_css_js_dir(directory): + """ Minify all html, css, and javascript files in the directory. """ + + files = [directory + '/' + f for f in os.listdir(directory) if os.path.isfile(directory + '/' + f) + and (f.endswith('html') or f.endswith('css') or f.endswith('js'))] + + total_size = 0 + total_minified_size = 0 + + for filename in files: + size = os.stat(filename).st_size + minified_size = minify_html_css_js_file(filename) + total_size += size + total_minified_size += minified_size + print(f"{filename}: Size: {total_size}, minified size: {total_minified_size}, " + f"%{total_minified_size / total_size * 100:.0f}") + + +class SdistAndMinify(sdist): + """ Extend sdist to add minifying python, html and css files to reduce memory overhead for resource constrained + devices such as the esp8266. + """ + + def make_release_tree(self, base_dir, files): + """ make_release_tree creates the directory tree for the source distribution archive. + Extended by this class to minify the python, html and css files before packaging into a sdist tar or + wheel. Minification is done after the super().make_release_tree so the files are copied to base_dir but + not yet packaged. + """ + super().make_release_tree(base_dir, files) + minify_html_css_js_dir(base_dir + '/phew') + minify_py_dir(base_dir + '/phew') + + setup( - name="micropython-phew", - version="0.0.2", + name="micropython-ccrighton-phew", + version="0.0.5", description="A small webserver and templating library specifically designed for MicroPython on the Pico W.", long_description=open("README.md").read(), long_description_content_type="text/markdown", project_urls={ - "GitHub": "https://github.com/pimoroni/phew" + "GitHub": "https://github.com/ccrighton/phew", + "Change Log": "https://github.com/ccrighton/phew/blob/main/changelog.md" }, - author="Jonathan Williamson", - maintainer="Phil Howard", - maintainer_email="phil@pimoroni.com", + author="Jonathan Williamson - Pimoroni", + maintainer="Charlie Crighton", + maintainer_email="code@crighton.net.nz", license="MIT", license_files="LICENSE", - packages=["phew"] + packages=["phew"], + cmdclass={'sdist': SdistAndMinify} )