From 98e1ecdf13d394d3f69613ecaf0330a87bdbc59e Mon Sep 17 00:00:00 2001 From: Joe Kaufeld Date: Thu, 12 Jun 2025 00:20:17 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20file=20upload=20ability=20for?= =?UTF-8?q?=20multipart=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example.py | 20 ++++++++ poetry.lock | 74 ++++++++++++++++++++++++------ pyproject.toml | 1 + spiderweb/files.py | 49 ++++++++++++++++++++ spiderweb/main.py | 34 ++++++++++---- spiderweb/request.py | 44 +++++++++++++----- spiderweb/response.py | 4 ++ spiderweb/tests/test_middleware.py | 2 +- templates/file_upload.html | 13 ++++++ 9 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 spiderweb/files.py create mode 100644 templates/file_upload.html diff --git a/example.py b/example.py index c1745e2..e997504 100644 --- a/example.py +++ b/example.py @@ -29,6 +29,7 @@ app = SpiderwebRouter( append_slash=False, # default cors_allow_all_origins=True, static_url="static_stuff", + media_dir="media", debug=True, case_transform_middleware_type="spongebob", ) @@ -71,6 +72,25 @@ def example(request, id): return HttpResponse(body=f"Example with id {id}") +@app.route("file_upload/") +def file_upload(request): + if request.method == "POST": + if "file" not in request.FILES: + return HttpResponse(body="No file uploaded", status_code=400) + file = request.FILES["file"] + content = file.read() + filepath = file.save() # Save the file to the media directory + try: + return HttpResponse(body=f"File content: {content.decode('utf-8')}") + except UnicodeDecodeError: + return HttpResponse( + body=f"The file has been uploaded, but it is not a text file." + f" Saved to {filepath}", + status_code=400) + else: + return TemplateResponse(request, "file_upload.html") + + @app.error(405) def http405(request) -> HttpResponse: return HttpResponse(body="Method not allowed", status_code=405) diff --git a/poetry.lock b/poetry.lock index ae350d9..ad67ecb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -6,6 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -17,18 +18,19 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "black" @@ -36,6 +38,7 @@ version = "24.8.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, @@ -70,7 +73,7 @@ platformdirs = ">=2" [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -80,6 +83,8 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -159,6 +164,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -173,6 +179,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -184,6 +192,7 @@ version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, @@ -260,7 +269,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -268,6 +277,7 @@ version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, @@ -317,6 +327,7 @@ version = "2.6.1" description = "DNS toolkit" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, @@ -337,6 +348,7 @@ version = "2.2.0" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, @@ -352,6 +364,7 @@ version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, @@ -373,6 +386,7 @@ version = "6.112.1" description = "A library for property-based testing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "hypothesis-6.112.1-py3-none-any.whl", hash = "sha256:93631b1498b20d2c205ed304cbd41d50e9c069d78a9c773c1324ca094c5e30ce"}, {file = "hypothesis-6.112.1.tar.gz", hash = "sha256:b070d7a1bb9bd84706c31885c9aeddc138e2b36a9c112a91984f49501c567856"}, @@ -383,7 +397,7 @@ attrs = ">=22.2.0" sortedcontainers = ">=2.1.0,<3.0.0" [package.extras] -all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.70)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.13)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] +all = ["backports.zoneinfo (>=0.2.1) ; python_version < \"3.9\"", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.70)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.13)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] codemods = ["libcst (>=0.3.16)"] crosshair = ["crosshair-tool (>=0.0.70)", "hypothesis-crosshair (>=0.0.13)"] @@ -397,7 +411,7 @@ pandas = ["pandas (>=1.1)"] pytest = ["pytest (>=4.6)"] pytz = ["pytz (>=2014.1)"] redis = ["redis (>=3.0.0)"] -zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"] +zoneinfo = ["backports.zoneinfo (>=0.2.1) ; python_version < \"3.9\"", "tzdata (>=2024.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] [[package]] name = "idna" @@ -405,6 +419,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -419,6 +434,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -430,6 +446,7 @@ version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -447,6 +464,7 @@ version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -510,12 +528,29 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "multipart" +version = "1.2.1" +description = "Parser for multipart/form-data" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "multipart-1.2.1-py3-none-any.whl", hash = "sha256:c03dc203bc2e67f6b46a599467ae0d87cf71d7530504b2c1ff4a9ea21d8b8c8c"}, + {file = "multipart-1.2.1.tar.gz", hash = "sha256:829b909b67bc1ad1c6d4488fcdc6391c2847842b08323addf5200db88dbe9480"}, +] + +[package.extras] +dev = ["build", "pytest", "pytest-cov", "twine"] +docs = ["sphinx (>=8,<9)", "sphinx-autobuild"] + [[package]] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -527,6 +562,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -538,6 +574,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -549,6 +586,7 @@ version = "3.17.6" description = "a little orm" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "peewee-3.17.6.tar.gz", hash = "sha256:cea5592c6f4da1592b7cff8eaf655be6648a1f5857469e30037bf920c03fb8fb"}, ] @@ -559,6 +597,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -575,6 +614,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -590,6 +630,8 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -601,6 +643,7 @@ version = "2.9.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, @@ -616,7 +659,7 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" @@ -624,6 +667,7 @@ version = "2.23.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, @@ -725,6 +769,7 @@ version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, @@ -745,6 +790,7 @@ version = "0.5.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, @@ -772,6 +818,7 @@ version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, @@ -783,12 +830,13 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.11" -content-hash = "17f5dc4b157da57ad75a6f6aa3feb7adfa07500b805d5e79d1f09d640964949f" +content-hash = "81a82cbbeda308234dd260a0d9edfd0e4e6264e2f40eb908049e2461c1438eaa" diff --git a/pyproject.toml b/pyproject.toml index 88ee2ee..7cc4338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ jinja2 = "^3.1.4" cryptography = "^43.0.0" email-validator = "^2.2.0" pydantic = "^2.8.2" +multipart = "^1.2.1" [tool.poetry.group.dev.dependencies] ruff = "^0.5.5" diff --git a/spiderweb/files.py b/spiderweb/files.py new file mode 100644 index 0000000..061a82a --- /dev/null +++ b/spiderweb/files.py @@ -0,0 +1,49 @@ +import random +import string + +from multipart import MultipartPart + + +class MediaFile: + # This class acts as a sort of container for uploaded files. + # Rather than trying to subclass the MultipartPart class and deal with + # the complexities of multipart parsing, we just use this class to + # add the save functionality for the media folder. Also makes the most + # common attributes available directly on the instance. + def __init__(self, server, multipart_part: MultipartPart): + self._file: MultipartPart = multipart_part + self.filename: str = self._file.filename + self.content_type: str = self._file.content_type + self.server = server + #: Part size in bytes. + self.size = self._file.size + #: Part name. + self.name = self._file.name + #: Charset as defined in the part header, or the parser default charset. + self.charset = self._file.charset + #: All part headers as a list of (name, value) pairs. + self.headerlist = self._file.headerlist + + self.memfile_limit = self._file.memfile_limit + self.buffer_size = self._file.buffer_size + + def get_random_suffix(self) -> str: + """Generate a random 6 character suffix.""" + return "".join(random.choices(string.ascii_letters, k=6)) + + def save(self): + file_path = self.server.BASE_DIR / self.server.media_dir / self._file.filename + if file_path.exists(): + # If the file already exists, append a random suffix to the filename + suffix = self.get_random_suffix() + file_path = file_path.with_name( + f"{file_path.stem}_[{suffix}]{file_path.suffix}" + ) + self._file.save_as(file_path) + return file_path + + def read(self): + return self._file.file.read() + + def seek(self, offset: int, whence: int = 0): + return self._file.file.seek(offset, whence) \ No newline at end of file diff --git a/spiderweb/main.py b/spiderweb/main.py index 51230a4..848e579 100644 --- a/spiderweb/main.py +++ b/spiderweb/main.py @@ -3,8 +3,8 @@ import logging import pathlib import re import traceback -import urllib.parse as urlparse from logging import Logger +from pathlib import Path from threading import Thread from typing import Optional, Callable, Sequence, Literal from wsgiref.simple_server import WSGIServer @@ -74,8 +74,10 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi templates_dirs: Sequence[str] = None, middleware: Sequence[str] = None, append_slash: bool = False, - staticfiles_dirs: Sequence[str] = None, + staticfiles_dirs: Sequence[str | Path] = None, static_url: str = "static", + media_dir: str | Path = None, + media_url: str = "media", routes: Sequence[tuple[str, Callable] | tuple[str, Callable, dict]] = None, error_routes: dict[int, Callable] = None, secret_key: str = None, @@ -96,9 +98,12 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi self.port = port if port else 8000 self.server_address = (self.addr, self.port) self.append_slash = append_slash + self.fix_route_starting_slash = True self.templates_dirs = templates_dirs self.staticfiles_dirs = staticfiles_dirs + self.media_dir = media_dir self.static_url = static_url + self.media_url = media_url self._middleware: list[str] = middleware or [] self.middleware: list[Callable] = [] self.secret_key = secret_key if secret_key else self.generate_key() @@ -200,6 +205,21 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi " files will not be served." ) + if self.media_dir: + self.media_dir = pathlib.Path(self.media_dir) + if not pathlib.Path(self.BASE_DIR / self.media_dir).exists(): + self.log.error(f"Media directory '{str(self.media_dir)}' does not exist.") + raise ConfigError + if self.debug: + self.add_route( + rf"/{self.media_url}/", send_file + ) + else: + self.log.warning( + "`media_dir` is set, but `debug` is set to FALSE." + " Media files will not be served." + ) + # finally, run the startup checks to verify everything is correct and happy. self.log.info("Run startup checks...") self.run_middleware_checks() @@ -283,6 +303,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi return resp except ConnectionAbortedError as e: self.log.error(f"{request.method} {request.path} : {e}") + return HttpResponse(status_code=500) def prepare_and_fire_response(self, start_response, request, resp) -> list[bytes]: try: @@ -303,7 +324,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi except Exception: self.log.error(traceback.format_exc()) - self.fire_response( + return self.fire_response( start_response, request, self.get_error_route(500)(request) ) @@ -334,13 +355,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi if not self.check_valid_host(request): handler = self.get_error_route(403) - if request.is_form_request(): - form_data = urlparse.parse_qs(request.content) - for key, value in form_data.items(): - if len(value) == 1: - form_data[key] = value[0] - setattr(request, request.method, form_data) - try: if handler: abort_view = self.process_request_middleware(request) diff --git a/spiderweb/request.py b/spiderweb/request.py index 90e0160..579a3eb 100644 --- a/spiderweb/request.py +++ b/spiderweb/request.py @@ -1,9 +1,17 @@ import json -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs from spiderweb.constants import DEFAULT_ENCODING +from spiderweb.files import MediaFile from spiderweb.utils import get_client_address, Headers +from multipart import ( + parse_form_data, + is_form_request as m_is_form_request, + MultiDict, + MultipartPart +) + class Request: def __init__( @@ -24,8 +32,9 @@ class Request: self.query_params = [] self.server = server self.handler = handler # the view function that will be called - self.GET = {} - self.POST = {} + self.GET = MultiDict() + self.POST = MultiDict() + self.FILES = MultiDict() self.META = {} self.COOKIES = {} # only used for the session middleware @@ -39,10 +48,26 @@ class Request: self.populate_cookies() content_length = int(self.headers.get("content_length") or 0) - if content_length: - self.content = ( - self.environ["wsgi.input"].read(content_length).decode(DEFAULT_ENCODING) - ) + if self.is_form_request(): + if self.method == "POST": + # this pulls from wsgi.input, so we don't have to do it ourselves + self.POST, self.FILES = parse_form_data(self.environ) + for key, value in self.FILES.items(): + if isinstance(value, MultipartPart): + self.FILES[key] = MediaFile(self.server, value) + else: + if content_length: + self.content = ( + self.environ["wsgi.input"].read(content_length).decode(DEFAULT_ENCODING) + ) + self.GET.update(parse_qs(self.content)) + + for group in (self.GET, self.POST): + for key, value in group.items(): + if len(value) == 1 and ( + isinstance(value, list) or isinstance(value, tuple) + ): + group[key] = value[0] def populate_headers(self) -> None: data = self.headers @@ -90,7 +115,4 @@ class Request: return json.loads(self.content) def is_form_request(self) -> bool: - return ( - "content_type" in self.headers - and self.headers["content_type"] == "application/x-www-form-urlencoded" - ) + return m_is_form_request(self.environ) diff --git a/spiderweb/response.py b/spiderweb/response.py index 44ffa0b..ffbc331 100644 --- a/spiderweb/response.py +++ b/spiderweb/response.py @@ -12,6 +12,8 @@ from spiderweb.exceptions import GeneralException from spiderweb.request import Request from spiderweb.utils import Headers +from multipart import MultiDict + mimetypes.init() @@ -122,6 +124,8 @@ class JsonResponse(HttpResponse): self.headers["content-type"] = "application/json" def render(self) -> str: + if isinstance(self.data, MultiDict): + self.data = self.data.dict return json.dumps(self.data) diff --git a/spiderweb/tests/test_middleware.py b/spiderweb/tests/test_middleware.py index 1914eac..1ba6a27 100644 --- a/spiderweb/tests/test_middleware.py +++ b/spiderweb/tests/test_middleware.py @@ -300,7 +300,7 @@ def test_csrf_trusted_origins(): environ["HTTP_ORIGIN"] = "example.com" resp2 = app(environ, start_response)[0].decode(DEFAULT_ENCODING) - assert resp2 == '{"name": "bob"}' + assert resp2 == '{"name": ["bob"]}' def test_post_process_middleware(): diff --git a/templates/file_upload.html b/templates/file_upload.html new file mode 100644 index 0000000..f37d3ec --- /dev/null +++ b/templates/file_upload.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+ + +
+ {{ csrf_token }} + + +
+{% endblock %} \ No newline at end of file