From e2217fbc1d38fe2a1743dcf1a5079868bf075c85 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 1 Oct 2023 03:39:06 +0200 Subject: [PATCH] add basic upload/download --- .env.example | 19 ++++ .gitignore | 5 +- Dockerfile | 4 +- LICENSE | 201 ++++++++++++++++++++++++++++++++++ README.md | 11 +- data/.gitkeep | 0 entry.sh | 4 +- poetry.lock | 177 +++++++++++++++++++++++++++++- pyles/__init__.py | 2 + pyles/app.py | 28 ++++- pyles/blueprints/__init__.py | 3 + pyles/blueprints/api/files.py | 35 ++++++ pyles/db.py | 133 ++++++++++++++++++++++ pyles/files.py | 66 +++++++++++ pyles/settings.py | 14 +++ pyles/user.py | 29 +++++ pyproject.toml | 4 + 17 files changed, 723 insertions(+), 12 deletions(-) create mode 100644 LICENSE create mode 100644 data/.gitkeep create mode 100644 pyles/blueprints/__init__.py create mode 100644 pyles/blueprints/api/files.py create mode 100644 pyles/files.py create mode 100644 pyles/user.py diff --git a/.env.example b/.env.example index ee98a31..f669d9a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,20 @@ +# The number of workers the server will use. Flask recommends (cpu count * 2) as a baseline. WORKERS=4 +# The secret key used for tokens. You can generate one with (for example) `openssl rand -base64 48`. +SECRET_KEY=change-me-insecure!-Fj+4Y8afr3TzLpG1bkSYQxEVrhGPr5nokxBs9JPxfuvv +# The database file used +DATABASE=data/db.sqlite + +# The base URL used for uploaded files. +# This should include the schema (http:// or https://) and not have a trailing slash. +BASE_URL=http://localhost:5000 + +# The storage backend used, can be 'local' or 's3' +STORAGE_BACKEND=local +# The directory files are uploaded to with the local setting, relative to the working directory. +STORAGE_LOCAL_DIR=data/uploads + +# The maximum uploaded file size, in megabytes. Setting this to 0 or a negative number allows unlimited file sizes. +MAX_FILE_SIZE=15 +# The maximum content length Flask will accept. This should generally be slightly higher than MAX_FILE_SIZE. +MAX_CONTENT_LENGTH=16 diff --git a/.gitignore b/.gitignore index f285146..340cf07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env -data/ +data/* +!data/.gitkeep .vscode -__pycache__ \ No newline at end of file +__pycache__ diff --git a/Dockerfile b/Dockerfile index 46408ce..57e160f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ ENV POETRY_HOME=/opt/poetry ENV POETRY_VENV=/opt/poetry-venv ENV POETRY_CACHE_DIR=/opt/.cache -RUN apk add --no-cache tini +RUN apk add --no-cache tini libmagic FROM python-base as poetry-base @@ -26,4 +26,4 @@ RUN poetry install --no-interaction --no-cache --without dev COPY . /app ENTRYPOINT ["/sbin/tini", "--"] -CMD ["sh", "./entry.sh"] \ No newline at end of file +CMD ["sh", "./entry.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 1926d73..46d8fc2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # pyles -(pun on *py*thon and fi*les*) +(pun on *py*thon and fi*les*) (it's not a very good pun) + +# Requirements + +- `libmagic` (`libmagic` on Alpine, `libmagic1` on Debian) for file type identification + +# License + +Licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0), +found in the LICENSE file in this repository. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/entry.sh b/entry.sh index 89da07d..81e8862 100644 --- a/entry.sh +++ b/entry.sh @@ -1,4 +1,4 @@ #!/bin/sh source .env -echo "poetry run gunicorn --workers=${WORKERS:-2} 'pyles:app'" -poetry run gunicorn --workers=${WORKERS:-2} 'pyles:app' +echo "poetry run gunicorn --workers=${WORKERS:-2} --bind=0.0.0.0:8000 'pyles:app'" +poetry run gunicorn --workers=${WORKERS:-2} --bind=0.0.0.0:8000 'pyles:app' diff --git a/poetry.lock b/poetry.lock index d63e149..41db64b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,64 @@ # This file is automatically @generated by Poetry and should not be changed by hand. +[[package]] +name = "argon2-cffi" +version = "23.1.0" +description = "Argon2 for Python" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[package.extras] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +description = "Low-level CFFI bindings for Argon2" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, + {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, +] + +[package.dependencies] +cffi = ">=1.0.1" + +[package.extras] +dev = ["cogapp", "pre-commit", "pytest", "wheel"] +tests = ["pytest"] + [[package]] name = "black" version = "23.9.1" @@ -57,6 +116,71 @@ files = [ {file = "blinker-1.6.2.tar.gz", hash = "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213"}, ] +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.7" @@ -307,6 +431,33 @@ files = [ {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] +[[package]] +name = "peewee" +version = "3.16.3" +description = "a little orm" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.16.3.tar.gz", hash = "sha256:12b30e931193bc37b11f7c2ac646e3f67125a8b1a543ad6ab37ad124c8df7d16"}, +] + +[[package]] +name = "peewee-migrate" +version = "1.12.2" +description = "Support for migrations in Peewee ORM" +category = "main" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "peewee_migrate-1.12.2-py3-none-any.whl", hash = "sha256:2930bf83ef802cdb5fb123116c5eb87cbf3756cb27674f674923be6bb27dabee"}, + {file = "peewee_migrate-1.12.2.tar.gz", hash = "sha256:c8187c97b756909ea57e77cce06ae66395219e86764ef0b286a7bc72ff7405ad"}, +] + +[package.dependencies] +click = "*" +peewee = ">=3,<4" + [[package]] name = "platformdirs" version = "3.10.0" @@ -323,6 +474,18 @@ files = [ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "python-dotenv" version = "1.0.0" @@ -338,6 +501,18 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-magic" +version = "0.4.27" +description = "File type identification using libmagic" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b"}, + {file = "python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3"}, +] + [[package]] name = "werkzeug" version = "2.3.7" @@ -359,4 +534,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "29a9314d17536581f56eec94d6d7b60f0f2a0cf5f401e2c3c149cf6067656a0e" +content-hash = "a8a740d71fe53f39a3e5d8ff069560abfb1e99db162b77892eb16cd0b34bda9d" diff --git a/pyles/__init__.py b/pyles/__init__.py index c07c459..5af2281 100644 --- a/pyles/__init__.py +++ b/pyles/__init__.py @@ -1 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 + from .app import app diff --git a/pyles/app.py b/pyles/app.py index a72e893..aefe13c 100644 --- a/pyles/app.py +++ b/pyles/app.py @@ -1,8 +1,28 @@ -from flask import Flask +# SPDX-License-Identifier: Apache-2.0 + +from flask import Flask, make_response + +from pyles.settings import SECRET_KEY +from pyles.blueprints import files_api +from pyles.files import get_file_by_hash +from pyles.db import File app = Flask(__name__) +app.secret_key = SECRET_KEY + +app.register_blueprint(files_api) -@app.route("/") -def hello_world(): - return {"data": "hello world!"} +@app.route("/.") +def download_file(file_id: str, extension: str): + file = File.get_or_none(url_id=file_id) + if not file: + return ("", 404) + + data = get_file_by_hash(file.hash, file.content_type) + resp = make_response(data) + resp.status = 200 + resp.content_type = file.content_type + resp.content_length = len(data) + + return resp diff --git a/pyles/blueprints/__init__.py b/pyles/blueprints/__init__.py new file mode 100644 index 0000000..f5c4fe0 --- /dev/null +++ b/pyles/blueprints/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 + +from .api.files import bp as files_api diff --git a/pyles/blueprints/api/files.py b/pyles/blueprints/api/files.py new file mode 100644 index 0000000..3beb20b --- /dev/null +++ b/pyles/blueprints/api/files.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 + +from flask import Blueprint, g, request, jsonify +from werkzeug.utils import secure_filename + +from pyles.db import File +from pyles.files import upload_file +from pyles.user import token_required +from pyles.settings import BASE_URL + +bp = Blueprint("files_api", __name__) + + +@bp.post("/upload") +@token_required +def upload(): + file = request.files.get("file") + if not file: + return jsonify({"error": "Missing file"}), 400 + + hash, content_type = upload_file(file) + db_file: File = File.create( + user=g.user, + filename=secure_filename(file.filename), + hash=hash, + content_type=content_type, + ) + + return jsonify( + { + "id": db_file.id, + "hash": db_file.hash, + "url": f"{BASE_URL}/{db_file.path}", + } + ) diff --git a/pyles/db.py b/pyles/db.py index e69de29..b631730 100644 --- a/pyles/db.py +++ b/pyles/db.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import os +import uuid +import mimetypes +from datetime import datetime, timezone + +import peewee +from peewee_migrate import Router +from playhouse.pool import PooledSqliteDatabase +from argon2 import PasswordHasher +from itsdangerous.url_safe import URLSafeSerializer + +from pyles.settings import DATABASE, SECRET_KEY + +db = PooledSqliteDatabase( + DATABASE, + max_connections=30, + stale_timeout=300, +) +db.connect() + +migrations = Router(db) + +ph = PasswordHasher() + + +class BaseModel(peewee.Model): + class Meta: + database = db + + +class User(BaseModel): + id = peewee.UUIDField(default=uuid.uuid4, primary_key=True) + username = peewee.CharField(max_length=40, unique=True) + is_admin = peewee.BooleanField(null=False, default=False) + password = peewee.CharField() + salt = peewee.CharField() + + @classmethod + def new(cls, username: str, password: str, is_admin=False) -> "User": + return cls.create( + username=username, + password=ph.hash(password), + salt=os.urandom(16).hex(), + is_admin=is_admin, + ) + + def set_password(self, password: str): + """Sets a password, but doesn't save it. + This also changes the user's token salt.""" + + self.password = ph.hash(password) + self.salt = os.urandom(16).hex() + + return self + + def verify_password(self, password: str): + """Checks the user's password. + Returns True if the password is valid, False otherwise. + Does not raise an error.""" + + try: + return ph.verify(self.password, password) + except: + return False + + def get_token(self): + if not self.salt: + self.salt = os.urandom(16).hex() + self.save() + + s = URLSafeSerializer(SECRET_KEY, salt=self.salt) + return s.dumps(str(self.id)) + + def verify_token(self, token: str): + s = URLSafeSerializer(SECRET_KEY, salt=self.salt) + + try: + value = s.loads(token) + return value == str(self.id) + except: + return False + + +def random_url_id(): + return base64.b64encode(os.urandom(12), altchars=b"bB").decode("utf-8") + + +class File(BaseModel): + id = peewee.AutoField() + url_id = peewee.CharField(null=False, unique=True, default=random_url_id) + filename = peewee.CharField(null=False) + hash = peewee.CharField(null=False) + content_type = peewee.CharField(null=False) + created_at = peewee.DateTimeField( + null=False, default=lambda: datetime.now(tz=timezone.utc) + ) + expires_at = peewee.DateTimeField(null=True) + + user = peewee.ForeignKeyField(User) + + @property + def path(self): + ext = mimetypes.guess_extension(self.content_type, strict=False) + if not ext: + ext = "" + + return f"{self.url_id}{ext}" + + +class Tag(BaseModel): + id = peewee.AutoField() + name = peewee.CharField() + + user = peewee.ForeignKeyField(User) + + +class FileTag(BaseModel): + file = peewee.ForeignKeyField(File) + tag = peewee.ForeignKeyField(Tag) + + +with db: + db.create_tables([User, File, Tag, FileTag]) + +migrations.migrator.create_model(User) +migrations.migrator.create_model(File) +migrations.migrator.create_model(Tag) +migrations.migrator.create_model(FileTag) + +migrations.run() diff --git a/pyles/files.py b/pyles/files.py new file mode 100644 index 0000000..71f01ce --- /dev/null +++ b/pyles/files.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path +import mimetypes +import hashlib +import os + +import magic +from werkzeug.datastructures import FileStorage + +from pyles.settings import STORAGE_BACKEND, STORAGE_LOCAL_DIR + + +if STORAGE_BACKEND == "local": + path = Path(".") / STORAGE_LOCAL_DIR + os.makedirs(path, exist_ok=True) + + +def _local_file_path(hash: str, content_type: str): + ext = mimetypes.guess_extension(content_type, strict=False) + if not ext: + raise ValueError("Extension couldn't be guessed") + + return Path(".") / STORAGE_LOCAL_DIR / f"{hash}{ext}" + + +def get_file_by_hash(hash: str, content_type: str): + match STORAGE_BACKEND: + case "local": + return _local_get_file_by_hash(hash, content_type) + case "s3": + raise NotImplementedError() + + +def upload_file(file: FileStorage) -> tuple[str, str]: + """Upload a file. + The first return argument is the hash, the second return argument is the file's MIME type. + """ + + match STORAGE_BACKEND: + case "local": + return _local_upload_file(file) + case "s3": + raise NotImplementedError() + + +def _local_get_file_by_hash(hash: str, content_type: str): + p = _local_file_path(hash, content_type) + with p.open("rb") as f: + return f.read() + + +def _local_upload_file(file: FileStorage) -> tuple[str, str]: + """Uploads `file` to local storage. + The first return argument is the hash, the second return argument is the file's MIME type. + """ + + stream = file.stream.read() + file_type = magic.from_buffer(stream[:2048], mime=True) + hash = hashlib.sha256(stream).hexdigest() + p = _local_file_path(hash, file_type) + + with p.open("wb") as f: + f.write(stream) + + return (hash, file_type) diff --git a/pyles/settings.py b/pyles/settings.py index d48cf2e..a70d494 100644 --- a/pyles/settings.py +++ b/pyles/settings.py @@ -1,6 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 + from environs import Env +from typing import Literal env = Env() env.read_env() DATABASE: str = env("DATABASE") +SECRET_KEY: str = env("SECRET_KEY") + +BASE_URL: str = env("BASE_URL") + +MAX_FILE_SIZE: int = env.int("MAX_FILE_SIZE", 15) +MAX_CONTENT_LENGTH: int = env.int("MAX_CONTENT_LENGTH", 16) + +STORAGE_BACKEND: Literal["local", "s3"] = env( + "STORAGE_BACKEND", "local", validate=lambda s: s == "local" or s == "s3" +) +STORAGE_LOCAL_DIR: str = env("STORAGE_LOCAL_DIR", "data/uploads") diff --git a/pyles/user.py b/pyles/user.py new file mode 100644 index 0000000..3a3ffff --- /dev/null +++ b/pyles/user.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 + +from functools import wraps + +from itsdangerous.url_safe import URLSafeSerializer +from flask import g, request, redirect, url_for, jsonify + +from pyles.settings import SECRET_KEY +from pyles.db import User + + +def token_required(f): + @wraps(f) + def inner(*args, **kwargs): + token = request.headers.get("Authorization") + if not token: + return jsonify({"error": "Missing token"}), 403 + + _, id = URLSafeSerializer(SECRET_KEY).loads_unsafe(token) + u: User = User.get_or_none(id=id) + if u is None: + return jsonify({"error": "Invalid token"}), 403 + + if not u.verify_token(token): + return jsonify({"error": "Invalid token"}), 403 + g.user = u + return f(*args, **kwargs) + + return inner diff --git a/pyproject.toml b/pyproject.toml index 294f2ea..137e895 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ flask = "^2.3.3" itsdangerous = "^2.1.2" environs = "^9.5.0" gunicorn = "^21.2.0" +peewee = "^3.16.3" +argon2-cffi = "^23.1.0" +peewee-migrate = "^1.12.2" +python-magic = "^0.4.27" [tool.poetry.group.dev.dependencies]