diff --git a/example.py b/example.py index 7416880..7d103ef 100644 --- a/example.py +++ b/example.py @@ -6,6 +6,7 @@ from spiderweb.response import HttpResponse, JsonResponse, TemplateResponse, Red app = WebServer( templates_dirs=["templates"], middleware=[ + "spiderweb.middleware.csrf.CSRFMiddleware", "example_middleware.TestMiddleware", "example_middleware.RedirectMiddleware", "example_middleware.ExplodingMiddleware", @@ -46,6 +47,19 @@ def example(request, id): return HttpResponse(body=f"Example with id {id}") +@app.error(405) +def http405(request) -> HttpResponse: + return HttpResponse(body="Method not allowed", status_code=405) + + +@app.route("/form", allowed_methods=["POST"]) +def form(request): + if request.method == "POST": + return JsonResponse(data=request.POST) + else: + return TemplateResponse(request, "form.html") + + if __name__ == "__main__": # can also add routes like this: # app.add_route("/", index) diff --git a/example_middleware.py b/example_middleware.py index be2d105..f566b2a 100644 --- a/example_middleware.py +++ b/example_middleware.py @@ -5,20 +5,20 @@ from spiderweb.response import HttpResponse, RedirectResponse class TestMiddleware(SpiderwebMiddleware): - def process_request(self, request: Request) -> HttpResponse | None: + def process_request(self, request: Request) -> None: # example of a middleware that sets a flag on the request request.spiderweb = True def process_response( self, request: Request, response: HttpResponse - ) -> HttpResponse | None: + ) -> None: # example of a middleware that sets a header on the resp if hasattr(request, "spiderweb"): response.headers["X-Spiderweb"] = "true" class RedirectMiddleware(SpiderwebMiddleware): - def process_request(self, request: Request) -> HttpResponse | None: + def process_request(self, request: Request) -> HttpResponse: if request.path == "/middleware": return RedirectResponse("/") diff --git a/poetry.lock b/poetry.lock index 93162f3..ad84957 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "black" @@ -44,6 +44,85 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cffi" +version = "1.17.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, + {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, + {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, + {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, + {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, + {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, + {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, + {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, + {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, + {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, + {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, + {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, + {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, + {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, + {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, + {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, + {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, + {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, + {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, + {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, + {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, + {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, + {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, + {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, + {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, + {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, + {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, + {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, + {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, + {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, + {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.7" @@ -69,6 +148,55 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "43.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -240,6 +368,17 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pytest" version = "8.3.2" @@ -290,4 +429,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f7533dcd984fcec36a80d3523af159bc6ebe71edb3c315d8bfd01690d910fb76" +content-hash = "96cb529cc8a301c9ac0920582fb7ccb26bc789b0c5ccbc4135fc2d8d6936bb75" diff --git a/pyproject.toml b/pyproject.toml index 7fdd4a2..4738790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" python = "^3.11" peewee = "^3.17.6" jinja2 = "^3.1.4" +cryptography = "^43.0.0" [tool.poetry.group.dev.dependencies] ruff = "^0.5.5" diff --git a/spiderweb/__init__.py b/spiderweb/__init__.py index 1efac13..eff7bf0 100644 --- a/spiderweb/__init__.py +++ b/spiderweb/__init__.py @@ -1 +1,2 @@ from spiderweb.main import route, WebServer # noqa: F401 +from spiderweb.middleware import * # noqa: F401, F403 diff --git a/spiderweb/default_responses.py b/spiderweb/default_responses.py index 5ab8466..017ed37 100644 --- a/spiderweb/default_responses.py +++ b/spiderweb/default_responses.py @@ -11,5 +11,9 @@ def http404(request): ) +def http405(request): + return JsonResponse(data={"error": "Method not allowed"}, status_code=405) + + def http500(request): return JsonResponse(data={"error": "Internal server error"}, status_code=500) diff --git a/spiderweb/exceptions.py b/spiderweb/exceptions.py index e3fcd9a..36450d7 100644 --- a/spiderweb/exceptions.py +++ b/spiderweb/exceptions.py @@ -52,6 +52,14 @@ class ServerError(SpiderwebNetworkException): self.desc = desc if desc else "The server has encountered an error" +class CSRFError(SpiderwebNetworkException): + def __init__(self, desc=None): + self.code = 403 + self.msg = "Forbidden" + self.desc = desc if desc else "CSRF token is invalid" + + + class ConfigError(SpiderwebException): pass diff --git a/spiderweb/main.py b/spiderweb/main.py index c9fb277..de3a52e 100644 --- a/spiderweb/main.py +++ b/spiderweb/main.py @@ -2,7 +2,7 @@ # https://gist.github.com/earonesty/ab07b4c0fea2c226e75b3d538cc0dc55 # # Extensively modified by @itsthejoker -import json +from datetime import datetime, timedelta import re import signal import time @@ -13,10 +13,11 @@ import threading import logging from typing import Callable, Any, NoReturn +from cryptography.fernet import Fernet from jinja2 import Environment, FileSystemLoader from spiderweb.converters import * # noqa: F403 -from spiderweb.default_responses import http403, http404, http500 # noqa: F401 +from spiderweb.default_responses import * # noqa: F403 from spiderweb.exceptions import ( APIError, ConfigError, @@ -32,6 +33,9 @@ from spiderweb.utils import import_by_string log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +DEFAULT_ALLOWED_METHODS = ["GET"] +DEFAULT_ENCODING = "utf-8" + def route(path): def outer(func): @@ -68,6 +72,7 @@ class WebServer(HTTPServer): templates_dirs: list[str] = None, middleware: list[str] = None, append_slash: bool = False, + secret_key: str = None, ): """ Create a new server on address, port. Port can be zero. @@ -83,13 +88,17 @@ class WebServer(HTTPServer): self.append_slash = append_slash self.templates_dirs = templates_dirs self.middleware = middleware if middleware else [] + self.secret_key = secret_key if secret_key else self._create_secret_key() + self.fernet = Fernet(self.key) + self.DEFAULT_ENCODING = DEFAULT_ENCODING + self.DEFAULT_ALLOWED_METHODS = DEFAULT_ALLOWED_METHODS self._thread = None if self.middleware: middleware_by_reference = [] for m in self.middleware: try: - middleware_by_reference.append(import_by_string(m)()) + middleware_by_reference.append(import_by_string(m)(server=self)) except ImportError: raise ConfigError(f"Middleware '{m}' not found.") self.middleware = middleware_by_reference @@ -105,11 +114,8 @@ class WebServer(HTTPServer): class HandlerClass(RequestHandler): pass - # inject template loader, middleware, and other important things into handler self.handler_class = custom_handler if custom_handler else HandlerClass - self.handler_class.env = self.env - self.handler_class.middleware = self.middleware - self.handler_class.append_slash = self.append_slash + self.handler_class.server = self # routed methods map into handler for method in type(self).__dict__.values(): @@ -148,22 +154,32 @@ class WebServer(HTTPServer): if self.convert_path(path) in self.handler_class._routes: raise ConfigError(f"Route '{path}' already exists.") - def add_route(self, path: str, method: Callable): + def add_route(self, path: str, method: Callable, allowed_methods: list[str]): """Add a route to the server.""" if not hasattr(self.handler_class, "_routes"): - setattr(self.handler_class, "_routes", []) + setattr(self.handler_class, "_routes", {}) if self.append_slash and not path.endswith("/"): updated_path = path + "/" self.check_for_route_duplicates(updated_path) self.check_for_route_duplicates(path) - self.handler_class._routes[self.convert_path(path)] = DummyRedirectRoute(updated_path) - self.handler_class._routes[self.convert_path(updated_path)] = method + self.handler_class._routes[self.convert_path(path)] = {'func': DummyRedirectRoute(updated_path), 'allowed_methods': allowed_methods} + self.handler_class._routes[self.convert_path(updated_path)] = {'func': method, 'allowed_methods': allowed_methods} else: self.check_for_route_duplicates(path) - self.handler_class._routes[self.convert_path(path)] = method + self.handler_class._routes[self.convert_path(path)] = {'func': method, 'allowed_methods': allowed_methods} - def route(self, path) -> Callable: + def add_error_route(self, code: int, method: Callable): + """Add an error route to the server.""" + if not hasattr(self.handler_class, "_error_routes"): + setattr(self.handler_class, "_error_routes", {}) + + if code not in self.handler_class._error_routes: + self.handler_class._error_routes[code] = method + else: + raise ConfigError(f"Error route for code {code} already exists.") + + def route(self, path, allowed_methods=None) -> Callable: """ Decorator for adding a route to a view. @@ -176,15 +192,26 @@ class WebServer(HTTPServer): return HttpResponse(content="Hello, world!") :param path: str + :param allowed_methods: list[str] :return: Callable """ def outer(func): - self.add_route(path, func) + self.add_route( + path, + func, + allowed_methods if allowed_methods else DEFAULT_ALLOWED_METHODS + ) return func return outer + def error(self, code: int) -> Callable: + def outer(func): + self.add_error_route(code, func) + return func + return outer + @property def port(self): """Return current port.""" @@ -225,15 +252,25 @@ class WebServer(HTTPServer): super().shutdown() self.socket.close() + def _create_secret_key(self): + self.key = Fernet.generate_key() + + def encrypt(self, data: str): + return self.fernet.encrypt(bytes(data, DEFAULT_ENCODING)) + + def decrypt(self, data: str): + if isinstance(data, bytes): + return self.fernet.decrypt(data).decode(DEFAULT_ENCODING) + return self.fernet.decrypt(bytes(data, DEFAULT_ENCODING)).decode(DEFAULT_ENCODING) class RequestHandler(BaseHTTPRequestHandler): - # I can't help the naming convention of these because that's what - # BaseHTTPRequestHandler uses for some weird reason - - # These stop pycharm from complaining about these not existing. They're - # injected by the WebServer class at runtime - _routes = {} - middleware = [] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # These stop pycharm from complaining about these not existing. They're + # injected by the WebServer class at runtime + self._routes = {} + self._error_routes = {} + self.server = None def get_request(self): return Request( @@ -244,8 +281,11 @@ class RequestHandler(BaseHTTPRequestHandler): path=self.path, ) + # I can't help the naming convention of these because that's what + # BaseHTTPRequestHandler uses for some weird reason def do_GET(self): request = self.get_request() + request.method = "GET" self.handle_request(request) def do_POST(self): @@ -254,28 +294,23 @@ class RequestHandler(BaseHTTPRequestHandler): length = int(self.headers["Content-Length"]) content = self.rfile.read(length) request = self.get_request() + request.method = "POST" request.content = content - if content: - try: - request.json() - except json.JSONDecodeError: - raise APIError(400, "Invalid JSON", content) self.handle_request(request) - def get_route(self, path) -> tuple[Callable, dict[str, Any]]: + def get_route(self, path) -> tuple[Callable, dict[str, Any], list[str]]: for option in self._routes.keys(): if match_data := option.match(path): - return self._routes[option], convert_match_to_dict( + return self._routes[option]['func'], convert_match_to_dict( match_data.groupdict() - ) + ), self._routes[option]['allowed_methods'] raise NotFound() def get_error_route(self, code: int) -> Callable: - try: - view = globals()[f"http{code}"] - return view - except KeyError: + view = self._error_routes.get(code) or globals().get(f"http{code}") + if not view: return http500 + return view def _fire_response(self, resp: HttpResponse): self.send_response(resp.status_code) @@ -285,7 +320,7 @@ class RequestHandler(BaseHTTPRequestHandler): for key, value in resp.headers.items(): self.send_header(key, value) self.end_headers() - self.wfile.write(bytes(content, "utf-8")) + self.wfile.write(bytes(content, DEFAULT_ENCODING)) def fire_response(self, request: Request, resp: HttpResponse): try: @@ -299,11 +334,11 @@ class RequestHandler(BaseHTTPRequestHandler): self.fire_response(request, self.get_error_route(500)(request)) def process_request_middleware(self, request: Request) -> None | bool: - for middleware in self.middleware: + for middleware in self.server.middleware: try: resp = middleware.process_request(request) except UnusedMiddleware: - self.middleware.remove(middleware) + self.server.middleware.remove(middleware) continue if resp: self.process_response_middleware(request, resp) @@ -311,11 +346,11 @@ class RequestHandler(BaseHTTPRequestHandler): return True # abort further processing def process_response_middleware(self, request: Request, response: HttpResponse) -> None: - for middleware in self.middleware: + for middleware in self.server.middleware: try: middleware.process_response(request, response) except UnusedMiddleware: - self.middleware.remove(middleware) + self.server.middleware.remove(middleware) continue def prepare_and_fire_response(self, request, resp) -> None: @@ -323,10 +358,10 @@ class RequestHandler(BaseHTTPRequestHandler): if isinstance(resp, dict): self.fire_response(request, JsonResponse(data=resp)) if isinstance(resp, TemplateResponse): - if hasattr(self, "env"): # injected from above - resp.set_template_loader(self.env) + if hasattr(self.server, "env"): + resp.set_template_loader(self.server.env) - for middleware in self.middleware: + for middleware in self.server.middleware: middleware.process_response(request, resp) self.fire_response(request, resp) @@ -338,6 +373,9 @@ class RequestHandler(BaseHTTPRequestHandler): log.error(traceback.format_exc()) self.fire_response(request, self.get_error_route(500)(request)) + def is_form_request(self, request: Request) -> bool: + return "Content-Type" in request.headers and request.headers["Content-Type"] == "application/x-www-form-urlencoded" + def send_error_response(self, request: Request, e: SpiderwebNetworkException): try: self.send_error(e.code, e.msg, e.desc) @@ -349,17 +387,25 @@ class RequestHandler(BaseHTTPRequestHandler): request.url = urlparse.urlparse(request.path) try: - handler, additional_args = self.get_route(request.url.path) + handler, additional_args, allowed_methods = self.get_route(request.url.path) except NotFound: handler = self.get_error_route(404) additional_args = {} + allowed_methods = DEFAULT_ALLOWED_METHODS - if request.url.query: - params = urlparse.parse_qs(request.url.query) - else: - params = {} + if request.method not in allowed_methods: + # replace the potentially valid handler with the error route + handler = self.get_error_route(405) + + request.query_params = urlparse.parse_qs(request.url.query) if request.url.query else {} + + if self.is_form_request(request): + formdata = urlparse.parse_qs(request.content.decode("utf-8")) + for key, value in formdata.items(): + if len(value) == 1: + formdata[key] = value[0] + setattr(request, request.method, formdata) - request.query_params = params try: if handler: # middleware is injected from WebServer diff --git a/spiderweb/middleware/__init__.py b/spiderweb/middleware/__init__.py new file mode 100644 index 0000000..35964e5 --- /dev/null +++ b/spiderweb/middleware/__init__.py @@ -0,0 +1,2 @@ +from .base import SpiderwebMiddleware +from .csrf import CSRFMiddleware diff --git a/spiderweb/middleware.py b/spiderweb/middleware/base.py similarity index 93% rename from spiderweb/middleware.py rename to spiderweb/middleware/base.py index 9a1eebd..d0a1de3 100644 --- a/spiderweb/middleware.py +++ b/spiderweb/middleware/base.py @@ -17,6 +17,8 @@ class SpiderwebMiddleware: If `process_request` returns a HttpResponse, the request will be short-circuited and the response will be returned immediately. `process_response` will not be called. """ + def __init__(self, server): + self.server = server def process_request(self, request: Request) -> HttpResponse | None: pass diff --git a/spiderweb/middleware/csrf.py b/spiderweb/middleware/csrf.py new file mode 100644 index 0000000..16b37c2 --- /dev/null +++ b/spiderweb/middleware/csrf.py @@ -0,0 +1,37 @@ +from datetime import datetime, timedelta + +from spiderweb.exceptions import CSRFError +from spiderweb.middleware import SpiderwebMiddleware +from spiderweb.request import Request +from spiderweb.response import HttpResponse + + +class CSRFMiddleware(SpiderwebMiddleware): + CSRF_EXPIRY = 60 * 60 # 1 hour + + def process_request(self, request: Request) -> HttpResponse | None: + if request.method == "POST": + csrf_token = request.headers.get("X-CSRF-TOKEN") or request.GET.get("csrf_token") or request.POST.get("csrf_token") + if self.is_csrf_valid(csrf_token): + return None + else: + raise CSRFError() + return None + + def process_response(self, request: Request, response: HttpResponse) -> None: + token = self.get_csrf_token() + # do we need it in both places? + response.headers["X-CSRF-TOKEN"] = token + request.csrf_token = token + + def get_csrf_token(self): + return self.server.encrypt(str(datetime.now().isoformat())).decode(self.server.DEFAULT_ENCODING) + + def is_csrf_valid(self, key): + try: + decoded = self.server.decrypt(key) + if datetime.now() - timedelta(seconds=self.CSRF_EXPIRY) > datetime.fromisoformat(decoded): + return False + return True + except Exception: + return False diff --git a/spiderweb/request.py b/spiderweb/request.py index 15806f5..83e256f 100644 --- a/spiderweb/request.py +++ b/spiderweb/request.py @@ -19,6 +19,8 @@ class Request: self.path: str = path self.url = url self.query_params = query_params + self.GET = {} + self.POST = {} def json(self): return json.loads(self.content) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a7655ff --- /dev/null +++ b/templates/base.html @@ -0,0 +1,18 @@ + + +
+ + +
@@ -19,9 +12,4 @@
The value of request.spiderweb
is {{ request.spiderweb }}. If this is True,
middleware is working.