├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── Makefile ├── README.rst ├── demo ├── README.rst ├── index.html └── script.js ├── pyproject.toml ├── requirements.in ├── requirements.txt ├── scripts ├── create_account.py ├── delete.py ├── download.py ├── requirements.txt └── upload.py ├── src └── kinto_attachment │ ├── __init__.py │ ├── listeners.py │ ├── utils.py │ └── views.py └── tests ├── __init__.py ├── config ├── events.ini ├── functional.ini ├── local.ini ├── s3.ini └── s3_per_resource.ini ├── test_events.py ├── test_plugin_setup.py ├── test_utils.py └── test_views_attachment.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 99 8 | groups: 9 | all-dependencies: 10 | update-types: ["major", "minor", "patch"] 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: weekly 15 | open-pull-requests-limit: 99 16 | groups: 17 | all-dependencies: 18 | update-types: ["major", "minor", "patch"] 19 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | categories: 6 | - title: Breaking Changes 7 | labels: 8 | - "breaking-change" 9 | - title: Bug Fixes 10 | labels: 11 | - "bug" 12 | - title: New Features 13 | labels: 14 | - "enhancement" 15 | - title: Documentation 16 | labels: 17 | - "documentation" 18 | - title: Dependency Updates 19 | labels: 20 | - "dependencies" 21 | - title: Other Changes 22 | labels: 23 | - "*" 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | 21 | - name: Print environment 22 | run: | 23 | python --version 24 | 25 | - name: Install pypa/build 26 | run: python3 -m pip install build 27 | 28 | - name: Build a binary wheel and a source tarball 29 | run: python3 -m build 30 | 31 | - name: Store the distribution packages 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: python-package-distributions 35 | path: dist/ 36 | 37 | publish-to-pypi: 38 | name: Publish Python 🐍 distribution 📦 to PyPI 39 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 40 | needs: 41 | - build 42 | runs-on: ubuntu-latest 43 | environment: 44 | name: release 45 | url: https://pypi.org/p/kinto-attachment 46 | permissions: 47 | id-token: write 48 | steps: 49 | - name: Download all the dists 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: python-package-distributions 53 | path: dist/ 54 | - name: Publish distribution 📦 to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run CI checks 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - uses: actions/setup-python@v5 12 | 13 | - name: Run linting and formatting checks 14 | run: make lint 15 | 16 | unit-tests: 17 | runs-on: ubuntu-latest 18 | needs: lint 19 | strategy: 20 | matrix: 21 | python-version: ["3.9", "3.10", "3.11"] 22 | services: 23 | postgres: 24 | image: postgres 25 | env: 26 | POSTGRES_PASSWORD: postgres 27 | options: >- 28 | --health-cmd pg_isready 29 | --health-interval 10s 30 | --health-timeout 5s 31 | --health-retries 5 32 | ports: 33 | - 5432:5432 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Create database 38 | env: 39 | PGPASSWORD: postgres 40 | run: | 41 | psql -c "CREATE DATABASE testdb ENCODING 'UTF8' TEMPLATE template0;" -U postgres -h localhost 42 | 43 | - name: Set up Python ${{ matrix.python-version }} 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | cache: pip 48 | cache-dependency-path: pyproject.toml 49 | 50 | - name: Install dependencies 51 | run: make install 52 | 53 | - name: Run moto 54 | run: | 55 | make run-moto & 56 | 57 | - name: Run unit tests 58 | run: make test 59 | 60 | functional-tests: 61 | runs-on: ubuntu-latest 62 | needs: lint 63 | services: 64 | postgres: 65 | image: postgres 66 | env: 67 | POSTGRES_PASSWORD: postgres 68 | options: >- 69 | --health-cmd pg_isready 70 | --health-interval 10s 71 | --health-timeout 5s 72 | --health-retries 5 73 | ports: 74 | - 5432:5432 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - uses: actions/setup-python@v5 79 | with: 80 | python-version: "3.11" 81 | 82 | - name: Create database 83 | env: 84 | PGPASSWORD: postgres 85 | run: | 86 | psql -c "CREATE DATABASE testdb ENCODING 'UTF8' TEMPLATE template0;" -U postgres -h localhost 87 | 88 | - name: Install dependencies 89 | run: make install 90 | 91 | - name: Run moto 92 | run: | 93 | make run-moto & 94 | 95 | - name: Run kinto 96 | run: make run-kinto & sleep 5 97 | 98 | - name: Run functional tests 99 | run: make functional 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | .venv/ 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | >= 6.4.0 5 | -------- 6 | 7 | Since version 6.4.0, we use `Github releases `_ and autogenerated changelogs. 8 | 9 | 10 | 6.3.2 (2023-11-14) 11 | ------------------ 12 | 13 | **Bug fixes** 14 | 15 | - Catch TypeError when data/permissions are passed as files (#685) 16 | 17 | **Documentation** 18 | 19 | - Fix #617: document release process (#691) 20 | 21 | 22 | 6.3.1 (2022-10-17) 23 | ------------------ 24 | 25 | - Remove upper bound for kinto version (#567) 26 | 27 | 28 | 6.3.0 (2022-04-20) 29 | ------------------ 30 | 31 | **New features** 32 | 33 | - Include the Google Cloud backend automatically when ``kinto.attachment.gcloud.*`` settings are used. 34 | 35 | 36 | 6.2.0 (2021-12-02) 37 | ------------------ 38 | 39 | **New features** 40 | 41 | - ``base_url`` field in server's capabilities will be added a trailing slash (``/``) 42 | if missing. 43 | 44 | 45 | 6.1.0 (2020-06-23) 46 | ------------------ 47 | 48 | **New features** 49 | 50 | - Allow to override mimetypes from config (#315) 51 | 52 | 53 | 6.0.4 (2020-06-04) 54 | ------------------ 55 | 56 | **Bug fixes** 57 | 58 | - Add missing content type when uploading to S3 59 | 60 | 61 | 6.0.3 (2020-03-30) 62 | ------------------ 63 | 64 | **Bug fixes** 65 | 66 | - Fix broken compatibility with Kinto 13.6.4 67 | 68 | 69 | 6.0.2 (2019-11-13) 70 | ------------------ 71 | 72 | **Bug fixes** 73 | 74 | - Fix attachment deletion not being committed (fixes #149) 75 | 76 | **Internal changes** 77 | 78 | - Use ``unittest.mock`` instead of the ``mock`` library 79 | 80 | 6.0.1 (2018-12-19) 81 | ------------------ 82 | 83 | **Bug fixes** 84 | 85 | - Fix support of kinto >= 12 86 | 87 | 6.0.0 (2018-10-02) 88 | ------------------ 89 | 90 | **Breaking changes** 91 | 92 | - Do not allow any file extension by default. Now allow documents+images+text+data (Fix #130) 93 | 94 | **Bug fixes** 95 | 96 | - Fix heartbeat when allowed file types is not ``any`` (Fix #148) 97 | 98 | 99 | 5.0.0 (2018-07-31) 100 | ------------------ 101 | 102 | **Breaking changes** 103 | 104 | - Gzip ``Content-Encoding`` is not used anymore when uploading on S3 (fixes #144) 105 | 106 | **Internal changes** 107 | 108 | - Heartbeat now uses ``utils.save_file()`` for better detection of configuration or deployment errors (fixes #146) 109 | 110 | 111 | 4.0.0 (2018-07-24) 112 | ------------------ 113 | 114 | **Breaking changes** 115 | 116 | - Gzip ``Content-Encoding`` is now always enabled when uploading on S3 (fixes #139) 117 | - Overriding settings via the querystring (eg. ``?gzipped``, ``randomize``, ``use_content_encoding``) is not possible anymore 118 | 119 | **Internal changes** 120 | 121 | - Refactor reading of settings 122 | 123 | 3.0.1 (2018-07-05) 124 | ------------------ 125 | 126 | **Bug fix** 127 | 128 | - Do not delete attachment when record is deleted if ``keep_old_files`` setting is true (#137) 129 | 130 | 131 | 3.0.0 (2018-04-10) 132 | ------------------ 133 | 134 | **Breaking changes** 135 | 136 | - The collection specific ``use_content_encoding`` setting must now be separated with ``.`` instead of ``_``. 137 | (eg. use ``kinto.attachment.resources.bid.cid.use_content_encoding`` instead of ``kinto.attachment.resources.bid_cid.use_content_encoding``) (fixes #134) 138 | 139 | 140 | 2.1.0 (2017-12-06) 141 | ------------------ 142 | 143 | **New features** 144 | 145 | - Add support for the ``Content-Encoding`` header with the S3Backend (#132) 146 | 147 | 148 | 2.0.1 (2017-04-06) 149 | ------------------ 150 | 151 | **Bug fixes** 152 | 153 | - Set request parameters before instantiating a record resource. (#127) 154 | 155 | 156 | 2.0.0 (2017-03-03) 157 | ------------------ 158 | 159 | **Breaking changes** 160 | 161 | - Remove Python 2.7 support and upgrade to Python 3.5. (#125) 162 | 163 | 164 | 1.1.2 (2017-02-01) 165 | ------------------ 166 | 167 | **Bug fixes** 168 | 169 | - Fix invalid request when attaching a file on non UUID record id (fixes #122) 170 | 171 | 172 | 1.1.1 (2017-02-01) 173 | ------------------ 174 | 175 | **Bug fixes** 176 | 177 | - Fixes compatibility with Kinto 5.3 (fixes #120) 178 | 179 | 180 | 1.1.0 (2016-12-16) 181 | ------------------ 182 | 183 | - Expose the gzipped settings value in the capability (#117) 184 | 185 | 186 | 1.0.1 (2016-11-04) 187 | ------------------ 188 | 189 | **Bug fixes** 190 | 191 | - Make kinto-attachment compatible with both cornice 1.x and 2.x (#115) 192 | 193 | 194 | 1.0.0 (2016-09-07) 195 | ------------------ 196 | 197 | **Breaking change** 198 | 199 | - Remove the ``base_url`` from the public settings because the 200 | accurate value is in the capability. 201 | 202 | **Protocol** 203 | 204 | - Add the plugin version in the capability. 205 | 206 | 207 | 0.8.0 (2016-07-18) 208 | ------------------ 209 | 210 | **New features** 211 | 212 | - Prevent ``attachment`` attributes to be modified manually (fixes #83) 213 | 214 | **Bug fixes** 215 | 216 | - Fix crash when the file is not uploaded using ``attachment`` field name (fixes #57) 217 | - Fix crash when the multipart content-type is invalid. 218 | - Prevent crash when filename is not provided (fixes #81) 219 | - Update the call to the Record resource to use named attributes. (#97) 220 | - Show detailed error when data is not posted with multipart content-type. 221 | - Fix crash when submitted data is not valid JSON (fixes #104) 222 | 223 | **Internal changes** 224 | 225 | - Remove hard-coded CORS setup (fixes #59) 226 | 227 | 228 | 0.7.0 (2016-06-10) 229 | ------------------ 230 | 231 | - Add the gzip option to automatically gzip files on upload (#85) 232 | - Run functional test on latest kinto release as well as kinto master (#86) 233 | 234 | 235 | 0.6.0 (2016-05-19) 236 | ------------------ 237 | 238 | **Breaking changes** 239 | 240 | - Update to ``kinto.core`` for compatibility with Kinto 3.0. This 241 | release is no longer compatible with Kinto < 3.0, please upgrade! 242 | 243 | **New features** 244 | 245 | - Add a ``kinto.attachment.extra.base_url`` settings to be exposed publicly. (#73) 246 | 247 | 248 | 0.5.1 (2016-04-14) 249 | ------------------ 250 | 251 | **Bug fixes** 252 | 253 | - Fix MANIFEST.in rules 254 | 255 | 256 | 0.5.0 (2016-04-14) 257 | ------------------ 258 | 259 | **New features** 260 | 261 | - Add ability to disable filename randomization using a ``?randomize=false`` querystring (#62) 262 | - Add a ``--keep-filenames`` option in ``upload.py`` script to disable randomization (#63) 263 | 264 | **Bug fixes** 265 | 266 | - Fix a setting name for S3 bucket in README (#68) 267 | - Do nothing in heartbeat if server is readonly (fixes #69) 268 | 269 | **Internal changes** 270 | 271 | - Big refactor of views (#61) 272 | 273 | 274 | 0.4.0 (2016-03-09) 275 | ------------------ 276 | 277 | **New features** 278 | 279 | - Previous files can be kept if the setting ``kinto.keep_old_files`` is set 280 | to ``true``. This can be useful when clients try to download files from a 281 | collection of records that is not up-to-date. 282 | - Add heartbeat entry for attachments backend (#41) 283 | 284 | **Bug fixes** 285 | 286 | - Now compatible with the default bucket (#42) 287 | - Now compatible with Python 3 (#44) 288 | 289 | **Internal changes** 290 | 291 | - Upload/Download scripts now use ``kinto.py`` (#38) 292 | 293 | 294 | 0.3.0 (2016-02-05) 295 | ------------------ 296 | 297 | **New feature** 298 | 299 | - Expose the API capability ``attachments`` in the root URL (#35) 300 | 301 | **Internal changes** 302 | 303 | - Upgrade tests for Kinto 1.11.0 (#36) 304 | 305 | 306 | 0.2.0 (2015-12-21) 307 | ------------------ 308 | 309 | **New feature** 310 | 311 | - Setting to store files into folders by bucket or collection (fixes #22) 312 | 313 | **Bug fixes** 314 | 315 | - Remove existing file when attachment is replaced (fixes #28) 316 | 317 | **Documentation** 318 | 319 | - The demo is now fully online, since the Mozilla demo server has this plugin 320 | installed. 321 | - Add some minimal information for production 322 | 323 | 324 | 0.1.0 (2015-12-02) 325 | ------------------ 326 | 327 | * Initial working proof-of-concept. 328 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 Mozilla Foundation 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV := $(shell echo $${VIRTUAL_ENV-$$PWD/.venv}) 2 | INSTALL_STAMP = $(VENV)/.install.stamp 3 | TEMPDIR := $(shell mktemp -d) 4 | PYTHON ?= python3 5 | 6 | .IGNORE: clean 7 | .PHONY: all install virtualenv tests tests-once 8 | 9 | OBJECTS = .venv .coverage 10 | 11 | all: install 12 | 13 | $(VENV)/bin/python: 14 | $(PYTHON) -m venv $(VENV) 15 | 16 | install: $(INSTALL_STAMP) pyproject.toml requirements.txt 17 | $(INSTALL_STAMP): $(VENV)/bin/python pyproject.toml requirements.txt 18 | $(VENV)/bin/pip install -U pip 19 | $(VENV)/bin/pip install -r requirements.txt 20 | $(VENV)/bin/pip install -e ".[dev,monitoring]" 21 | touch $(INSTALL_STAMP) 22 | 23 | lint: install 24 | $(VENV)/bin/ruff check src tests 25 | $(VENV)/bin/ruff format --check src tests 26 | 27 | format: install 28 | $(VENV)/bin/ruff check --fix src tests 29 | $(VENV)/bin/ruff format src tests 30 | 31 | requirements.txt: requirements.in 32 | pip-compile requirements.in 33 | 34 | tests: test 35 | tests-once: test 36 | test: install 37 | $(VENV)/bin/py.test --cov-report term-missing --cov-fail-under 100 --cov kinto_attachment 38 | 39 | clean: 40 | find src/ -name '*.pyc' -delete 41 | find src/ -name '__pycache__' -type d -exec rm -fr {} \; 42 | rm -rf $(OBJECTS) *.egg-info .pytest_cache .ruff_cache build dist 43 | 44 | run-kinto: install 45 | $(VENV)/bin/python -m http.server -d $(TEMPDIR) 8000 & 46 | $(VENV)/bin/kinto migrate --ini tests/config/functional.ini 47 | $(VENV)/bin/kinto start --ini tests/config/functional.ini 48 | 49 | need-kinto-running: 50 | @curl http://localhost:8888/v0/ 2>/dev/null 1>&2 || (echo "Run 'make run-kinto' before starting tests." && exit 1) 51 | 52 | run-moto: install 53 | $(VENV)/bin/moto_server -H 0.0.0.0 -p 6000 54 | 55 | need-moto-running: 56 | @curl http://localhost:6000 2>/dev/null 1>&2 || (echo "Run 'make run-moto' before starting tests." && exit 1) 57 | 58 | functional: install need-kinto-running need-moto-running 59 | /usr/bin/openssl rand -base64 -out $(TEMPDIR)/image1.png 3000 60 | /usr/bin/openssl rand -base64 -out $(TEMPDIR)/image2.png 3000 61 | /usr/bin/openssl rand -base64 -out $(TEMPDIR)/image3.png 3000 62 | $(VENV)/bin/python scripts/create_account.py --server=http://localhost:8888/v1 --auth=my-user:my-secret 63 | $(VENV)/bin/python scripts/upload.py --server=http://localhost:8888/v1 --bucket=services --collection=logs --auth=my-user:my-secret $(TEMPDIR)/image1.png $(TEMPDIR)/image2.png $(TEMPDIR)/image3.png 64 | $(VENV)/bin/python scripts/download.py --server=http://localhost:8888/v1 --bucket=services --collection=logs --auth=my-user:my-secret -f $(TEMPDIR) 65 | $(VENV)/bin/python scripts/delete.py --server=http://localhost:8888/v1 --bucket=services --collection=logs --auth=my-user:my-secret 66 | $(VENV)/bin/python scripts/upload.py --server=http://localhost:8888/v1 --bucket=services --collection=app --auth=my-user:my-secret $(TEMPDIR)/image1.png $(TEMPDIR)/image2.png $(TEMPDIR)/image3.png 67 | $(VENV)/bin/python scripts/download.py --server=http://localhost:8888/v1 --bucket=services --collection=app --auth=my-user:my-secret -f $(TEMPDIR)/kintoapp 68 | /bin/rm $(TEMPDIR)/image1.png $(TEMPDIR)/image2.png $(TEMPDIR)/image3.png 69 | /bin/rm -rf $(TEMPDIR)/kinto* 70 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Kinto Attachment 3 | ================ 4 | 5 | .. image:: https://github.com/Kinto/kinto-attachment/actions/workflows/test.yml/badge.svg 6 | :target: https://github.com/Kinto/kinto-attachment/actions 7 | 8 | .. image:: https://img.shields.io/pypi/v/kinto-attachment.svg 9 | :target: https://pypi.python.org/pypi/kinto-attachment 10 | 11 | Attach files to `Kinto records `_. 12 | 13 | 14 | Install 15 | ======= 16 | 17 | :: 18 | 19 | pip install kinto-attachment 20 | 21 | 22 | Setup 23 | ===== 24 | 25 | In the Kinto project settings 26 | 27 | .. code-block:: ini 28 | 29 | kinto.includes = kinto_attachment 30 | kinto.attachment.base_url = http://cdn.service.org/files/ 31 | 32 | 33 | Local File storage 34 | ------------------ 35 | 36 | Store files locally: 37 | 38 | .. code-block:: ini 39 | 40 | kinto.attachment.base_path = /tmp 41 | 42 | 43 | S3 File Storage 44 | --------------- 45 | 46 | Store on Amazon S3: 47 | 48 | .. code-block:: ini 49 | 50 | kinto.attachment.aws.access_key = 51 | kinto.attachment.aws.secret_key = 52 | kinto.attachment.aws.bucket_name = 53 | kinto.attachment.aws.acl = 54 | 55 | 56 | .. note:: 57 | 58 | ``access_key`` and ``secret_key`` may be omitted when using AWS Identity 59 | and Access Management (IAM). 60 | 61 | See `Pyramid Storage `_. 62 | 63 | 64 | Google Cloud Storage 65 | -------------------- 66 | 67 | .. code-block:: ini 68 | 69 | kinto.attachment.gcloud.credentials = 70 | kinto.attachment.gcloud.bucket_name = 71 | kinto.attachment.gcloud.acl = publicRead 72 | 73 | See `Google Cloud ACL permissions `_ 74 | 75 | 76 | The ``folder`` option 77 | --------------------- 78 | 79 | With this option, the files will be stored in sub-folders. 80 | 81 | Use the ``{bucket_id}`` and ``{collection_id}`` placeholders to organize the files 82 | by bucket or collection. 83 | 84 | .. code-block:: ini 85 | 86 | kinto.attachment.folder = {bucket_id}/{collection_id} 87 | 88 | Or only for a particular bucket: 89 | 90 | .. code-block:: ini 91 | 92 | kinto.attachment.resources.blog.folder = blog-assets 93 | 94 | Or a specific collection: 95 | 96 | .. code-block:: ini 97 | 98 | kinto.attachment.resources.blog.articles.folder = articles-images 99 | 100 | 101 | The ``keep_old_files`` option 102 | ----------------------------- 103 | 104 | When set to ``true``, the files won't be deleted from disk/S3 when the associated record 105 | is deleted or when the attachment replaced. 106 | 107 | .. code-block:: ini 108 | 109 | kinto.attachment.keep_old_files = true 110 | 111 | Or only for a particular bucket: 112 | 113 | .. code-block:: ini 114 | 115 | kinto.attachment.resources.blog.keep_old_files = false 116 | 117 | Or a specific collection: 118 | 119 | .. code-block:: ini 120 | 121 | kinto.attachment.resources.blog.articles.keep_old_files = true 122 | 123 | The ``randomize`` option 124 | ------------------------ 125 | 126 | If you want uploaded files to be stored with a random name (default: True): 127 | 128 | .. code-block:: ini 129 | 130 | kinto.attachment.randomize = true 131 | 132 | Or only for a particular bucket: 133 | 134 | .. code-block:: ini 135 | 136 | kinto.attachment.resources.blog.randomize = true 137 | 138 | Or a specific collection: 139 | 140 | .. code-block:: ini 141 | 142 | kinto.attachment.resources.blog.articles.randomize = true 143 | 144 | The ``extensions`` option 145 | ------------------------- 146 | 147 | If you want to upload files which are not in the default allowed extensions (see `Pyramid extensions groups `_ (default: ``default``): 148 | 149 | .. code-block:: ini 150 | 151 | kinto.attachment.extensions = default+video 152 | 153 | 154 | The ``mimetypes`` option 155 | ------------------------ 156 | 157 | By default, the mimetype is guessed from the filename using Python standard mimetypes module. 158 | 159 | If you want to add or override mimetypes, use the following setting and the associated syntax: 160 | 161 | .. code-block:: ini 162 | 163 | kinto.attachment.mimetypes = .ftl:application/vnd.fluent;.db:application/vnd.sqlite3 164 | 165 | 166 | Default bucket 167 | -------------- 168 | 169 | In order to upload files on the ``default`` bucket, the built-in default bucket 170 | plugin should be enabled before the ``kinto_attachment`` plugin. 171 | 172 | In the configuration, this means adding it explicitly to includes: 173 | 174 | .. code-block:: ini 175 | 176 | kinto.includes = kinto.plugins.default_bucket 177 | kinto_attachment 178 | 179 | Production 180 | ---------- 181 | 182 | * Make sure the ``base_url`` can be reached (and points to ``base_path`` if 183 | files are stored locally) 184 | * Adjust the max size for uploaded files (e.g. ``client_max_body_size 10m;`` for NGinx) 185 | 186 | For example, with NGinx 187 | 188 | :: 189 | 190 | server { 191 | listen 80; 192 | 193 | location /v1 { 194 | ... 195 | } 196 | 197 | location /files { 198 | root /var/www/kinto; 199 | } 200 | } 201 | 202 | 203 | API 204 | === 205 | 206 | **POST /{record-url}/attachment** 207 | 208 | It will create the underlying record if it does not exist. 209 | 210 | Required 211 | 212 | - ``attachment``: a single multipart-encoded file 213 | 214 | Optional 215 | 216 | - ``data``: attributes to set on record (serialized JSON) 217 | - ``permissions``: permissions to set on record (serialized JSON) 218 | 219 | 220 | **DELETE /{record-url}/attachment** 221 | 222 | Deletes the attachement from the record. 223 | 224 | 225 | Attributes 226 | ---------- 227 | 228 | When a file is attached, the related record is given an ``attachment`` attribute 229 | with the following fields: 230 | 231 | - ``filename``: the original filename 232 | - ``hash``: a SHA-256 hex digest 233 | - ``location``: the URL of the attachment 234 | - ``mimetype``: the `media type `_ of 235 | the file 236 | - ``size``: size in bytes 237 | 238 | .. code-block:: json 239 | 240 | { 241 | "data": { 242 | "attachment": { 243 | "filename": "IMG_20150219_174559.jpg", 244 | "hash": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", 245 | "location": "http://cdn.service.org/files/ffa9c7b9-7561-406b-b7f9-e00ac94644ff.jpg", 246 | "mimetype": "image/jpeg", 247 | "size": 1481798 248 | }, 249 | "id": "c2ce1975-0e52-4b2f-a5db-80166aeca688", 250 | "last_modified": 1447834938251, 251 | "theme": "orange", 252 | "type": "wallpaper" 253 | }, 254 | "permissions": { 255 | "write": ["basicauth:6de355038fd943a2dc91405063b91018bb5dd97a08d1beb95713d23c2909748f"] 256 | } 257 | } 258 | 259 | 260 | Usage 261 | ===== 262 | 263 | Using HTTPie 264 | ------------ 265 | 266 | .. code-block:: bash 267 | 268 | http --form POST http://localhost:8888/v1/buckets/website/collections/assets/records/c2ce1975-0e52-4b2f-a5db-80166aeca689/attachment \ 269 | data='{"type": "wallpaper", "theme": "orange"}' \ 270 | attachment"@~/Pictures/background.jpg" \ 271 | --auth alice:passwd 272 | 273 | .. code-block:: http 274 | 275 | HTTP/1.1 201 Created 276 | Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff 277 | Content-Length: 209 278 | Content-Type: application/json; charset=UTF-8 279 | Date: Wed, 18 Nov 2015 08:22:18 GMT 280 | Etag: "1447834938251" 281 | Last-Modified: Wed, 18 Nov 2015 08:22:18 GMT 282 | Location: http://localhost:8888/v1/buckets/website/collections/font/assets/c2ce1975-0e52-4b2f-a5db-80166aeca689 283 | Server: waitress 284 | 285 | { 286 | "filename": "IMG_20150219_174559.jpg", 287 | "hash": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", 288 | "location": "http://cdn.service.org/files/ffa9c7b9-7561-406b-b7f9-e00ac94644ff.jpg", 289 | "mimetype": "image/jpeg", 290 | "size": 1481798 291 | } 292 | 293 | In order to force a specific file attachment mimetype: 294 | 295 | .. code-block:: bash 296 | 297 | http -f POST $URL attachment"~/files/data.bin;type=application/pdf" 298 | 299 | 300 | Using cURL 301 | ---------- 302 | 303 | .. code-block:: bash 304 | 305 | curl -X POST ${SERVER}/buckets/${BUCKET}/collections/${COLLECTION}/records/${RECORD_ID}/attachment \ 306 | -H 'Content-Type:multipart/form-data' \ 307 | -F attachment="@$FILEPATH;type=application/x-protobuf" \ 308 | -F 'data={"name": "Mac Fly", "age": 42}' \ 309 | -H "Authorization: $STAGE_AUTH" 310 | 311 | 312 | Using Python requests 313 | --------------------- 314 | 315 | .. code-block:: python 316 | 317 | auth = ("alice", "passwd") 318 | attributes = {"type": "wallpaper", "theme": "orange"} 319 | perms = {"read": ["system.Everyone"]} 320 | 321 | files = [("attachment", ("background.jpg", open("Pictures/background.jpg", "rb"), "image/jpeg"))] 322 | 323 | payload = {"data": json.dumps(attributes), "permissions": json.dumps(perms)} 324 | response = requests.post(SERVER_URL + endpoint, data=payload, files=files, auth=auth) 325 | 326 | response.raise_for_status() 327 | 328 | 329 | Using JavaScript 330 | ---------------- 331 | 332 | .. code-block:: javascript 333 | 334 | var headers = {Authorization: "Basic " + btoa("alice:passwd")}; 335 | var attributes = {"type": "wallpaper", "theme": "orange"}; 336 | var perms = {"read": ["system.Everyone"]}; 337 | 338 | // File object from input field 339 | var filefield = form.elements.attachment.files[0]; 340 | // If necessary, force the file content-type: 341 | var file = new Blob([filefield], { type: "application/pdf" }); 342 | 343 | // Build form data 344 | var payload = new FormData(); 345 | // Multipart attachment 346 | payload.append('attachment', file, "background.jpg"); 347 | // Record attributes and permissions JSON encoded 348 | payload.append('data', JSON.stringify(attributes)); 349 | payload.append('permissions', JSON.stringify(perms)); 350 | 351 | // Post form using GlobalFetch API 352 | var url = `${server}/buckets/${bucket}/collections/${collection}/records/${record}/attachment`; 353 | fetch(url, {method: "POST", body: payload, headers: headers}) 354 | .then(function (result) { 355 | console.log(result); 356 | }); 357 | 358 | 359 | Scripts 360 | ======= 361 | 362 | Two scripts are provided in this repository. 363 | 364 | They rely on the ``kinto-client`` Python package, which can be installed in a 365 | virtualenv: 366 | 367 | :: 368 | 369 | $ virtualenv env --python=python3 370 | $ source env/bin/activate 371 | $ pip install kinto-client 372 | 373 | Or globally on your system (**not recommended**): 374 | 375 | :: 376 | 377 | $ sudo pip install kinto-client 378 | 379 | 380 | Upload files 381 | ------------ 382 | 383 | ``upload.py`` takes a list of files and posts them on the specified server, 384 | bucket and collection:: 385 | 386 | $ python3 scripts/upload.py --server=$SERVER --bucket=$BUCKET --collection=$COLLECTION --auth "token:mysecret" README.rst pictures/* 387 | 388 | See ``python3 scripts/upload.py --help`` for more details about options. 389 | 390 | 391 | Download files 392 | -------------- 393 | 394 | ``download.py`` downloads the attachments from the specified server, bucket and 395 | collection and store them on disk:: 396 | 397 | $ python3 scripts/download.py --server=$SERVER --bucket=$BUCKET --collection=$COLLECTION --auth "token:mysecret" 398 | 399 | If the record has an ``original`` attribute, the script decompresses the attachment 400 | after downloading it. 401 | 402 | Files are stored in the current folder by default. 403 | See ``python3 scripts/download.py --help`` for more details about options. 404 | 405 | 406 | Known limitations 407 | ================= 408 | 409 | * No support for chunk upload (#10) 410 | * Files are not removed when server is purged with ``POST /v1/__flush__`` 411 | 412 | Relative URL in records (workaround) 413 | ------------------------------------ 414 | 415 | Currently the full URL is returned in records. This is very convenient for API consumers 416 | which can access the attached file just using the value in the ``location`` attribute. 417 | 418 | However, the way it is implemented has a limitation: the full URL is stored in each record 419 | directly. This is annoying because changing the ``base_url`` setting 420 | won't actually change the ``location`` attributes on existing records. 421 | 422 | As workaround, it is possible to set the ``kinto.attachment.base_url`` to an empty 423 | value. The ``location`` attribute in records will now contain a *relative* URL. 424 | 425 | Using another setting ``kinto.attachment.extra.base_url``, it is possible to advertise 426 | the base URL that can be preprended by clients to obtain the full attachment URL. 427 | If specified, it is going to be exposed in the capabilities of the root URL endpoint. 428 | 429 | 430 | Run tests 431 | ========= 432 | 433 | Run a fake Amazon S3 server in a separate terminal:: 434 | 435 | make run-moto 436 | 437 | Run the tests suite:: 438 | 439 | make tests 440 | 441 | 442 | Releasing 443 | ========= 444 | 445 | 1. Create a release on Github on https://github.com/Kinto/kinto-attachment/releases/new 446 | 2. Create a new tag `X.Y.Z` (*This tag will be created from the target when you publish this release.*) 447 | 3. Generate release notes 448 | 4. Publish release 449 | 450 | 451 | Notes 452 | ===== 453 | 454 | * `API design discussion `_ about mixing up ``attachment`` and record fields. 455 | -------------------------------------------------------------------------------- /demo/README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Run Demo 3 | ======== 4 | 5 | Online demo 6 | ----------- 7 | 8 | The demo server at Mozilla runs with *kinto-attachment* enabled. 9 | 10 | An hosted version of this folder is hosted on [Github pages](http://kinto.github.io/kinto-attachment) 11 | and can be used to play with the file feature. 12 | 13 | 14 | Run the demo locally 15 | -------------------- 16 | 17 | * Follow `the instructions in Kinto documentation `_ 18 | to get a local instance running. 19 | 20 | * Follow the instructions of this plugin to install and set the appropriate settings 21 | in ``config.ini`` 22 | 23 | We will serve the files locally, using these settings in config: 24 | 25 | :: 26 | 27 | kinto.attachment.base_path = /tmp 28 | kinto.attachment.base_url = http://localhost:7777 29 | 30 | In a separate terminal, run a simple server to serve the uploaded files: 31 | 32 | :: 33 | 34 | cd /tmp/ 35 | python -m SimpleHTTPServer 7777 36 | 37 | Now start Kinto 38 | 39 | :: 40 | 41 | kinto --ini config.ini start 42 | 43 | It should run on http://localhost:8888 44 | 45 | Edit `demo/index.js` to set the `server` variable to `http://localhost:8888/v1`. 46 | 47 | In a separate terminal, run a simple server to server the JavaScript demo app: 48 | 49 | :: 50 | 51 | cd demo/ 52 | python -m SimpleHTTPServer 9999 53 | 54 | * Navigate to http://localhost:9999 and post files! 55 | * The links on uploaded files are served on http://localhost:7777 56 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 | 15 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 41 |
TypeFilenameTypeSizeHash
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /demo/script.js: -------------------------------------------------------------------------------- 1 | function main() { 2 | // Local Kinto 3 | var server = "https://demo.kinto-storage.org/v1"; 4 | // var server = "http://localhost:8888/v1"; 5 | 6 | // Basic Authentication 7 | var headers = {Authorization: "Basic " + btoa("user:pass")}; 8 | // Bucket id 9 | var bucket = "fennec-ota"; 10 | 11 | // Reference to demo form. 12 | var form = document.forms.upload; 13 | form.onsubmit = submitFile; 14 | 15 | // When collection is chosen in combobox, refresh list of records. 16 | form.elements.category.addEventListener("change", refreshRecords); 17 | 18 | // When file is chosen in form, read its content 19 | // using HTML5 File API. 20 | var attachment = {file: null, binary: null}; 21 | var reader = new FileReader(); 22 | reader.addEventListener("load", function () { 23 | attachment.binary = reader.result; 24 | form.elements.submit.removeAttribute('disabled'); 25 | }); 26 | form.elements.attachment.addEventListener("change", function () { 27 | if(reader.readyState === FileReader.LOADING) { 28 | reader.abort(); 29 | } 30 | var field = form.elements.attachment; 31 | attachment.file = field.files[0]; 32 | reader.readAsBinaryString(attachment.file); 33 | }); 34 | 35 | // On startup, create bucket/collections objects if necessary, and load list. 36 | createObjects() 37 | .then(refreshRecords); 38 | 39 | 40 | function submitFile(event) { 41 | // Build the form submission manually, using auth headers. 42 | // Send file as multipart and form fields as serialized JSON. 43 | event.preventDefault(); 44 | 45 | // Collection id 46 | var collection = form.elements.category.value; 47 | // Record id 48 | var record = uuid4(); 49 | 50 | // Build form data 51 | var formData = new FormData(); 52 | // Multipart attachment 53 | formData.append('attachment', attachment.file, attachment.file.name); 54 | // Record attributes as JSON encoded 55 | var attributes = { 56 | type: form.elements.type.value 57 | }; 58 | formData.append('data', JSON.stringify(attributes)); 59 | 60 | // Post form using GlobalFetch API 61 | var url = `${server}/buckets/${bucket}/collections/${collection}/records/${record}/attachment`; 62 | fetch(url, {method: "POST", body: formData, headers: headers}) 63 | .then(function (result) { 64 | if (result.status > 400) { 65 | throw new Error('Failed'); 66 | } 67 | }) 68 | .then(refreshRecords) 69 | .catch(function (error) { 70 | document.getElementById("error").textContent = error.toString(); 71 | }); 72 | } 73 | 74 | function refreshRecords() { 75 | // Current collection id. 76 | var collection = form.elements.category.value; 77 | // List all records. 78 | var url = `${server}/buckets/${bucket}/collections/${collection}/records`; 79 | fetch(url, {headers: headers}) 80 | .then(function (response) { 81 | return response.json(); 82 | }) 83 | .then(function (result) { 84 | var tbody = document.querySelector("#records tbody"); 85 | tbody.innerHTML = ""; 86 | result.data.forEach(function(record) { 87 | tbody.appendChild(renderRecord(record)); 88 | }); 89 | }); 90 | } 91 | 92 | function renderRecord(record) { 93 | var tpl = document.getElementById("record-tpl"); 94 | var row = tpl.content.cloneNode(true); 95 | 96 | var link = row.querySelector(".location a"); 97 | link.setAttribute("href", record.attachment.location); 98 | link.textContent = record.attachment.filename; 99 | 100 | row.querySelector(".type").textContent = record.type; 101 | row.querySelector(".mimetype").textContent = record.attachment.mimetype; 102 | row.querySelector(".size").textContent = record.attachment.size; 103 | row.querySelector(".hash").textContent = record.attachment.hash; 104 | return row; 105 | } 106 | 107 | // Prepare demo objects. 108 | // This demo posts records in the ``fennec-ota`` bucket. The target *collection* 109 | // can be chosen in the form from ``font``, ``locale`` and ``hyphenation`` values. 110 | function createObjects() { 111 | var creationOptions = {method: 'PUT', headers: Object.assign({}, headers, {'If-None-Match': '*'})}; 112 | return fetch(`${server}/buckets/${bucket}`, creationOptions) 113 | .then(function (response) { 114 | var allCollections = ['font', 'locale', 'hyphenation'].map(function (collectionId) { 115 | return fetch(`${server}/buckets/${bucket}/collections/${collectionId}`, creationOptions); 116 | }); 117 | return Promise.all(allCollections); 118 | }); 119 | } 120 | } 121 | 122 | 123 | // Generate random uuid4 124 | // Source: http://stackoverflow.com/a/2117523 125 | function uuid4() { 126 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 127 | var r = crypto.getRandomValues(new Uint8Array(1))[0]%16|0, v = c == 'x' ? r : (r&0x3|0x8); 128 | return v.toString(16); 129 | }); 130 | } 131 | 132 | 133 | window.addEventListener("DOMContentLoaded", main); 134 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | dynamic = ["version", "dependencies"] 3 | name = "kinto-attachment" 4 | description = "Attach files to Kinto records" 5 | readme = "README.rst" 6 | license = {file = "LICENSE"} 7 | classifiers = [ 8 | "Programming Language :: Python", 9 | "Programming Language :: Python :: 3", 10 | "Topic :: Internet :: WWW/HTTP", 11 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 12 | "License :: OSI Approved :: Apache Software License" 13 | ] 14 | keywords = ["web services"] 15 | authors = [ 16 | {name = "Mozilla Services", email = "developers@kinto-storage.org"}, 17 | ] 18 | 19 | [project.urls] 20 | Repository = "https://github.com/Kinto/kinto-attachment" 21 | 22 | [project.scripts] 23 | kinto-send-email = "kinto_emailer.command_send:main" 24 | 25 | [tool.setuptools_scm] 26 | # can be empty if no extra settings are needed, presence enables setuptools_scm 27 | 28 | [tool.setuptools.dynamic] 29 | dependencies = { file = ["requirements.in"] } 30 | 31 | [build-system] 32 | requires = ["setuptools>=64", "setuptools_scm>=8"] 33 | build-backend = "setuptools.build_meta" 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "ruff", 38 | "moto[server,s3]<6", 39 | "kinto_http", 40 | "pytest", 41 | "pytest-cache", 42 | "pytest-cov", 43 | "webtest", 44 | "kinto[postgresql]", 45 | ] 46 | 47 | [tool.pip-tools] 48 | generate-hashes = true 49 | 50 | [tool.coverage.run] 51 | omit = [ 52 | "tests/*", 53 | ] 54 | relative_files = true 55 | 56 | [tool.ruff] 57 | line-length = 99 58 | extend-exclude = [ 59 | "__pycache__", 60 | ".venv/", 61 | ] 62 | 63 | [tool.ruff.lint] 64 | select = [ 65 | # pycodestyle 66 | "E", "W", 67 | # flake8 68 | "F", 69 | # isort 70 | "I", 71 | ] 72 | ignore = [ 73 | # `format` will wrap lines. 74 | "E501", 75 | ] 76 | 77 | [tool.ruff.lint.isort] 78 | lines-after-imports = 2 79 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | boto 2 | google-cloud-storage 3 | kinto[monitoring] 4 | pyramid_storage 5 | greenlet>=1 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile --allow-unsafe --generate-hashes requirements.in 6 | # 7 | attrs==23.2.0 \ 8 | --hash=sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30 \ 9 | --hash=sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1 10 | # via 11 | # jsonschema 12 | # referencing 13 | bcrypt==4.1.2 \ 14 | --hash=sha256:02d9ef8915f72dd6daaef40e0baeef8a017ce624369f09754baf32bb32dba25f \ 15 | --hash=sha256:1c28973decf4e0e69cee78c68e30a523be441972c826703bb93099868a8ff5b5 \ 16 | --hash=sha256:2a298db2a8ab20056120b45e86c00a0a5eb50ec4075b6142db35f593b97cb3fb \ 17 | --hash=sha256:33313a1200a3ae90b75587ceac502b048b840fc69e7f7a0905b5f87fac7a1258 \ 18 | --hash=sha256:3566a88234e8de2ccae31968127b0ecccbb4cddb629da744165db72b58d88ca4 \ 19 | --hash=sha256:387e7e1af9a4dd636b9505a465032f2f5cb8e61ba1120e79a0e1cd0b512f3dfc \ 20 | --hash=sha256:44290ccc827d3a24604f2c8bcd00d0da349e336e6503656cb8192133e27335e2 \ 21 | --hash=sha256:57fa9442758da926ed33a91644649d3e340a71e2d0a5a8de064fb621fd5a3326 \ 22 | --hash=sha256:68e3c6642077b0c8092580c819c1684161262b2e30c4f45deb000c38947bf483 \ 23 | --hash=sha256:69057b9fc5093ea1ab00dd24ede891f3e5e65bee040395fb1e66ee196f9c9b4a \ 24 | --hash=sha256:6cad43d8c63f34b26aef462b6f5e44fdcf9860b723d2453b5d391258c4c8e966 \ 25 | --hash=sha256:71b8be82bc46cedd61a9f4ccb6c1a493211d031415a34adde3669ee1b0afbb63 \ 26 | --hash=sha256:732b3920a08eacf12f93e6b04ea276c489f1c8fb49344f564cca2adb663b3e4c \ 27 | --hash=sha256:9800ae5bd5077b13725e2e3934aa3c9c37e49d3ea3d06318010aa40f54c63551 \ 28 | --hash=sha256:a97e07e83e3262599434816f631cc4c7ca2aa8e9c072c1b1a7fec2ae809a1d2d \ 29 | --hash=sha256:ac621c093edb28200728a9cca214d7e838529e557027ef0581685909acd28b5e \ 30 | --hash=sha256:b8df79979c5bae07f1db22dcc49cc5bccf08a0380ca5c6f391cbb5790355c0b0 \ 31 | --hash=sha256:b90e216dc36864ae7132cb151ffe95155a37a14e0de3a8f64b49655dd959ff9c \ 32 | --hash=sha256:ba4e4cc26610581a6329b3937e02d319f5ad4b85b074846bf4fef8a8cf51e7bb \ 33 | --hash=sha256:ba55e40de38a24e2d78d34c2d36d6e864f93e0d79d0b6ce915e4335aa81d01b1 \ 34 | --hash=sha256:be3ab1071662f6065899fe08428e45c16aa36e28bc42921c4901a191fda6ee42 \ 35 | --hash=sha256:d75fc8cd0ba23f97bae88a6ec04e9e5351ff3c6ad06f38fe32ba50cbd0d11946 \ 36 | --hash=sha256:e51c42750b7585cee7892c2614be0d14107fad9581d1738d954a262556dd1aab \ 37 | --hash=sha256:ea505c97a5c465ab8c3ba75c0805a102ce526695cd6818c6de3b1a38f6f60da1 \ 38 | --hash=sha256:eb3bd3321517916696233b5e0c67fd7d6281f0ef48e66812db35fc963a422a1c \ 39 | --hash=sha256:f70d9c61f9c4ca7d57f3bfe88a5ccf62546ffbadf3681bb1e268d9d2e41c91a7 \ 40 | --hash=sha256:fbe188b878313d01b7718390f31528be4010fed1faa798c5a1d0469c9c48c369 41 | # via kinto 42 | boto==2.49.0 \ 43 | --hash=sha256:147758d41ae7240dc989f0039f27da8ca0d53734be0eb869ef16e3adcfa462e8 \ 44 | --hash=sha256:ea0d3b40a2d852767be77ca343b58a9e3a4b00d9db440efb8da74b4e58025e5a 45 | # via -r requirements.in 46 | cachetools==5.3.2 \ 47 | --hash=sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2 \ 48 | --hash=sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1 49 | # via google-auth 50 | certifi==2024.7.4 \ 51 | --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ 52 | --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 53 | # via 54 | # requests 55 | # sentry-sdk 56 | charset-normalizer==3.3.2 \ 57 | --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ 58 | --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ 59 | --hash=sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786 \ 60 | --hash=sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8 \ 61 | --hash=sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09 \ 62 | --hash=sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185 \ 63 | --hash=sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574 \ 64 | --hash=sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e \ 65 | --hash=sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519 \ 66 | --hash=sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898 \ 67 | --hash=sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269 \ 68 | --hash=sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3 \ 69 | --hash=sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f \ 70 | --hash=sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6 \ 71 | --hash=sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8 \ 72 | --hash=sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a \ 73 | --hash=sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73 \ 74 | --hash=sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc \ 75 | --hash=sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714 \ 76 | --hash=sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2 \ 77 | --hash=sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc \ 78 | --hash=sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce \ 79 | --hash=sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d \ 80 | --hash=sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e \ 81 | --hash=sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6 \ 82 | --hash=sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269 \ 83 | --hash=sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96 \ 84 | --hash=sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d \ 85 | --hash=sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a \ 86 | --hash=sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4 \ 87 | --hash=sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77 \ 88 | --hash=sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d \ 89 | --hash=sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0 \ 90 | --hash=sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed \ 91 | --hash=sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068 \ 92 | --hash=sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac \ 93 | --hash=sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25 \ 94 | --hash=sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8 \ 95 | --hash=sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab \ 96 | --hash=sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26 \ 97 | --hash=sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2 \ 98 | --hash=sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db \ 99 | --hash=sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f \ 100 | --hash=sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 \ 101 | --hash=sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99 \ 102 | --hash=sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c \ 103 | --hash=sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d \ 104 | --hash=sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811 \ 105 | --hash=sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa \ 106 | --hash=sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a \ 107 | --hash=sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 \ 108 | --hash=sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b \ 109 | --hash=sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04 \ 110 | --hash=sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c \ 111 | --hash=sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001 \ 112 | --hash=sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458 \ 113 | --hash=sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389 \ 114 | --hash=sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99 \ 115 | --hash=sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985 \ 116 | --hash=sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537 \ 117 | --hash=sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238 \ 118 | --hash=sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f \ 119 | --hash=sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d \ 120 | --hash=sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796 \ 121 | --hash=sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a \ 122 | --hash=sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143 \ 123 | --hash=sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8 \ 124 | --hash=sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c \ 125 | --hash=sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5 \ 126 | --hash=sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5 \ 127 | --hash=sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711 \ 128 | --hash=sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4 \ 129 | --hash=sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6 \ 130 | --hash=sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c \ 131 | --hash=sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7 \ 132 | --hash=sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4 \ 133 | --hash=sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b \ 134 | --hash=sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae \ 135 | --hash=sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12 \ 136 | --hash=sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c \ 137 | --hash=sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae \ 138 | --hash=sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8 \ 139 | --hash=sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887 \ 140 | --hash=sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b \ 141 | --hash=sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4 \ 142 | --hash=sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f \ 143 | --hash=sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5 \ 144 | --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \ 145 | --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ 146 | --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 147 | # via requests 148 | colander==2.0 \ 149 | --hash=sha256:419cd68178d2ee6ee4cae5d5cb1830725634b0a2840917156e89eb2592237ef3 \ 150 | --hash=sha256:cb6b929fd0e79cdd3d310d3229e8f7884bd4fddd5cbf1d882063a6fb283b272b 151 | # via kinto 152 | colorama==0.4.6 \ 153 | --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ 154 | --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 155 | # via logging-color-formatter 156 | dockerflow==2024.4.2 \ 157 | --hash=sha256:b9f92455449ba46555f57db34cccefc4c49d3533c67793624ab7e80a1625caa7 \ 158 | --hash=sha256:f4216a3a809093860d7b2db84ba0a25c894cb8eb98b74f4f6a04badbc4f6b0a4 159 | # via kinto 160 | google-api-core==2.15.0 \ 161 | --hash=sha256:2aa56d2be495551e66bbff7f729b790546f87d5c90e74781aa77233bcb395a8a \ 162 | --hash=sha256:abc978a72658f14a2df1e5e12532effe40f94f868f6e23d95133bd6abcca35ca 163 | # via 164 | # google-cloud-core 165 | # google-cloud-storage 166 | google-auth==2.26.2 \ 167 | --hash=sha256:3f445c8ce9b61ed6459aad86d8ccdba4a9afed841b2d1451a11ef4db08957424 \ 168 | --hash=sha256:97327dbbf58cccb58fc5a1712bba403ae76668e64814eb30f7316f7e27126b81 169 | # via 170 | # google-api-core 171 | # google-cloud-core 172 | # google-cloud-storage 173 | google-cloud-core==2.4.2 \ 174 | --hash=sha256:7459c3e83de7cb8b9ecfec9babc910efb4314030c56dd798eaad12c426f7d180 \ 175 | --hash=sha256:a4fcb0e2fcfd4bfe963837fad6d10943754fd79c1a50097d68540b6eb3d67f35 176 | # via google-cloud-storage 177 | google-cloud-storage==3.1.0 \ 178 | --hash=sha256:944273179897c7c8a07ee15f2e6466a02da0c7c4b9ecceac2a26017cb2972049 \ 179 | --hash=sha256:eaf36966b68660a9633f03b067e4a10ce09f1377cae3ff9f2c699f69a81c66c6 180 | # via -r requirements.in 181 | google-crc32c==1.5.0 \ 182 | --hash=sha256:024894d9d3cfbc5943f8f230e23950cd4906b2fe004c72e29b209420a1e6b05a \ 183 | --hash=sha256:02c65b9817512edc6a4ae7c7e987fea799d2e0ee40c53ec573a692bee24de876 \ 184 | --hash=sha256:02ebb8bf46c13e36998aeaad1de9b48f4caf545e91d14041270d9dca767b780c \ 185 | --hash=sha256:07eb3c611ce363c51a933bf6bd7f8e3878a51d124acfc89452a75120bc436289 \ 186 | --hash=sha256:1034d91442ead5a95b5aaef90dbfaca8633b0247d1e41621d1e9f9db88c36298 \ 187 | --hash=sha256:116a7c3c616dd14a3de8c64a965828b197e5f2d121fedd2f8c5585c547e87b02 \ 188 | --hash=sha256:19e0a019d2c4dcc5e598cd4a4bc7b008546b0358bd322537c74ad47a5386884f \ 189 | --hash=sha256:1c7abdac90433b09bad6c43a43af253e688c9cfc1c86d332aed13f9a7c7f65e2 \ 190 | --hash=sha256:1e986b206dae4476f41bcec1faa057851f3889503a70e1bdb2378d406223994a \ 191 | --hash=sha256:272d3892a1e1a2dbc39cc5cde96834c236d5327e2122d3aaa19f6614531bb6eb \ 192 | --hash=sha256:278d2ed7c16cfc075c91378c4f47924c0625f5fc84b2d50d921b18b7975bd210 \ 193 | --hash=sha256:2ad40e31093a4af319dadf503b2467ccdc8f67c72e4bcba97f8c10cb078207b5 \ 194 | --hash=sha256:2e920d506ec85eb4ba50cd4228c2bec05642894d4c73c59b3a2fe20346bd00ee \ 195 | --hash=sha256:3359fc442a743e870f4588fcf5dcbc1bf929df1fad8fb9905cd94e5edb02e84c \ 196 | --hash=sha256:37933ec6e693e51a5b07505bd05de57eee12f3e8c32b07da7e73669398e6630a \ 197 | --hash=sha256:398af5e3ba9cf768787eef45c803ff9614cc3e22a5b2f7d7ae116df8b11e3314 \ 198 | --hash=sha256:3b747a674c20a67343cb61d43fdd9207ce5da6a99f629c6e2541aa0e89215bcd \ 199 | --hash=sha256:461665ff58895f508e2866824a47bdee72497b091c730071f2b7575d5762ab65 \ 200 | --hash=sha256:4c6fdd4fccbec90cc8a01fc00773fcd5fa28db683c116ee3cb35cd5da9ef6c37 \ 201 | --hash=sha256:5829b792bf5822fd0a6f6eb34c5f81dd074f01d570ed7f36aa101d6fc7a0a6e4 \ 202 | --hash=sha256:596d1f98fc70232fcb6590c439f43b350cb762fb5d61ce7b0e9db4539654cc13 \ 203 | --hash=sha256:5ae44e10a8e3407dbe138984f21e536583f2bba1be9491239f942c2464ac0894 \ 204 | --hash=sha256:635f5d4dd18758a1fbd1049a8e8d2fee4ffed124462d837d1a02a0e009c3ab31 \ 205 | --hash=sha256:64e52e2b3970bd891309c113b54cf0e4384762c934d5ae56e283f9a0afcd953e \ 206 | --hash=sha256:66741ef4ee08ea0b2cc3c86916ab66b6aef03768525627fd6a1b34968b4e3709 \ 207 | --hash=sha256:67b741654b851abafb7bc625b6d1cdd520a379074e64b6a128e3b688c3c04740 \ 208 | --hash=sha256:6ac08d24c1f16bd2bf5eca8eaf8304812f44af5cfe5062006ec676e7e1d50afc \ 209 | --hash=sha256:6f998db4e71b645350b9ac28a2167e6632c239963ca9da411523bb439c5c514d \ 210 | --hash=sha256:72218785ce41b9cfd2fc1d6a017dc1ff7acfc4c17d01053265c41a2c0cc39b8c \ 211 | --hash=sha256:74dea7751d98034887dbd821b7aae3e1d36eda111d6ca36c206c44478035709c \ 212 | --hash=sha256:759ce4851a4bb15ecabae28f4d2e18983c244eddd767f560165563bf9aefbc8d \ 213 | --hash=sha256:77e2fd3057c9d78e225fa0a2160f96b64a824de17840351b26825b0848022906 \ 214 | --hash=sha256:7c074fece789b5034b9b1404a1f8208fc2d4c6ce9decdd16e8220c5a793e6f61 \ 215 | --hash=sha256:7c42c70cd1d362284289c6273adda4c6af8039a8ae12dc451dcd61cdabb8ab57 \ 216 | --hash=sha256:7f57f14606cd1dd0f0de396e1e53824c371e9544a822648cd76c034d209b559c \ 217 | --hash=sha256:83c681c526a3439b5cf94f7420471705bbf96262f49a6fe546a6db5f687a3d4a \ 218 | --hash=sha256:8485b340a6a9e76c62a7dce3c98e5f102c9219f4cfbf896a00cf48caf078d438 \ 219 | --hash=sha256:84e6e8cd997930fc66d5bb4fde61e2b62ba19d62b7abd7a69920406f9ecca946 \ 220 | --hash=sha256:89284716bc6a5a415d4eaa11b1726d2d60a0cd12aadf5439828353662ede9dd7 \ 221 | --hash=sha256:8b87e1a59c38f275c0e3676fc2ab6d59eccecfd460be267ac360cc31f7bcde96 \ 222 | --hash=sha256:8f24ed114432de109aa9fd317278518a5af2d31ac2ea6b952b2f7782b43da091 \ 223 | --hash=sha256:98cb4d057f285bd80d8778ebc4fde6b4d509ac3f331758fb1528b733215443ae \ 224 | --hash=sha256:998679bf62b7fb599d2878aa3ed06b9ce688b8974893e7223c60db155f26bd8d \ 225 | --hash=sha256:9ba053c5f50430a3fcfd36f75aff9caeba0440b2d076afdb79a318d6ca245f88 \ 226 | --hash=sha256:9c99616c853bb585301df6de07ca2cadad344fd1ada6d62bb30aec05219c45d2 \ 227 | --hash=sha256:a1fd716e7a01f8e717490fbe2e431d2905ab8aa598b9b12f8d10abebb36b04dd \ 228 | --hash=sha256:a2355cba1f4ad8b6988a4ca3feed5bff33f6af2d7f134852cf279c2aebfde541 \ 229 | --hash=sha256:b1f8133c9a275df5613a451e73f36c2aea4fe13c5c8997e22cf355ebd7bd0728 \ 230 | --hash=sha256:b8667b48e7a7ef66afba2c81e1094ef526388d35b873966d8a9a447974ed9178 \ 231 | --hash=sha256:ba1eb1843304b1e5537e1fca632fa894d6f6deca8d6389636ee5b4797affb968 \ 232 | --hash=sha256:be82c3c8cfb15b30f36768797a640e800513793d6ae1724aaaafe5bf86f8f346 \ 233 | --hash=sha256:c02ec1c5856179f171e032a31d6f8bf84e5a75c45c33b2e20a3de353b266ebd8 \ 234 | --hash=sha256:c672d99a345849301784604bfeaeba4db0c7aae50b95be04dd651fd2a7310b93 \ 235 | --hash=sha256:c6c777a480337ac14f38564ac88ae82d4cd238bf293f0a22295b66eb89ffced7 \ 236 | --hash=sha256:cae0274952c079886567f3f4f685bcaf5708f0a23a5f5216fdab71f81a6c0273 \ 237 | --hash=sha256:cd67cf24a553339d5062eff51013780a00d6f97a39ca062781d06b3a73b15462 \ 238 | --hash=sha256:d3515f198eaa2f0ed49f8819d5732d70698c3fa37384146079b3799b97667a94 \ 239 | --hash=sha256:d5280312b9af0976231f9e317c20e4a61cd2f9629b7bfea6a693d1878a264ebd \ 240 | --hash=sha256:de06adc872bcd8c2a4e0dc51250e9e65ef2ca91be023b9d13ebd67c2ba552e1e \ 241 | --hash=sha256:e1674e4307fa3024fc897ca774e9c7562c957af85df55efe2988ed9056dc4e57 \ 242 | --hash=sha256:e2096eddb4e7c7bdae4bd69ad364e55e07b8316653234a56552d9c988bd2d61b \ 243 | --hash=sha256:e560628513ed34759456a416bf86b54b2476c59144a9138165c9a1575801d0d9 \ 244 | --hash=sha256:edfedb64740750e1a3b16152620220f51d58ff1b4abceb339ca92e934775c27a \ 245 | --hash=sha256:f13cae8cc389a440def0c8c52057f37359014ccbc9dc1f0827936bcd367c6100 \ 246 | --hash=sha256:f314013e7dcd5cf45ab1945d92e713eec788166262ae8deb2cfacd53def27325 \ 247 | --hash=sha256:f583edb943cf2e09c60441b910d6a20b4d9d626c75a36c8fcac01a6c96c01183 \ 248 | --hash=sha256:fd8536e902db7e365f49e7d9029283403974ccf29b13fc7028b97e2295b33556 \ 249 | --hash=sha256:fe70e325aa68fa4b5edf7d1a4b6f691eb04bbccac0ace68e34820d283b5f80d4 250 | # via 251 | # google-cloud-storage 252 | # google-resumable-media 253 | google-resumable-media==2.7.2 \ 254 | --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ 255 | --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 256 | # via google-cloud-storage 257 | googleapis-common-protos==1.62.0 \ 258 | --hash=sha256:4750113612205514f9f6aa4cb00d523a94f3e8c06c5ad2fee466387dc4875f07 \ 259 | --hash=sha256:83f0ece9f94e5672cced82f592d2a5edf527a96ed1794f0bab36d5735c996277 260 | # via google-api-core 261 | greenlet==3.2.2 \ 262 | --hash=sha256:00cd814b8959b95a546e47e8d589610534cfb71f19802ea8a2ad99d95d702057 \ 263 | --hash=sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207 \ 264 | --hash=sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3 \ 265 | --hash=sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4 \ 266 | --hash=sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b \ 267 | --hash=sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc \ 268 | --hash=sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825 \ 269 | --hash=sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370 \ 270 | --hash=sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708 \ 271 | --hash=sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457 \ 272 | --hash=sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763 \ 273 | --hash=sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf \ 274 | --hash=sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe \ 275 | --hash=sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e \ 276 | --hash=sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d \ 277 | --hash=sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59 \ 278 | --hash=sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa \ 279 | --hash=sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e \ 280 | --hash=sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51 \ 281 | --hash=sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3 \ 282 | --hash=sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e \ 283 | --hash=sha256:6629311595e3fe7304039c67f00d145cd1d38cf723bb5b99cc987b23c1433d61 \ 284 | --hash=sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5 \ 285 | --hash=sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74 \ 286 | --hash=sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907 \ 287 | --hash=sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275 \ 288 | --hash=sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f \ 289 | --hash=sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59 \ 290 | --hash=sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c \ 291 | --hash=sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf \ 292 | --hash=sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b \ 293 | --hash=sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819 \ 294 | --hash=sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65 \ 295 | --hash=sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e \ 296 | --hash=sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240 \ 297 | --hash=sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5 \ 298 | --hash=sha256:a8fa80665b1a29faf76800173ff5325095f3e66a78e62999929809907aca5659 \ 299 | --hash=sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485 \ 300 | --hash=sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec \ 301 | --hash=sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8 \ 302 | --hash=sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418 \ 303 | --hash=sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce \ 304 | --hash=sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6 \ 305 | --hash=sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7 \ 306 | --hash=sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6 \ 307 | --hash=sha256:d0cb7d47199001de7658c213419358aa8937df767936506db0db7ce1a71f4a2f \ 308 | --hash=sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13 \ 309 | --hash=sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b \ 310 | --hash=sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068 \ 311 | --hash=sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325 \ 312 | --hash=sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330 \ 313 | --hash=sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834 \ 314 | --hash=sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b \ 315 | --hash=sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5 \ 316 | --hash=sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421 317 | # via 318 | # -r requirements.in 319 | # sqlalchemy 320 | hupper==1.12 \ 321 | --hash=sha256:18b1653d9832c9f8e7d3401986c7e7af2ae6783616be0bc406bfe0b14134a5c6 \ 322 | --hash=sha256:b8bc41bb75939e816f30f118026d0ba99544af4d6992583df3b4813765af27ef 323 | # via pyramid 324 | idna==3.7 \ 325 | --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ 326 | --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 327 | # via requests 328 | iso8601==2.1.0 \ 329 | --hash=sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df \ 330 | --hash=sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242 331 | # via colander 332 | jsonpatch==1.33 \ 333 | --hash=sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade \ 334 | --hash=sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c 335 | # via kinto 336 | jsonpointer==2.4 \ 337 | --hash=sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a \ 338 | --hash=sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88 339 | # via jsonpatch 340 | jsonschema==4.20.0 \ 341 | --hash=sha256:4f614fd46d8d61258610998997743ec5492a648b33cf478c1ddc23ed4598a5fa \ 342 | --hash=sha256:ed6231f0429ecf966f5bc8dfef245998220549cbbcf140f913b7464c52c3b6b3 343 | # via kinto 344 | jsonschema-specifications==2023.12.1 \ 345 | --hash=sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc \ 346 | --hash=sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c 347 | # via jsonschema 348 | kinto[monitoring]==23.0.2 \ 349 | --hash=sha256:38bcabc2424cfa4011cdbd9a7cb00326fef879fe7b60abd4ada8388e95b0ba7d \ 350 | --hash=sha256:4f0517a2409d2ac0987c34fae81b2625b58d6363a340fd5cc44e074cade8e51d 351 | # via -r requirements.in 352 | logging-color-formatter==1.0.3 \ 353 | --hash=sha256:495920c386d48cad9e9463a10f51b4deb95cab3b0185d02a36af67d66ae93787 \ 354 | --hash=sha256:ecee191e6414f6133ae8b3d5cf1598612210b3fe5eeb901a7b1aa70d9751be47 355 | # via kinto 356 | markupsafe==3.0.2 \ 357 | --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ 358 | --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ 359 | --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ 360 | --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ 361 | --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ 362 | --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ 363 | --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ 364 | --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ 365 | --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ 366 | --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ 367 | --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ 368 | --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ 369 | --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ 370 | --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ 371 | --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ 372 | --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ 373 | --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ 374 | --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ 375 | --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ 376 | --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ 377 | --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ 378 | --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ 379 | --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ 380 | --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ 381 | --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ 382 | --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ 383 | --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ 384 | --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ 385 | --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ 386 | --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ 387 | --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ 388 | --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ 389 | --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ 390 | --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ 391 | --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ 392 | --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ 393 | --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ 394 | --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ 395 | --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ 396 | --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ 397 | --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ 398 | --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ 399 | --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ 400 | --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ 401 | --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ 402 | --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ 403 | --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ 404 | --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ 405 | --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ 406 | --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ 407 | --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ 408 | --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ 409 | --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ 410 | --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ 411 | --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ 412 | --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ 413 | --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ 414 | --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ 415 | --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ 416 | --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ 417 | --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 418 | # via werkzeug 419 | newrelic==10.11.0 \ 420 | --hash=sha256:0947282a9eea3f5ba2d9ad5d93f2e321852b74dda7cb5d0acdca787c2ebc71b9 \ 421 | --hash=sha256:0feffdb0426d0f3aa5e2d88d19bd803e322d1f9cc41e2f8bcaac16c2231ef943 \ 422 | --hash=sha256:20f1be634b0e91440b86857655d649ea0b11d703f2b6ee270cf80e1b3a2c0f3c \ 423 | --hash=sha256:42bef02b8dfe7601f3a0cdf565079931ccc9fdf07d349c8bfabfdb84cc87aee0 \ 424 | --hash=sha256:45de69ecd1f7ef941efcdcf0a7e34101fbbf83432c01a94c73fa3468a09b50e4 \ 425 | --hash=sha256:50dff058fe897665959a825023c28bb561e829a32db97da994eb1157a3bbb096 \ 426 | --hash=sha256:536608c5458b8e926359fffc12308fc5b0483e8e47485f5e927aaf86d5823967 \ 427 | --hash=sha256:53788c94aba8da4cecbd0a834f33d98a4039cf91efaebdb94cf44091c8127014 \ 428 | --hash=sha256:60de082b20b1982e9af723beb5f445ca8215d8133e89c020b474f383a43b092e \ 429 | --hash=sha256:751b92a8884521df33bd0a8d1e3c22efe4a49452f60d403fdc7dd757eee1ad54 \ 430 | --hash=sha256:76cd2536bbf70a14132baae530d31c4b9eba10f857ea47e9b3f0ee355cfe0fc0 \ 431 | --hash=sha256:7fc659191ef0cd205cf8968f28739b0e3845d59e73bde110c14125d6f70318d6 \ 432 | --hash=sha256:898e9e0e23ecc524d468523513f70b2351ec39702a42df160344687d84fade1f \ 433 | --hash=sha256:9a9f82ac18b688447f93cf35f231f69275c161dcc1a92f7359da800c6e941334 \ 434 | --hash=sha256:9eb55674dcabb9659b2c306a97f416405f6558a511cefe84adb68303d92787c1 \ 435 | --hash=sha256:a5addef183673e56e9b36910dd06351ee3690f9d57275a841f36fa8f7e1e2b8e \ 436 | --hash=sha256:a6e3bd87c009e6d0738248c7679b7c92775ff637e2c8978fa8d69d78d3fe5e9e \ 437 | --hash=sha256:af623e96f8cdc2224e81d299ef96ef91370a7fbe80bc5430e9f91403dee062c9 \ 438 | --hash=sha256:b586cf584f0672bfa001eac0426646b7584c066c9adc94eda9a1c995118131b3 \ 439 | --hash=sha256:b9438a822ed3272974722ed0bb5b9d4982b3e9888db3befd6404142af8dc6721 \ 440 | --hash=sha256:bb51f1e8d2d4b48d2dba956bae6c5186a9284088566b4bb6ee68cf00365982c3 \ 441 | --hash=sha256:c427d72a05d22670bc557442e0952d38a2c74464605112071888307f07c24c6d \ 442 | --hash=sha256:c57874afff1b47a3a9445bf99b6a3afea67a9401ccbb9c505d3638dc6ba1bc97 \ 443 | --hash=sha256:ce24132055279ee1fb6e517651da306988ad427771b3a89c5144b860617fc5da \ 444 | --hash=sha256:d7706823abcb99638196a0315b3f7dbd8d50ae625ceddfd98915adef989fa888 \ 445 | --hash=sha256:da796354f0b90cbd93a4212e16f83e0d022ae7ed9f846c169a652f24812f10fa \ 446 | --hash=sha256:e5dc8e824d2b86483a554807971117bc2a9a5d81426aa6f71243ec01a5899972 \ 447 | --hash=sha256:ec4e5299e9e78543ff6399593e80a4ebd911a6096a84572d7031e04f098782ec \ 448 | --hash=sha256:f694d74f9d64a4862cb836270d5c9136d158853bd6dd7edb3456951f33fc3458 449 | # via kinto 450 | pastedeploy==3.1.0 \ 451 | --hash=sha256:76388ad53a661448d436df28c798063108f70e994ddc749540d733cdbd1b38cf \ 452 | --hash=sha256:9ddbaf152f8095438a9fe81f82c78a6714b92ae8e066bed418b6a7ff6a095a95 453 | # via plaster-pastedeploy 454 | plaster==1.1.2 \ 455 | --hash=sha256:42992ab1f4865f1278e2ad740e8ad145683bb4022e03534265528f0c23c0df2d \ 456 | --hash=sha256:f8befc54bf8c1147c10ab40297ec84c2676fa2d4ea5d6f524d9436a80074ef98 457 | # via 458 | # plaster-pastedeploy 459 | # pyramid 460 | plaster-pastedeploy==1.0.1 \ 461 | --hash=sha256:ad3550cc744648969ed3b810f33c9344f515ee8d8a8cec18e8f2c4a643c2181f \ 462 | --hash=sha256:be262e6d2e41a7264875daa2fe2850cbb0615728bcdc92828fdc72736e381412 463 | # via pyramid 464 | prometheus-client==0.21.1 \ 465 | --hash=sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb \ 466 | --hash=sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301 467 | # via kinto 468 | protobuf==4.25.2 \ 469 | --hash=sha256:10894a2885b7175d3984f2be8d9850712c57d5e7587a2410720af8be56cdaf62 \ 470 | --hash=sha256:2db9f8fa64fbdcdc93767d3cf81e0f2aef176284071507e3ede160811502fd3d \ 471 | --hash=sha256:33a1aeef4b1927431d1be780e87b641e322b88d654203a9e9d93f218ee359e61 \ 472 | --hash=sha256:47f3de503fe7c1245f6f03bea7e8d3ec11c6c4a2ea9ef910e3221c8a15516d62 \ 473 | --hash=sha256:5e5c933b4c30a988b52e0b7c02641760a5ba046edc5e43d3b94a74c9fc57c1b3 \ 474 | --hash=sha256:8f62574857ee1de9f770baf04dde4165e30b15ad97ba03ceac65f760ff018ac9 \ 475 | --hash=sha256:a8b7a98d4ce823303145bf3c1a8bdb0f2f4642a414b196f04ad9853ed0c8f830 \ 476 | --hash=sha256:b50c949608682b12efb0b2717f53256f03636af5f60ac0c1d900df6213910fd6 \ 477 | --hash=sha256:d66a769b8d687df9024f2985d5137a337f957a0916cf5464d1513eee96a63ff0 \ 478 | --hash=sha256:fc381d1dd0516343f1440019cedf08a7405f791cd49eef4ae1ea06520bc1c020 \ 479 | --hash=sha256:fe599e175cb347efc8ee524bcd4b902d11f7262c0e569ececcb89995c15f0a5e 480 | # via 481 | # google-api-core 482 | # googleapis-common-protos 483 | pyasn1==0.5.1 \ 484 | --hash=sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58 \ 485 | --hash=sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c 486 | # via 487 | # pyasn1-modules 488 | # rsa 489 | pyasn1-modules==0.3.0 \ 490 | --hash=sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c \ 491 | --hash=sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d 492 | # via google-auth 493 | pyramid==2.0.2 \ 494 | --hash=sha256:2e6585ac55c147f0a51bc00dadf72075b3bdd9a871b332ff9e5e04117ccd76fa \ 495 | --hash=sha256:372138a738e4216535cc76dcce6eddd5a1aaca95130f2354fb834264c06f18de 496 | # via 497 | # kinto 498 | # pyramid-multiauth 499 | # pyramid-storage 500 | # pyramid-tm 501 | pyramid-multiauth==1.0.1 \ 502 | --hash=sha256:6d8785558e1d0bbe0d0da43e296efc0fbe0de5071d1f9b1091e891f0e4ec9682 \ 503 | --hash=sha256:c265258af8021094e5b98602e8bfe094eec1350eebb56473f36cd0e076910822 504 | # via kinto 505 | pyramid-storage==1.3.2 \ 506 | --hash=sha256:31030f38448dc1f7eee8c82f13c1c2a0168233e4a92058234c8bef87cd20bdaf \ 507 | --hash=sha256:dd4fff0d19290c102948d746bf1f32d15ae5b2173d0c1ebc48a136a00643c6bc 508 | # via -r requirements.in 509 | pyramid-tm==2.5 \ 510 | --hash=sha256:5c81dcecd33770f5e3596687d2be35ffc4f8ce5eda00a31acb00ae35a51430d0 \ 511 | --hash=sha256:6638721946e809de8b4bf3f405bd2daaaa76d58442cbdf46be30ebc259f1a354 512 | # via kinto 513 | python-dateutil==2.8.2 \ 514 | --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ 515 | --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 516 | # via kinto 517 | python-rapidjson==1.14 \ 518 | --hash=sha256:012bdf6380ef6f807fd39b36c315548ad1de2f75346487d33a3326e4b2d7427b \ 519 | --hash=sha256:030c921779b225c9b4765dcfafecc7b18d2d9ded15529718bf8320d3f23ef428 \ 520 | --hash=sha256:0721d58740a00504711773fbb4380d7b4abf575a05f6dd348e259e3d4eab9c1e \ 521 | --hash=sha256:150c464f5a7273cdf3caf21050724dc3746a5e6632c3a38206a4e49827e4d0ab \ 522 | --hash=sha256:1c993c0d60a50ae8233e184ce48840626ea017c3154aa72995910587860c1bcb \ 523 | --hash=sha256:1d2dfbbaa3be9f4cff96b89a2f2dc25589d50db00ff44799fc575775628342e8 \ 524 | --hash=sha256:26806f0a658c34b48d2951d8d3f846ca9deb93a34e664ef436db632a188b6779 \ 525 | --hash=sha256:2abcce7f4bb0cf4ecd3df7755f03798a7250cb5f584f263d4e045478f7b4b8a4 \ 526 | --hash=sha256:2afd65129f71e850286c52386d4a0d9020aca536f7dfb5e382a02e68922ec887 \ 527 | --hash=sha256:2c36878511b9be19194a8c655113eafbab2f08c4e60856a84acbf81088520bb9 \ 528 | --hash=sha256:2f798972be1696b8070d4f6e0fb69d0785f6666a278bbba5073ce1af901cbac5 \ 529 | --hash=sha256:2f83e62cebafe42efd6e219479be35dff88f6ea0a98b8651129cc721c2736124 \ 530 | --hash=sha256:36d7269b23b79cf35315026fcc257ac6d2ac10a1d67f86e9d69484bef79b96fa \ 531 | --hash=sha256:37055e7c0ca0823965189c544193db9f0402aed2632779797a67660e4bf7e53c \ 532 | --hash=sha256:3c1f11c2283f97817fe6dbf677569f297e9295c7856683e1e11fbac27b745fee \ 533 | --hash=sha256:3d668824d110277547c186e8cea7de435ea592af33598579e5a9ff6f5c642847 \ 534 | --hash=sha256:40ea40077c88645c9f149c77285568dc3e0c9e91bc6a90f283109e5c89011c73 \ 535 | --hash=sha256:43a4746f4e7d94b8516add40bffd28feb394578505ffb1df30837482222b229f \ 536 | --hash=sha256:43b53d5136de86e58591f645352544f4ca7471f675f51dd971bb04df847e9b39 \ 537 | --hash=sha256:449180f5f3cdee5dd7190ac06b147e3f4ca876abdf001a586ddde2c8c9ce6184 \ 538 | --hash=sha256:4fba1cba0ad8cbb2292bba74d8440348f4bb9f260dd7654af485bfd38f2cecce \ 539 | --hash=sha256:5b3d72fd0997505c9ee16a1f92b589de029551bc0fbaa30f1cac63fdba6f7ad4 \ 540 | --hash=sha256:5f923a0e6f204145589dd451f99724ebbe10cc74750eecc4fef38f330d954c11 \ 541 | --hash=sha256:6a5231bf3a539125dcd19951f1db4568a2423cb21978f8bec95eda60fcc45f23 \ 542 | --hash=sha256:6dcac7681c17ef91beb346d1dd6f517dc7b1f20359194ebe4691fc0a496712d9 \ 543 | --hash=sha256:6ef02eb912f9972772e1f8d3c87e90276c562d6641b87afe06728457fe63b3e9 \ 544 | --hash=sha256:77a9a31dd71737f3ab6508d4182be54241949b959d92260ffd29e5199973f1b4 \ 545 | --hash=sha256:79541cab64fe531b5ad8050004393fcd1ed4d73632abac57293e7230a7a6c349 \ 546 | --hash=sha256:79bef625d8debbd5170f01554e7986087625066bc24b37ca1ed1deea48f371bc \ 547 | --hash=sha256:7dacc5074f4a65588fbbcc309b0e3112c1b204dda647d5340e68c91a9bc15718 \ 548 | --hash=sha256:7e0008dbca662bd4ed043f570ce0f80e6f89d0ea789cd12cbb3ffc2101e7889e \ 549 | --hash=sha256:8a820209ad42b62c16c96aca5653edc31bf3d93fdb8d822ea2f15b5aedd80974 \ 550 | --hash=sha256:8b25c0db4a5cd2d3ac46643a70000d9499293b178f4677021ca87a8c87d4e52a \ 551 | --hash=sha256:8dfea0dbe9e307795befbce08d59c93b7f41ce7aa70c58aeb1496054ea18fd62 \ 552 | --hash=sha256:8f6e7b870857d9879076a5de11eb28eec978fd6aa2578af6178c56532c8bd4dd \ 553 | --hash=sha256:968f747bf4374c14e4f3c7e6a60fe2b15c7e738a705183c71707d6390964e646 \ 554 | --hash=sha256:9a92ee79be231f94bfa7e62095dfffcb9ea032fc79526a8f072c9ab8d5ab8c14 \ 555 | --hash=sha256:a03b4a7e5d2ef45a5e10eb6f75dbe504a3fc946e225cc1684fe3b6977210e234 \ 556 | --hash=sha256:a26c97b44586d718239f709151e98a1f8de96f0b932f508ad4b81673eda87c8e \ 557 | --hash=sha256:a40ed1dea1259480efbabdafd4741b790dbef79cbb5e9344b22757e224148088 \ 558 | --hash=sha256:a8d08eee8fe2fdd633238e4513ea37ff1dd45b34baa5d4204226043d885e7f99 \ 559 | --hash=sha256:ad80879a0f2a65ab7ddac64f08b5c686dcbdb31168beca70a58fc07ddbe5bad2 \ 560 | --hash=sha256:afd9d5dea1e9237af093b7477c097f1073b402d6d3797378068f6c560c90f0c6 \ 561 | --hash=sha256:b2aef38881acdd5b7bc191e95ae6c5bc18d97339fb42e38163a2ebd4dfd5e13d \ 562 | --hash=sha256:b4511becaccd7fce656173e30fae8eb93a2f456461318aba9c6653f426e4a574 \ 563 | --hash=sha256:b9d34b28f47a96aae6f697eb09febf9cac81a9e7cef2f55b02bcee2b1650d994 \ 564 | --hash=sha256:bce51e5570881215dd5d8ffe7150578cbe0882cf9eebc8d2bbd6c7f20bfb17dc \ 565 | --hash=sha256:bdf4841848597259a1d8ca6ebbd4b2843a116f84bc722d1675800105902c6e74 \ 566 | --hash=sha256:bf432624e462a9942e384d3c954d3085530765cedb72c877fd110f6eca5528e5 \ 567 | --hash=sha256:bfe254cf72a58dee14b00eb423b6450b7290d509acabbde701cbde793bf8e177 \ 568 | --hash=sha256:c05049fbc970be416522e4f68a3a55252a375065ddef78b2a821c64e9bfe8c3e \ 569 | --hash=sha256:c9b7857ebc3717035bf12e05ab05d3ba18255408776ab55a9b0658337a803d16 \ 570 | --hash=sha256:d70de908184593261ea20084419d5e2419134e3b37bb7df2bfd22996ad2d51ad \ 571 | --hash=sha256:d93de3501eab05e546135c42154e99f3b580e1c74ac26b5a7e92877756cc4b21 \ 572 | --hash=sha256:d9c22ec1d1d7d1a4fb7f80815f2d75c6f6880f6c98a243c5bd04a77c2cef2a1b \ 573 | --hash=sha256:df8729273cd1bc8e8514b8c9b28cb2861d1f055a16103e962f99f376fb9447cb \ 574 | --hash=sha256:e80b3f618b34f9772e8691ed3fcb64eae703182267e217c18cbac5c8417ee6cd \ 575 | --hash=sha256:ef26c5af200148365fc41fd5594ac393065952baec26a9c37900925ea3575588 \ 576 | --hash=sha256:f0ce716f9d8c2eb5ccd2807dbfd969e84f7ca86b09b9b56be27c1dee57dfaa9c \ 577 | --hash=sha256:f7ce2132531643fa9c2935146e28875c60a79fa0de1afc86951a2b09ef04b40a \ 578 | --hash=sha256:f827fc652ab51e3777b375d17867132351eb9b4e53578a139c24fb5c559fdb45 579 | # via kinto 580 | referencing==0.32.1 \ 581 | --hash=sha256:3c57da0513e9563eb7e203ebe9bb3a1b509b042016433bd1e45a2853466c3dd3 \ 582 | --hash=sha256:7e4dc12271d8e15612bfe35792f5ea1c40970dadf8624602e33db2758f7ee554 583 | # via 584 | # jsonschema 585 | # jsonschema-specifications 586 | requests==2.32.0 \ 587 | --hash=sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5 \ 588 | --hash=sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8 589 | # via 590 | # google-api-core 591 | # google-cloud-storage 592 | # kinto 593 | rpds-py==0.17.1 \ 594 | --hash=sha256:01f58a7306b64e0a4fe042047dd2b7d411ee82e54240284bab63e325762c1147 \ 595 | --hash=sha256:0210b2668f24c078307260bf88bdac9d6f1093635df5123789bfee4d8d7fc8e7 \ 596 | --hash=sha256:02866e060219514940342a1f84303a1ef7a1dad0ac311792fbbe19b521b489d2 \ 597 | --hash=sha256:0387ce69ba06e43df54e43968090f3626e231e4bc9150e4c3246947567695f68 \ 598 | --hash=sha256:060f412230d5f19fc8c8b75f315931b408d8ebf56aec33ef4168d1b9e54200b1 \ 599 | --hash=sha256:071bc28c589b86bc6351a339114fb7a029f5cddbaca34103aa573eba7b482382 \ 600 | --hash=sha256:0bfb09bf41fe7c51413f563373e5f537eaa653d7adc4830399d4e9bdc199959d \ 601 | --hash=sha256:10162fe3f5f47c37ebf6d8ff5a2368508fe22007e3077bf25b9c7d803454d921 \ 602 | --hash=sha256:149c5cd24f729e3567b56e1795f74577aa3126c14c11e457bec1b1c90d212e38 \ 603 | --hash=sha256:1701fc54460ae2e5efc1dd6350eafd7a760f516df8dbe51d4a1c79d69472fbd4 \ 604 | --hash=sha256:1957a2ab607f9added64478a6982742eb29f109d89d065fa44e01691a20fc20a \ 605 | --hash=sha256:1a746a6d49665058a5896000e8d9d2f1a6acba8a03b389c1e4c06e11e0b7f40d \ 606 | --hash=sha256:1bfcad3109c1e5ba3cbe2f421614e70439f72897515a96c462ea657261b96518 \ 607 | --hash=sha256:1d36b2b59e8cc6e576f8f7b671e32f2ff43153f0ad6d0201250a7c07f25d570e \ 608 | --hash=sha256:1db228102ab9d1ff4c64148c96320d0be7044fa28bd865a9ce628ce98da5973d \ 609 | --hash=sha256:1dc29db3900cb1bb40353772417800f29c3d078dbc8024fd64655a04ee3c4bdf \ 610 | --hash=sha256:1e626b365293a2142a62b9a614e1f8e331b28f3ca57b9f05ebbf4cf2a0f0bdc5 \ 611 | --hash=sha256:1f3c3461ebb4c4f1bbc70b15d20b565759f97a5aaf13af811fcefc892e9197ba \ 612 | --hash=sha256:20de7b7179e2031a04042e85dc463a93a82bc177eeba5ddd13ff746325558aa6 \ 613 | --hash=sha256:24e4900a6643f87058a27320f81336d527ccfe503984528edde4bb660c8c8d59 \ 614 | --hash=sha256:2528ff96d09f12e638695f3a2e0c609c7b84c6df7c5ae9bfeb9252b6fa686253 \ 615 | --hash=sha256:25f071737dae674ca8937a73d0f43f5a52e92c2d178330b4c0bb6ab05586ffa6 \ 616 | --hash=sha256:270987bc22e7e5a962b1094953ae901395e8c1e1e83ad016c5cfcfff75a15a3f \ 617 | --hash=sha256:292f7344a3301802e7c25c53792fae7d1593cb0e50964e7bcdcc5cf533d634e3 \ 618 | --hash=sha256:2953937f83820376b5979318840f3ee47477d94c17b940fe31d9458d79ae7eea \ 619 | --hash=sha256:2a792b2e1d3038daa83fa474d559acfd6dc1e3650ee93b2662ddc17dbff20ad1 \ 620 | --hash=sha256:2a7b2f2f56a16a6d62e55354dd329d929560442bd92e87397b7a9586a32e3e76 \ 621 | --hash=sha256:2f4eb548daf4836e3b2c662033bfbfc551db58d30fd8fe660314f86bf8510b93 \ 622 | --hash=sha256:3664d126d3388a887db44c2e293f87d500c4184ec43d5d14d2d2babdb4c64cad \ 623 | --hash=sha256:3677fcca7fb728c86a78660c7fb1b07b69b281964673f486ae72860e13f512ad \ 624 | --hash=sha256:380e0df2e9d5d5d339803cfc6d183a5442ad7ab3c63c2a0982e8c824566c5ccc \ 625 | --hash=sha256:3ac732390d529d8469b831949c78085b034bff67f584559340008d0f6041a049 \ 626 | --hash=sha256:4128980a14ed805e1b91a7ed551250282a8ddf8201a4e9f8f5b7e6225f54170d \ 627 | --hash=sha256:4341bd7579611cf50e7b20bb8c2e23512a3dc79de987a1f411cb458ab670eb90 \ 628 | --hash=sha256:436474f17733c7dca0fbf096d36ae65277e8645039df12a0fa52445ca494729d \ 629 | --hash=sha256:4dc889a9d8a34758d0fcc9ac86adb97bab3fb7f0c4d29794357eb147536483fd \ 630 | --hash=sha256:4e21b76075c01d65d0f0f34302b5a7457d95721d5e0667aea65e5bb3ab415c25 \ 631 | --hash=sha256:516fb8c77805159e97a689e2f1c80655c7658f5af601c34ffdb916605598cda2 \ 632 | --hash=sha256:5576ee2f3a309d2bb403ec292d5958ce03953b0e57a11d224c1f134feaf8c40f \ 633 | --hash=sha256:5a024fa96d541fd7edaa0e9d904601c6445e95a729a2900c5aec6555fe921ed6 \ 634 | --hash=sha256:5d0e8a6434a3fbf77d11448c9c25b2f25244226cfbec1a5159947cac5b8c5fa4 \ 635 | --hash=sha256:5e7d63ec01fe7c76c2dbb7e972fece45acbb8836e72682bde138e7e039906e2c \ 636 | --hash=sha256:60e820ee1004327609b28db8307acc27f5f2e9a0b185b2064c5f23e815f248f8 \ 637 | --hash=sha256:637b802f3f069a64436d432117a7e58fab414b4e27a7e81049817ae94de45d8d \ 638 | --hash=sha256:65dcf105c1943cba45d19207ef51b8bc46d232a381e94dd38719d52d3980015b \ 639 | --hash=sha256:698ea95a60c8b16b58be9d854c9f993c639f5c214cf9ba782eca53a8789d6b19 \ 640 | --hash=sha256:70fcc6c2906cfa5c6a552ba7ae2ce64b6c32f437d8f3f8eea49925b278a61453 \ 641 | --hash=sha256:720215373a280f78a1814becb1312d4e4d1077b1202a56d2b0815e95ccb99ce9 \ 642 | --hash=sha256:7450dbd659fed6dd41d1a7d47ed767e893ba402af8ae664c157c255ec6067fde \ 643 | --hash=sha256:7b7d9ca34542099b4e185b3c2a2b2eda2e318a7dbde0b0d83357a6d4421b5296 \ 644 | --hash=sha256:7fbd70cb8b54fe745301921b0816c08b6d917593429dfc437fd024b5ba713c58 \ 645 | --hash=sha256:81038ff87a4e04c22e1d81f947c6ac46f122e0c80460b9006e6517c4d842a6ec \ 646 | --hash=sha256:810685321f4a304b2b55577c915bece4c4a06dfe38f6e62d9cc1d6ca8ee86b99 \ 647 | --hash=sha256:82ada4a8ed9e82e443fcef87e22a3eed3654dd3adf6e3b3a0deb70f03e86142a \ 648 | --hash=sha256:841320e1841bb53fada91c9725e766bb25009cfd4144e92298db296fb6c894fb \ 649 | --hash=sha256:8587fd64c2a91c33cdc39d0cebdaf30e79491cc029a37fcd458ba863f8815383 \ 650 | --hash=sha256:8ffe53e1d8ef2520ebcf0c9fec15bb721da59e8ef283b6ff3079613b1e30513d \ 651 | --hash=sha256:9051e3d2af8f55b42061603e29e744724cb5f65b128a491446cc029b3e2ea896 \ 652 | --hash=sha256:91e5a8200e65aaac342a791272c564dffcf1281abd635d304d6c4e6b495f29dc \ 653 | --hash=sha256:93432e747fb07fa567ad9cc7aaadd6e29710e515aabf939dfbed8046041346c6 \ 654 | --hash=sha256:938eab7323a736533f015e6069a7d53ef2dcc841e4e533b782c2bfb9fb12d84b \ 655 | --hash=sha256:9584f8f52010295a4a417221861df9bea4c72d9632562b6e59b3c7b87a1522b7 \ 656 | --hash=sha256:9737bdaa0ad33d34c0efc718741abaafce62fadae72c8b251df9b0c823c63b22 \ 657 | --hash=sha256:99da0a4686ada4ed0f778120a0ea8d066de1a0a92ab0d13ae68492a437db78bf \ 658 | --hash=sha256:99f567dae93e10be2daaa896e07513dd4bf9c2ecf0576e0533ac36ba3b1d5394 \ 659 | --hash=sha256:9bdf1303df671179eaf2cb41e8515a07fc78d9d00f111eadbe3e14262f59c3d0 \ 660 | --hash=sha256:9f0e4dc0f17dcea4ab9d13ac5c666b6b5337042b4d8f27e01b70fae41dd65c57 \ 661 | --hash=sha256:a000133a90eea274a6f28adc3084643263b1e7c1a5a66eb0a0a7a36aa757ed74 \ 662 | --hash=sha256:a3264e3e858de4fc601741498215835ff324ff2482fd4e4af61b46512dd7fc83 \ 663 | --hash=sha256:a71169d505af63bb4d20d23a8fbd4c6ce272e7bce6cc31f617152aa784436f29 \ 664 | --hash=sha256:a967dd6afda7715d911c25a6ba1517975acd8d1092b2f326718725461a3d33f9 \ 665 | --hash=sha256:aa5bfb13f1e89151ade0eb812f7b0d7a4d643406caaad65ce1cbabe0a66d695f \ 666 | --hash=sha256:ae35e8e6801c5ab071b992cb2da958eee76340e6926ec693b5ff7d6381441745 \ 667 | --hash=sha256:b686f25377f9c006acbac63f61614416a6317133ab7fafe5de5f7dc8a06d42eb \ 668 | --hash=sha256:b760a56e080a826c2e5af09002c1a037382ed21d03134eb6294812dda268c811 \ 669 | --hash=sha256:b86b21b348f7e5485fae740d845c65a880f5d1eda1e063bc59bef92d1f7d0c55 \ 670 | --hash=sha256:b9412abdf0ba70faa6e2ee6c0cc62a8defb772e78860cef419865917d86c7342 \ 671 | --hash=sha256:bd345a13ce06e94c753dab52f8e71e5252aec1e4f8022d24d56decd31e1b9b23 \ 672 | --hash=sha256:be22ae34d68544df293152b7e50895ba70d2a833ad9566932d750d3625918b82 \ 673 | --hash=sha256:bf046179d011e6114daf12a534d874958b039342b347348a78b7cdf0dd9d6041 \ 674 | --hash=sha256:c3d2010656999b63e628a3c694f23020322b4178c450dc478558a2b6ef3cb9bb \ 675 | --hash=sha256:c64602e8be701c6cfe42064b71c84ce62ce66ddc6422c15463fd8127db3d8066 \ 676 | --hash=sha256:d65e6b4f1443048eb7e833c2accb4fa7ee67cc7d54f31b4f0555b474758bee55 \ 677 | --hash=sha256:d8bbd8e56f3ba25a7d0cf980fc42b34028848a53a0e36c9918550e0280b9d0b6 \ 678 | --hash=sha256:da1ead63368c04a9bded7904757dfcae01eba0e0f9bc41d3d7f57ebf1c04015a \ 679 | --hash=sha256:dbbb95e6fc91ea3102505d111b327004d1c4ce98d56a4a02e82cd451f9f57140 \ 680 | --hash=sha256:dbc56680ecf585a384fbd93cd42bc82668b77cb525343170a2d86dafaed2a84b \ 681 | --hash=sha256:df3b6f45ba4515632c5064e35ca7f31d51d13d1479673185ba8f9fefbbed58b9 \ 682 | --hash=sha256:dfe07308b311a8293a0d5ef4e61411c5c20f682db6b5e73de6c7c8824272c256 \ 683 | --hash=sha256:e796051f2070f47230c745d0a77a91088fbee2cc0502e9b796b9c6471983718c \ 684 | --hash=sha256:efa767c220d94aa4ac3a6dd3aeb986e9f229eaf5bce92d8b1b3018d06bed3772 \ 685 | --hash=sha256:f0b8bf5b8db49d8fd40f54772a1dcf262e8be0ad2ab0206b5a2ec109c176c0a4 \ 686 | --hash=sha256:f175e95a197f6a4059b50757a3dca33b32b61691bdbd22c29e8a8d21d3914cae \ 687 | --hash=sha256:f2f3b28b40fddcb6c1f1f6c88c6f3769cd933fa493ceb79da45968a21dccc920 \ 688 | --hash=sha256:f6c43b6f97209e370124baf2bf40bb1e8edc25311a158867eb1c3a5d449ebc7a \ 689 | --hash=sha256:f7f4cb1f173385e8a39c29510dd11a78bf44e360fb75610594973f5ea141028b \ 690 | --hash=sha256:fad059a4bd14c45776600d223ec194e77db6c20255578bb5bcdd7c18fd169361 \ 691 | --hash=sha256:ff1dcb8e8bc2261a088821b2595ef031c91d499a0c1b031c152d43fe0a6ecec8 \ 692 | --hash=sha256:ffee088ea9b593cc6160518ba9bd319b5475e5f3e578e4552d63818773c6f56a 693 | # via 694 | # jsonschema 695 | # referencing 696 | rsa==4.9 \ 697 | --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ 698 | --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 699 | # via google-auth 700 | sentry-sdk[sqlalchemy]==2.27.0 \ 701 | --hash=sha256:90f4f883f9eff294aff59af3d58c2d1b64e3927b28d5ada2b9b41f5aeda47daf \ 702 | --hash=sha256:c58935bfff8af6a0856d37e8adebdbc7b3281c2b632ec823ef03cd108d216ff0 703 | # via kinto 704 | six==1.16.0 \ 705 | --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ 706 | --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 707 | # via python-dateutil 708 | sqlalchemy==2.0.40 \ 709 | --hash=sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a \ 710 | --hash=sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d \ 711 | --hash=sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2 \ 712 | --hash=sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e \ 713 | --hash=sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26 \ 714 | --hash=sha256:1abb387710283fc5983d8a1209d9696a4eae9db8d7ac94b402981fe2fe2e39ad \ 715 | --hash=sha256:1ffdf9c91428e59744f8e6f98190516f8e1d05eec90e936eb08b257332c5e870 \ 716 | --hash=sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0 \ 717 | --hash=sha256:2cbafc8d39ff1abdfdda96435f38fab141892dc759a2165947d1a8fffa7ef596 \ 718 | --hash=sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a \ 719 | --hash=sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a \ 720 | --hash=sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4 \ 721 | --hash=sha256:37a5c21ab099a83d669ebb251fddf8f5cee4d75ea40a5a1653d9c43d60e20867 \ 722 | --hash=sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a \ 723 | --hash=sha256:46628ebcec4f23a1584fb52f2abe12ddb00f3bb3b7b337618b80fc1b51177aff \ 724 | --hash=sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705 \ 725 | --hash=sha256:4aeb939bcac234b88e2d25d5381655e8353fe06b4e50b1c55ecffe56951d18c2 \ 726 | --hash=sha256:50f5885bbed261fc97e2e66c5156244f9704083a674b8d17f24c72217d29baf5 \ 727 | --hash=sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51 \ 728 | --hash=sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00 \ 729 | --hash=sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364 \ 730 | --hash=sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011 \ 731 | --hash=sha256:574aea2c54d8f1dd1699449f332c7d9b71c339e04ae50163a3eb5ce4c4325ee4 \ 732 | --hash=sha256:5cfa124eda500ba4b0d3afc3e91ea27ed4754e727c7f025f293a22f512bcd4c9 \ 733 | --hash=sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1 \ 734 | --hash=sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad \ 735 | --hash=sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1 \ 736 | --hash=sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716 \ 737 | --hash=sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0 \ 738 | --hash=sha256:7e0505719939e52a7b0c65d20e84a6044eb3712bb6f239c6b1db77ba8e173a37 \ 739 | --hash=sha256:8b6b28d303b9d57c17a5164eb1fd2d5119bb6ff4413d5894e74873280483eeb5 \ 740 | --hash=sha256:8bb131ffd2165fae48162c7bbd0d97c84ab961deea9b8bab16366543deeab625 \ 741 | --hash=sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01 \ 742 | --hash=sha256:9408fd453d5f8990405cc9def9af46bfbe3183e6110401b407c2d073c3388f47 \ 743 | --hash=sha256:957f8d85d5e834397ef78a6109550aeb0d27a53b5032f7a57f2451e1adc37e98 \ 744 | --hash=sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1 \ 745 | --hash=sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d \ 746 | --hash=sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500 \ 747 | --hash=sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af \ 748 | --hash=sha256:ae9597cab738e7cc823f04a704fb754a9249f0b6695a6aeb63b74055cd417a96 \ 749 | --hash=sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758 \ 750 | --hash=sha256:b5a5bbe29c10c5bfd63893747a1bf6f8049df607638c786252cb9243b86b6706 \ 751 | --hash=sha256:baf7cee56bd552385c1ee39af360772fbfc2f43be005c78d1140204ad6148438 \ 752 | --hash=sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db \ 753 | --hash=sha256:bece9527f5a98466d67fb5d34dc560c4da964240d8b09024bb21c1246545e04e \ 754 | --hash=sha256:c0cae71e20e3c02c52f6b9e9722bca70e4a90a466d59477822739dc31ac18b4b \ 755 | --hash=sha256:c268b5100cfeaa222c40f55e169d484efa1384b44bf9ca415eae6d556f02cb08 \ 756 | --hash=sha256:c7b927155112ac858357ccf9d255dd8c044fd9ad2dc6ce4c4149527c901fa4c3 \ 757 | --hash=sha256:c884de19528e0fcd9dc34ee94c810581dd6e74aef75437ff17e696c2bfefae3e \ 758 | --hash=sha256:cd2f75598ae70bcfca9117d9e51a3b06fe29edd972fdd7fd57cc97b4dbf3b08a \ 759 | --hash=sha256:cf0e99cdb600eabcd1d65cdba0d3c91418fee21c4aa1d28db47d095b1064a7d8 \ 760 | --hash=sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00 \ 761 | --hash=sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191 \ 762 | --hash=sha256:f0fda83e113bb0fb27dc003685f32a5dcb99c9c4f41f4fa0838ac35265c23b5c \ 763 | --hash=sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7 \ 764 | --hash=sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e \ 765 | --hash=sha256:fe147fcd85aaed53ce90645c91ed5fca0cc88a797314c70dfd9d35925bd5d106 766 | # via sentry-sdk 767 | statsd==4.0.1 \ 768 | --hash=sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128 \ 769 | --hash=sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093 770 | # via kinto 771 | transaction==4.0 \ 772 | --hash=sha256:68035db913f60d1be12f6563d201daab36c83e763de15899ff8338f26e5e62f2 \ 773 | --hash=sha256:e2519a316a05b14b3d483ac777df311087daaffeeafd3e9f7de62fc087ce3209 774 | # via 775 | # kinto 776 | # pyramid-tm 777 | translationstring==1.4 \ 778 | --hash=sha256:5f4dc4d939573db851c8d840551e1a0fb27b946afe3b95aafc22577eed2d6262 \ 779 | --hash=sha256:bf947538d76e69ba12ab17283b10355a9ecfbc078e6123443f43f2107f6376f3 780 | # via 781 | # colander 782 | # pyramid 783 | typing-extensions==4.13.2 \ 784 | --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ 785 | --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef 786 | # via sqlalchemy 787 | urllib3==2.2.2 \ 788 | --hash=sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472 \ 789 | --hash=sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168 790 | # via 791 | # requests 792 | # sentry-sdk 793 | venusian==3.1.0 \ 794 | --hash=sha256:d1fb1e49927f42573f6c9b7c4fcf61c892af8fdcaa2314daa01d9a560b23488d \ 795 | --hash=sha256:eb72cdca6f3139a15dc80f9c95d3c10f8a54a0ba881eeef8e2ec5b42d3ee3a95 796 | # via pyramid 797 | waitress==3.0.1 \ 798 | --hash=sha256:26cdbc593093a15119351690752c99adc13cbc6786d75f7b6341d1234a3730ac \ 799 | --hash=sha256:ef0c1f020d9f12a515c4ec65c07920a702613afcad1dbfdc3bcec256b6c072b3 800 | # via kinto 801 | webob==1.8.8 \ 802 | --hash=sha256:2abc1555e118fc251e705fc6dc66c7f5353bb9fbfab6d20e22f1c02b4b71bcee \ 803 | --hash=sha256:b60ba63f05c0cf61e086a10c3781a41fcfe30027753a8ae6d819c77592ce83ea 804 | # via pyramid 805 | werkzeug==3.1.3 \ 806 | --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ 807 | --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 808 | # via kinto 809 | zope-deprecation==5.0 \ 810 | --hash=sha256:28c2ee983812efb4676d33c7a8c6ade0df191c1c6d652bbbfe6e2eeee067b2d4 \ 811 | --hash=sha256:b7c32d3392036b2145c40b3103e7322db68662ab09b7267afe1532a9d93f640f 812 | # via pyramid 813 | zope-interface==6.1 \ 814 | --hash=sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff \ 815 | --hash=sha256:13b7d0f2a67eb83c385880489dbb80145e9d344427b4262c49fbf2581677c11c \ 816 | --hash=sha256:1f294a15f7723fc0d3b40701ca9b446133ec713eafc1cc6afa7b3d98666ee1ac \ 817 | --hash=sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f \ 818 | --hash=sha256:2f8d89721834524a813f37fa174bac074ec3d179858e4ad1b7efd4401f8ac45d \ 819 | --hash=sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309 \ 820 | --hash=sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736 \ 821 | --hash=sha256:387545206c56b0315fbadb0431d5129c797f92dc59e276b3ce82db07ac1c6179 \ 822 | --hash=sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb \ 823 | --hash=sha256:57d0a8ce40ce440f96a2c77824ee94bf0d0925e6089df7366c2272ccefcb7941 \ 824 | --hash=sha256:5a804abc126b33824a44a7aa94f06cd211a18bbf31898ba04bd0924fbe9d282d \ 825 | --hash=sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92 \ 826 | --hash=sha256:6af47f10cfc54c2ba2d825220f180cc1e2d4914d783d6fc0cd93d43d7bc1c78b \ 827 | --hash=sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41 \ 828 | --hash=sha256:70d2cef1bf529bff41559be2de9d44d47b002f65e17f43c73ddefc92f32bf00f \ 829 | --hash=sha256:7ebc4d34e7620c4f0da7bf162c81978fce0ea820e4fa1e8fc40ee763839805f3 \ 830 | --hash=sha256:964a7af27379ff4357dad1256d9f215047e70e93009e532d36dcb8909036033d \ 831 | --hash=sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8 \ 832 | --hash=sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3 \ 833 | --hash=sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1 \ 834 | --hash=sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1 \ 835 | --hash=sha256:a41f87bb93b8048fe866fa9e3d0c51e27fe55149035dcf5f43da4b56732c0a40 \ 836 | --hash=sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d \ 837 | --hash=sha256:ad54ed57bdfa3254d23ae04a4b1ce405954969c1b0550cc2d1d2990e8b439de1 \ 838 | --hash=sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605 \ 839 | --hash=sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7 \ 840 | --hash=sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd \ 841 | --hash=sha256:c9559138690e1bd4ea6cd0954d22d1e9251e8025ce9ede5d0af0ceae4a401e43 \ 842 | --hash=sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0 \ 843 | --hash=sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b \ 844 | --hash=sha256:e441e8b7d587af0414d25e8d05e27040d78581388eed4c54c30c0c91aad3a379 \ 845 | --hash=sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a \ 846 | --hash=sha256:ef43ee91c193f827e49599e824385ec7c7f3cd152d74cb1dfe02cb135f264d83 \ 847 | --hash=sha256:ef467d86d3cfde8b39ea1b35090208b0447caaabd38405420830f7fd85fbdd56 \ 848 | --hash=sha256:f89b28772fc2562ed9ad871c865f5320ef761a7fcc188a935e21fe8b31a38ca9 \ 849 | --hash=sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de 850 | # via 851 | # pyramid 852 | # transaction 853 | 854 | # The following packages are considered to be unsafe in a requirements file: 855 | setuptools==80.8.0 \ 856 | --hash=sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257 \ 857 | --hash=sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0 858 | # via 859 | # pyramid 860 | # zope-deprecation 861 | # zope-interface 862 | -------------------------------------------------------------------------------- /scripts/create_account.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from kinto_http import cli_utils 4 | 5 | DEFAULT_SERVER = "http://localhost:8888/v1" 6 | 7 | 8 | def main(): 9 | parser = cli_utils.add_parser_options( 10 | description='Create account on Kinto', 11 | default_server=DEFAULT_SERVER) 12 | 13 | args = parser.parse_args() 14 | 15 | user, password = args.auth 16 | 17 | client = cli_utils.create_client_from_args(args) 18 | root_url = client.get_endpoint('root') 19 | account_url = root_url + "/accounts/" + user 20 | resp, _ = client.session.request(method='PUT', endpoint=account_url, payload={ 21 | "data": {"password": password} 22 | }) 23 | pprint.pprint(resp) 24 | 25 | 26 | if __name__ == '__main__': 27 | main() 28 | -------------------------------------------------------------------------------- /scripts/delete.py: -------------------------------------------------------------------------------- 1 | from kinto_http import cli_utils 2 | 3 | DEFAULT_SERVER = "http://localhost:8888/v1" 4 | 5 | 6 | def delete_attachments(client, records): 7 | for record in records: 8 | record_uri = client.get_endpoint('record', id=record['id']) 9 | client.session.request(method='delete', 10 | endpoint=record_uri + '/attachment') 11 | 12 | record = client.get_record(id=record['id'])["data"] 13 | assert record["attachment"] is None 14 | 15 | 16 | def main(): 17 | parser = cli_utils.add_parser_options( 18 | description='Delete files from Kinto', 19 | default_server=DEFAULT_SERVER) 20 | 21 | args = parser.parse_args() 22 | 23 | client = cli_utils.create_client_from_args(args) 24 | existing = client.get_records() 25 | delete_attachments(client, existing) 26 | 27 | 28 | if __name__ == '__main__': 29 | main() 30 | -------------------------------------------------------------------------------- /scripts/download.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | import requests 5 | from kinto_http import cli_utils 6 | 7 | 8 | def sha256(content): 9 | m = hashlib.sha256() 10 | m.update(content) 11 | return m.hexdigest() 12 | 13 | 14 | def download_files(client, records, folder, chunk_size=1024): 15 | 16 | for record in records: 17 | if 'attachment' not in record: 18 | continue 19 | 20 | attachment = record['attachment'] 21 | filename = attachment['filename'] 22 | remote_hash = attachment['hash'] 23 | 24 | destination = os.path.join(folder, filename) 25 | 26 | # Compare local hash with remote and skip if equal. 27 | if os.path.exists(destination): 28 | local_hash = sha256(open(destination, 'rb').read()) 29 | if local_hash == remote_hash: 30 | print('Skip "%s". Up-to-date.' % filename) 31 | continue 32 | 33 | # Download remote attachment by chunk. 34 | resp = requests.get(attachment['location'], stream=True) 35 | resp.raise_for_status() 36 | tmp_file = destination + '.tmp' 37 | with open(tmp_file, 'wb') as f: 38 | for chunk in resp.iter_content(chunk_size=chunk_size): 39 | if chunk: 40 | f.write(chunk) 41 | os.rename(tmp_file, destination) 42 | print('Downloaded "%s"' % filename) 43 | 44 | 45 | def main(): 46 | parser = cli_utils.add_parser_options( 47 | description='Download files from Kinto') 48 | parser.add_argument('-f', '--folder', help='Folder to download files in.', 49 | type=str, default=".") 50 | args = parser.parse_args() 51 | 52 | client = cli_utils.create_client_from_args(args) 53 | 54 | # See if timestamp was saved from last run. 55 | last_sync = None 56 | timestamp_file = os.path.join(args.folder, '.last_sync') 57 | if os.path.exists(args.folder): 58 | if os.path.exists(timestamp_file): 59 | last_sync = open(timestamp_file, 'r').read() 60 | else: 61 | os.makedirs(args.folder) 62 | 63 | # Retrieve the collection of records. 64 | existing = client.get_records(_since=last_sync, _sort="-last_modified") 65 | 66 | if existing: 67 | download_files(client, existing, args.folder) 68 | 69 | timestamp = max([r['last_modified'] for r in existing]) 70 | # Save the highest timestamp for next runs. 71 | with open(timestamp_file, 'w') as f: 72 | f.write("%s" % timestamp) 73 | 74 | 75 | if __name__ == '__main__': 76 | main() 77 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | kinto-http 2 | -------------------------------------------------------------------------------- /scripts/upload.py: -------------------------------------------------------------------------------- 1 | import json 2 | import hashlib 3 | import mimetypes 4 | import os 5 | import pprint 6 | import uuid 7 | 8 | from kinto_http import cli_utils 9 | from kinto_http.exceptions import KintoException 10 | 11 | DEFAULT_SERVER = "http://localhost:8888/v1" 12 | 13 | 14 | def sha256(content): 15 | m = hashlib.sha256() 16 | m.update(content) 17 | return m.hexdigest() 18 | 19 | 20 | def files_to_upload(records, files, force=False): 21 | records_by_id = {r['id']: r for r in records if 'attachment' in r} 22 | existing_files = {r['attachment']['filename']: r for r in records if 'attachment' in r} 23 | existing_original_files = {r['attachment']['original']['filename']: r 24 | for r in records 25 | if 'attachment' in r and 'original' in r['attachment']} 26 | to_upload = [] 27 | for filepath in files: 28 | filename = os.path.basename(filepath) 29 | 30 | record = None 31 | if filename in existing_files.keys(): 32 | record = existing_files[filename] 33 | elif filename in existing_original_files.keys(): 34 | record = existing_original_files[filename] 35 | 36 | if record: 37 | records_by_id.pop(record['id'], None) 38 | local_hash = sha256(open(filepath, 'rb').read()) 39 | 40 | remote_hash = record['attachment']['hash'] 41 | # If hash has changed, upload ! 42 | if local_hash != remote_hash or force: 43 | print("File '%s' has changed." % filename) 44 | to_upload.append((filepath, record)) 45 | else: 46 | print("File '%s' is up-to-date." % filename) 47 | else: 48 | identifier = hashlib.md5(filename.encode('utf-8')).hexdigest() 49 | record_id = str(uuid.UUID(identifier)) 50 | record = {'id': record_id} 51 | to_upload.append((filepath, record)) 52 | 53 | # XXX: add option to delete records when files are missing locally 54 | for id, record in records_by_id.items(): 55 | print("Ignore remote file '%s'." % record['attachment']['filename']) 56 | 57 | return to_upload 58 | 59 | 60 | def upload_files(client, files): 61 | permissions = {} # XXX: Permissions are inherited from collection. 62 | 63 | for filepath, record in files: 64 | mimetype, _ = mimetypes.guess_type(filepath) 65 | filename = os.path.basename(filepath) 66 | filecontent = open(filepath, "rb").read() 67 | record_uri = client.get_endpoint('record', id=record['id']) 68 | attachment_uri = '%s/attachment' % record_uri 69 | multipart = [("attachment", (filename, filecontent, mimetype))] 70 | try: 71 | body, _ = client.session.request(method='post', 72 | endpoint=attachment_uri, 73 | permissions=json.dumps(permissions), 74 | files=multipart) 75 | except KintoException as e: 76 | print(filepath, "error during upload.", e) 77 | else: 78 | pprint.pprint({"id": record['id'], "attachment": body}) 79 | 80 | 81 | def main(): 82 | parser = cli_utils.add_parser_options( 83 | description='Upload files to Kinto', 84 | default_server=DEFAULT_SERVER) 85 | 86 | parser.add_argument('--force', dest='force', action='store_true', 87 | help='Force upload even if the hash matches') 88 | parser.add_argument('files', metavar='FILE', action='store', 89 | nargs='+') 90 | args = parser.parse_args() 91 | 92 | client = cli_utils.create_client_from_args(args) 93 | 94 | try: 95 | client.create_bucket(if_not_exists=True) 96 | client.create_collection(if_not_exists=True) 97 | except KintoException: 98 | # Fail silently in case of 403 99 | pass 100 | 101 | existing = client.get_records() 102 | to_upload = files_to_upload(existing, args.files, force=args.force) 103 | upload_files(client, to_upload) 104 | 105 | 106 | if __name__ == '__main__': 107 | main() 108 | -------------------------------------------------------------------------------- /src/kinto_attachment/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import pkg_resources 4 | from kinto.core import metrics as core_metrics 5 | from pyramid.events import ApplicationCreated 6 | from pyramid.exceptions import ConfigurationError 7 | from pyramid.settings import asbool 8 | from pyramid_storage.interfaces import IFileStorage 9 | 10 | 11 | #: Module version, as defined in PEP-0396. 12 | __version__ = pkg_resources.get_distribution(__package__).version 13 | 14 | 15 | def includeme(config): 16 | # Process settings to remove storage wording. 17 | settings = config.get_settings() 18 | 19 | storage_settings = {} 20 | config.registry.attachment_resources = defaultdict(dict) 21 | 22 | for setting_name, setting_value in settings.items(): 23 | if setting_name.startswith("attachment."): 24 | if setting_name.startswith("attachment.resources."): 25 | # Resource specific config 26 | parts = setting_name.replace("attachment.resources.", "").split(".") 27 | # attachment.resources.{bid}.randomize 28 | # attachment.resources.{bid}.{cid}.randomize 29 | if len(parts) == 3: 30 | bucket_id, collection_id, name = parts 31 | resource_id = "/buckets/{}/collections/{}".format(bucket_id, collection_id) 32 | elif len(parts) == 2: 33 | bucket_id, name = parts 34 | resource_id = "/buckets/{}".format(bucket_id) 35 | else: 36 | message = "Configuration rule malformed: `{}`".format(setting_name) 37 | raise ConfigurationError(message) 38 | 39 | if name in ("randomize", "keep_old_files"): 40 | config.registry.attachment_resources[resource_id][name] = asbool(setting_value) 41 | else: 42 | message = "`{}` is not a supported setting name. Read `{}`".format( 43 | name, setting_name 44 | ) 45 | raise ConfigurationError(message) 46 | else: 47 | setting_name = setting_name.replace("attachment.", "storage.") 48 | storage_settings[setting_name] = setting_value 49 | 50 | # Force some pyramid_storage settings. 51 | storage_settings["storage.name"] = "attachment" 52 | storage_settings.setdefault("storage.extensions", "default") 53 | config.add_settings(storage_settings) 54 | 55 | # It may be useful to define an additional base_url setting. 56 | # (see workaround about relative base_url in README) 57 | extra_base_url = settings.get("attachment.extra.base_url", settings.get("attachment.base_url")) 58 | 59 | # Force trailing slash since attachment locations have no leading slash. 60 | if extra_base_url and not extra_base_url.endswith("/"): 61 | extra_base_url += "/" 62 | 63 | # Expose capability. 64 | config.add_api_capability( 65 | "attachments", 66 | version=__version__, 67 | description="Add file attachments to records", 68 | url="https://github.com/Kinto/kinto-attachment/", 69 | base_url=extra_base_url, 70 | ) 71 | 72 | # Register heartbeat to check attachments storage. 73 | from kinto_attachment.views import attachments_ping 74 | 75 | config.registry.heartbeats["attachments"] = attachments_ping 76 | 77 | # Enable attachment backend. 78 | if "storage.base_path" in storage_settings: 79 | config.include("pyramid_storage.local") 80 | elif "storage.gcloud.credentials" in storage_settings: 81 | config.include("pyramid_storage.gcloud") 82 | else: 83 | config.include("pyramid_storage.s3") 84 | 85 | def on_app_created(event): 86 | """Enable backend metrics when the app starts""" 87 | config = event.app 88 | metrics_service = config.registry.metrics 89 | storage_impl = config.registry.getUtility(IFileStorage) 90 | core_metrics.watch_execution_time(metrics_service, storage_impl, prefix="backend") 91 | 92 | config.add_subscriber(on_app_created, ApplicationCreated) 93 | 94 | config.scan("kinto_attachment.views") 95 | config.scan("kinto_attachment.listeners") 96 | -------------------------------------------------------------------------------- /src/kinto_attachment/listeners.py: -------------------------------------------------------------------------------- 1 | from kinto.core.errors import http_error 2 | from kinto.core.events import ACTIONS, ResourceChanged 3 | from pyramid.events import subscriber 4 | from pyramid.exceptions import HTTPBadRequest 5 | from pyramid.settings import asbool 6 | 7 | from . import utils 8 | 9 | 10 | # XXX: Use AfterResourceChanged when implemented. 11 | @subscriber( 12 | ResourceChanged, 13 | for_resources=("record", "collection", "bucket"), 14 | for_actions=(ACTIONS.DELETE,), 15 | ) 16 | def on_delete_record(event): 17 | """When a resource record is deleted, delete all related attachments. 18 | When a bucket or collection is deleted, it removes the attachments of 19 | every underlying records. 20 | """ 21 | keep_old_files = asbool(utils.setting_value(event.request, "keep_old_files", default=False)) 22 | 23 | # Retrieve attachments for these records using links. 24 | resource_name = event.payload["resource_name"] 25 | filter_field = "%s_uri" % resource_name 26 | uri = event.payload["uri"] 27 | utils.delete_attachment( 28 | event.request, link_field=filter_field, uri=uri, keep_old_files=keep_old_files 29 | ) 30 | 31 | 32 | @subscriber(ResourceChanged, for_resources=("record",), for_actions=(ACTIONS.UPDATE,)) 33 | def on_update_record(event): 34 | if getattr(event.request, "_attachment_auto_save", False): 35 | # Record attributes are being updated by the plugin itself. 36 | return 37 | 38 | # A user is changing the record, make sure attachment metadata is not 39 | # altered manually. 40 | for change in event.impacted_objects: 41 | attachment_before = change["old"].get("attachment") 42 | attachment_after = change["new"].get("attachment") 43 | if attachment_before and attachment_after: 44 | if attachment_before != attachment_after: 45 | error_msg = "Attachment metadata cannot be modified." 46 | raise http_error(HTTPBadRequest(), message=error_msg) 47 | -------------------------------------------------------------------------------- /src/kinto_attachment/utils.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import hashlib 3 | import json 4 | import os 5 | 6 | from kinto.authorization import RouteFactory 7 | from kinto.core import utils as core_utils 8 | from kinto.core.errors import raise_invalid 9 | from kinto.core.storage import Filter 10 | from kinto.views.records import Record 11 | from pyramid import httpexceptions 12 | from pyramid_storage.exceptions import FileNotAllowed 13 | 14 | 15 | FILE_LINKS = "__attachments__" 16 | 17 | RECORD_PATH = "/buckets/{bucket_id}/collections/{collection_id}/records/{id}" 18 | 19 | DEFAULT_MIMETYPES = { 20 | ".pem": "application/x-pem-file", 21 | ".geojson": "application/geojson", 22 | } 23 | 24 | 25 | class AttachmentRouteFactory(RouteFactory): 26 | def __init__(self, request): 27 | """ 28 | This class is the `context` object being passed to the 29 | :class:`kinto.core.authorization.AuthorizationPolicy`. 30 | 31 | Attachment is not a Kinto resource. 32 | 33 | The required permission is: 34 | * ``write`` if the related record exists; 35 | * ``record:create`` on the related collection otherwise. 36 | """ 37 | super(AttachmentRouteFactory, self).__init__(request) 38 | self.resource_name = "record" 39 | try: 40 | request.current_resource_name = "record" 41 | request.validated.setdefault("header", {}) 42 | request.validated.setdefault("querystring", {}) 43 | resource = Record(request, context=self) 44 | resource.object_id = request.matchdict["id"] 45 | existing = resource.get() 46 | except httpexceptions.HTTPNotFound: 47 | existing = None 48 | 49 | if existing: 50 | # Request write permission on the existing record. 51 | self.permission_object_id = record_uri(request) 52 | self.required_permission = "write" 53 | else: 54 | # Request create record permission on the parent collection. 55 | self.permission_object_id = collection_uri(request) 56 | self.required_permission = "create" 57 | # Set the current object in context, since it is used in the 58 | # authorization policy to distinguish operations on plural endpoints 59 | # from individual objects. See Kinto/kinto#918 60 | self.current_object = existing 61 | 62 | 63 | def sha256(content): 64 | m = hashlib.sha256() 65 | m.update(content) 66 | return m.hexdigest() 67 | 68 | 69 | def _object_uri(request, resource_name, matchdict, prefix): 70 | uri = core_utils.instance_uri(request, resource_name=resource_name, **matchdict) 71 | if prefix: 72 | uri = f"/{request.registry.route_prefix}{uri}" 73 | return uri 74 | 75 | 76 | def bucket_uri(request, prefix=False): 77 | matchdict = dict(request.matchdict) 78 | matchdict["id"] = matchdict["bucket_id"] 79 | return _object_uri(request, "bucket", matchdict, prefix) 80 | 81 | 82 | def collection_uri(request, prefix=False): 83 | matchdict = dict(request.matchdict) 84 | matchdict["id"] = matchdict["collection_id"] 85 | return _object_uri(request, "collection", matchdict, prefix) 86 | 87 | 88 | def record_uri(request, prefix=False): 89 | return _object_uri(request, "record", request.matchdict, prefix) 90 | 91 | 92 | def patch_record(record, request): 93 | # XXX: add util clone_request() 94 | backup_pattern = request.matched_route.pattern 95 | backup_body = request.body 96 | backup_validated = request.validated 97 | 98 | # Instantiate record resource with current request. 99 | context = RouteFactory(request) 100 | context.resource_name = "record" 101 | context.get_permission_object_id = lambda r, i: record_uri(r) 102 | record_pattern = request.matched_route.pattern.replace("/attachment", "") 103 | request.matched_route.pattern = record_pattern 104 | 105 | # Simulate update of fields. 106 | request.validated = dict(body=record, **backup_validated) 107 | 108 | request.body = json.dumps(record).encode("utf-8") 109 | resource = Record(request, context=context) 110 | resource.object_id = request.matchdict["id"] 111 | setattr(request, "_attachment_auto_save", True) # Flag in update listener. 112 | 113 | try: 114 | saved = resource.patch() 115 | except httpexceptions.HTTPNotFound: 116 | saved = resource.put() 117 | 118 | request.matched_route.pattern = backup_pattern 119 | request.body = backup_body 120 | request.validated = backup_validated 121 | return saved 122 | 123 | 124 | def delete_attachment(request, link_field=None, uri=None, keep_old_files=False): 125 | """Delete existing file and link.""" 126 | if link_field is None: 127 | link_field = "record_uri" 128 | if uri is None: 129 | uri = record_uri(request) 130 | 131 | storage = request.registry.storage 132 | filters = [Filter(link_field, uri, core_utils.COMPARISON.EQ)] 133 | 134 | # Remove file. 135 | if not keep_old_files: 136 | file_links = storage.list_all("", FILE_LINKS, filters=filters) 137 | for link in file_links: 138 | request.attachment.delete(link["location"]) 139 | 140 | # Remove link. 141 | storage.delete_all("", FILE_LINKS, filters=filters, with_deleted=False) 142 | 143 | 144 | def save_file(request, content, folder=None, keep_link=True, replace=False): 145 | randomize = setting_value(request, "randomize", default=True) 146 | 147 | overriden_mimetypes = {**DEFAULT_MIMETYPES} 148 | conf_mimetypes = setting_value(request, "mimetypes", default="") 149 | if conf_mimetypes: 150 | overriden_mimetypes.update(dict([v.split(":") for v in conf_mimetypes.split(";")])) 151 | 152 | # Read file to compute hash. 153 | if not isinstance(content, cgi.FieldStorage): 154 | error_msg = "Filename is required." 155 | raise_invalid(request, location="body", description=error_msg) 156 | 157 | # Posted file attributes. 158 | content.file.seek(0) 159 | filecontent = content.file.read() 160 | filehash = sha256(filecontent) 161 | size = len(filecontent) 162 | filename = content.filename 163 | 164 | _, extension = os.path.splitext(filename) 165 | mimetype = overriden_mimetypes.get(extension, content.type) 166 | 167 | save_options = { 168 | "folder": folder, 169 | "randomize": randomize, 170 | "replace": replace, 171 | "headers": {"Content-Type": mimetype}, 172 | } 173 | 174 | try: 175 | location = request.attachment.save(content, **save_options) 176 | except FileNotAllowed: 177 | error_msg = "File extension is not allowed." 178 | raise_invalid(request, location="body", description=error_msg) 179 | 180 | # File metadata. 181 | fullurl = request.attachment.url(location) 182 | attachment = { 183 | "filename": filename, 184 | "location": fullurl, 185 | "hash": filehash, 186 | "mimetype": mimetype, 187 | "size": size, 188 | } 189 | 190 | if keep_link: 191 | # Store link between record and attachment (for later deletion). 192 | request.registry.storage.create( 193 | "", 194 | FILE_LINKS, 195 | { 196 | "location": location, # store relative location. 197 | "bucket_uri": bucket_uri(request), 198 | "collection_uri": collection_uri(request), 199 | "record_uri": record_uri(request), 200 | }, 201 | ) 202 | 203 | return attachment 204 | 205 | 206 | def setting_value(request, name, default): 207 | value = request.registry.settings.get("attachment.{}".format(name), default) 208 | if "bucket_id" in request.matchdict: 209 | uri = "/buckets/{bucket_id}".format(**request.matchdict) 210 | if uri in request.registry.attachment_resources: 211 | value = request.registry.attachment_resources[uri].get(name, value) 212 | if "collection_id" in request.matchdict: 213 | uri = "/buckets/{bucket_id}/collections/{collection_id}".format(**request.matchdict) 214 | if uri in request.registry.attachment_resources: 215 | value = request.registry.attachment_resources[uri].get(name, value) 216 | return value 217 | -------------------------------------------------------------------------------- /src/kinto_attachment/views.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import json 3 | from io import BytesIO 4 | 5 | from kinto.core import Service, logger 6 | from kinto.core.authorization import DYNAMIC as DYNAMIC_PERMISSION 7 | from kinto.core.errors import ERRORS, http_error 8 | from pyramid import httpexceptions 9 | from pyramid.settings import asbool 10 | 11 | from kinto_attachment import utils 12 | 13 | 14 | HEARTBEAT_CONTENT = '{"test": "write"}' 15 | HEARTBEAT_FILENAME = "heartbeat" 16 | SINGLE_FILE_FIELD = "attachment" 17 | 18 | 19 | attachment = Service( 20 | name="attachment", 21 | description="Attach a file to a record", 22 | path=utils.RECORD_PATH + "/attachment", 23 | factory=utils.AttachmentRouteFactory, 24 | ) 25 | 26 | 27 | @attachment.post(permission=DYNAMIC_PERMISSION) 28 | def attachment_post(request): 29 | return post_attachment_view(request, SINGLE_FILE_FIELD) 30 | 31 | 32 | @attachment.delete(permission=DYNAMIC_PERMISSION) 33 | def attachment_delete(request): 34 | return delete_attachment_view(request, SINGLE_FILE_FIELD) 35 | 36 | 37 | def post_attachment_view(request, file_field): 38 | keep_old_files = asbool(utils.setting_value(request, "keep_old_files", default=False)) 39 | 40 | # Remove potential existing attachment. 41 | utils.delete_attachment(request, keep_old_files=keep_old_files) 42 | 43 | if "multipart/form-data" not in request.headers.get("Content-Type", ""): 44 | raise http_error( 45 | httpexceptions.HTTPBadRequest(), 46 | errno=ERRORS.INVALID_PARAMETERS, 47 | message="Content-Type should be multipart/form-data", 48 | ) 49 | 50 | # Store file locally. 51 | try: 52 | content = request.POST.get(file_field) 53 | except ValueError as e: 54 | raise http_error( 55 | httpexceptions.HTTPBadRequest(), errno=ERRORS.INVALID_PARAMETERS.value, message=str(e) 56 | ) 57 | 58 | if content is None: 59 | raise http_error( 60 | httpexceptions.HTTPBadRequest(), 61 | errno=ERRORS.INVALID_POSTED_DATA, 62 | message="Attachment missing.", 63 | ) 64 | 65 | folder_pattern = utils.setting_value(request, "folder", default="") 66 | folder = folder_pattern.format(**request.matchdict) or None 67 | attachment = utils.save_file(request, content, folder=folder) 68 | 69 | # Update related record. 70 | posted_data = {k: v for k, v in request.POST.items() if k != file_field} 71 | record = {"data": {}} 72 | for field in ("data", "permissions"): 73 | if field in posted_data: 74 | try: 75 | record[field] = json.loads(posted_data.pop(field)) 76 | except TypeError: 77 | error_msg = "body: %r field should be passed as form data, not files" % field 78 | raise http_error( 79 | httpexceptions.HTTPBadRequest(), 80 | errno=ERRORS.INVALID_POSTED_DATA, 81 | message=error_msg, 82 | ) 83 | except ValueError as e: 84 | error_msg = "body: %s is not valid JSON (%s)" % (field, str(e)) 85 | raise http_error( 86 | httpexceptions.HTTPBadRequest(), 87 | errno=ERRORS.INVALID_POSTED_DATA, 88 | message=error_msg, 89 | ) 90 | # Some fields remaining in posted_data after popping: invalid! 91 | for field in posted_data.keys(): 92 | error_msg = "body: %r not in ('data', 'permissions')" % field 93 | raise http_error( 94 | httpexceptions.HTTPBadRequest(), errno=ERRORS.INVALID_POSTED_DATA, message=error_msg 95 | ) 96 | 97 | record["data"][file_field] = attachment 98 | 99 | utils.patch_record(record, request) 100 | 101 | # Return attachment data (with location header) 102 | request.response.headers["Location"] = utils.record_uri(request, prefix=True) 103 | return attachment 104 | 105 | 106 | def delete_attachment_view(request, file_field): 107 | keep_old_files = asbool(utils.setting_value(request, "keep_old_files", default=False)) 108 | 109 | utils.delete_attachment(request, keep_old_files=keep_old_files) 110 | 111 | # Remove metadata. 112 | record = {"data": {}} 113 | record["data"][file_field] = None 114 | utils.patch_record(record, request) 115 | 116 | request.response.status = 204 117 | request.response.headers.pop("Content-Type", None) 118 | 119 | 120 | def attachments_ping(request): 121 | """Heartbeat view for the attachments backend. 122 | :returns: ``True`` if succeeds to write and delete, ``False`` otherwise. 123 | """ 124 | # Do nothing if server is readonly. 125 | if asbool(request.registry.settings.get("readonly", False)): 126 | return True 127 | 128 | # We will fake a file upload, so pick a file extension that is allowed. 129 | extensions = request.attachment.extensions or {"json"} 130 | allowed_extension = "." + list(extensions)[-1] 131 | 132 | status = False 133 | try: 134 | content = cgi.FieldStorage() 135 | content.filename = HEARTBEAT_FILENAME + allowed_extension 136 | content.file = BytesIO(HEARTBEAT_CONTENT.encode("utf-8")) 137 | content.type = "application/octet-stream" 138 | 139 | stored = utils.save_file(request, content, keep_link=False, replace=True) 140 | 141 | relative_location = stored["location"].replace(request.attachment.base_url, "") 142 | request.attachment.delete(relative_location) 143 | 144 | status = True 145 | except Exception as e: 146 | logger.exception(e) 147 | return status 148 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse 4 | 5 | import webtest 6 | from kinto.core import testing as core_support 7 | from kinto.core import utils as core_utils 8 | from pyramid_storage.interfaces import IFileStorage 9 | from pyramid_storage.s3 import S3FileStorage 10 | 11 | 12 | SAMPLE_SCHEMA = { 13 | "title": "Font file", 14 | "type": "object", 15 | "properties": { 16 | "family": {"type": "string"}, 17 | "author": {"type": "string"}, 18 | }, 19 | } 20 | 21 | 22 | def build_url(url, **params): 23 | url_parts = list(urlparse(url)) 24 | query = dict(parse_qsl(url_parts[4])) 25 | query.update(params) 26 | url_parts[4] = urlencode(query) 27 | return urlunparse(url_parts) 28 | 29 | 30 | def get_user_headers(user): 31 | credentials = "%s:secret" % user 32 | authorization = "Basic {0}".format(core_utils.encode64(credentials)) 33 | return {"Authorization": authorization} 34 | 35 | 36 | class BaseWebTest(object): 37 | config = "" 38 | 39 | def setUp(self): 40 | super(BaseWebTest, self).setUp() 41 | self.app = self.make_app() 42 | self.backend = self.app.app.registry.getUtility(IFileStorage) 43 | self.base_url = self.backend.url("") 44 | self._created = [] 45 | 46 | self.headers = {"Content-Type": "application/json", "Origin": "http://localhost:9999"} 47 | self.headers.update(get_user_headers("mat")) 48 | 49 | self.create_collection("fennec", "fonts") 50 | self.record_id = _id = str(uuid.uuid4()) 51 | self.record_uri = self.get_record_uri("fennec", "fonts", _id) 52 | self.endpoint_uri = self.record_uri + "/attachment" 53 | self.default_files = [("attachment", "image.jpg", b"--fake--")] 54 | self.file_field = "attachment" 55 | 56 | @property 57 | def nb_uploaded_files(self): 58 | return len(self.default_files) 59 | 60 | def make_app(self): 61 | curdir = os.path.dirname(os.path.realpath(__file__)) 62 | app = webtest.TestApp("config:%s" % self.config, relative_to=curdir) 63 | app.RequestClass = core_support.get_request_class(prefix="v1") 64 | return app 65 | 66 | def upload(self, params=[], files=None, headers={}, status=None): 67 | files = files or self.default_files 68 | headers = headers or self.headers.copy() 69 | content_type, body = self.app.encode_multipart(params, files) 70 | headers["Content-Type"] = content_type 71 | 72 | endpoint_url = self.endpoint_uri 73 | 74 | resp = self.app.post(endpoint_url, body, headers=headers, status=status) 75 | if 200 <= resp.status_code < 300: 76 | self._add_to_cleanup(resp.json) 77 | 78 | return resp 79 | 80 | def _add_to_cleanup(self, attachment): 81 | relativeurl = attachment["location"].replace(self.base_url, "") 82 | self._created.append(relativeurl) 83 | 84 | def create_collection(self, bucket_id, collection_id): 85 | bucket_uri = "/buckets/%s" % bucket_id 86 | self.app.put_json(bucket_uri, {}, headers=self.headers) 87 | collection_uri = bucket_uri + "/collections/%s" % collection_id 88 | collection = {"schema": SAMPLE_SCHEMA} 89 | self.app.put_json(collection_uri, {"data": collection}, headers=self.headers) 90 | 91 | def get_record_uri(self, bucket_id, collection_id, record_id): 92 | return ("/buckets/{bucket_id}/collections/{collection_id}/records/{record_id}").format( 93 | **locals() 94 | ) 95 | 96 | def get_record(self, resp): 97 | # Alias to resp.json, in a separate method to easily be extended. 98 | return resp.json 99 | 100 | 101 | class BaseWebTestLocal(BaseWebTest): 102 | config = "config/local.ini" 103 | 104 | def tearDown(self): 105 | """Delete uploaded local files.""" 106 | super(BaseWebTest, self).tearDown() 107 | basepath = self.app.app.registry.settings["kinto.attachment.base_path"] 108 | for created in self._created: 109 | filepath = os.path.join(basepath, created) 110 | if os.path.exists(filepath): 111 | os.remove(filepath) 112 | 113 | 114 | class BaseWebTestS3(BaseWebTest): 115 | config = "config/s3.ini" 116 | 117 | def __init__(self, *args, **kwargs): 118 | self._s3_bucket_created = False 119 | super(BaseWebTestS3, self).__init__(*args, **kwargs) 120 | 121 | def make_app(self): 122 | app = super(BaseWebTestS3, self).make_app() 123 | 124 | # Create the S3 bucket if necessary 125 | if not self._s3_bucket_created: 126 | prefix = "kinto.attachment." 127 | settings = app.app.registry.settings 128 | fs = S3FileStorage.from_settings(settings, prefix=prefix) 129 | 130 | bucket_name = settings[prefix + "aws.bucket_name"] 131 | fs.get_connection().create_bucket(bucket_name) 132 | self._s3_bucket_created = True 133 | 134 | return app 135 | -------------------------------------------------------------------------------- /tests/config/events.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = egg:kinto 3 | multiauth.policies = basicauth 4 | kinto.userid_hmac_secret = some-secret-string 5 | kinto.includes = kinto.plugins.default_bucket 6 | kinto_attachment 7 | 8 | kinto.attachment.base_path = /tmp 9 | kinto.attachment.base_url = 10 | 11 | kinto.event_listeners = tests 12 | kinto.event_listeners.tests.use = tests.test_events 13 | -------------------------------------------------------------------------------- /tests/config/functional.ini: -------------------------------------------------------------------------------- 1 | [server:main] 2 | use = egg:waitress#main 3 | host = 0.0.0.0 4 | port = 8888 5 | 6 | [app:main] 7 | use = egg:kinto 8 | kinto.userid_hmac_secret = some-secret-string 9 | multiauth.policies = account 10 | multiauth.policy.account.use = kinto.plugins.accounts.AccountsPolicy 11 | kinto.account_create_principals = system.Everyone 12 | kinto.experimental_collection_schema_validation = true 13 | 14 | kinto.storage_backend = kinto.core.storage.postgresql 15 | kinto.storage_url = postgresql://postgres:postgres@localhost/testdb 16 | 17 | kinto.includes = kinto.plugins.default_bucket 18 | kinto.plugins.accounts 19 | kinto.plugins.flush 20 | kinto_attachment 21 | 22 | kinto.attachment.base_path = /tmp 23 | kinto.attachment.base_url = http://localhost:8000/ 24 | 25 | kinto.attachment.folder = {bucket_id}/{collection_id} 26 | -------------------------------------------------------------------------------- /tests/config/local.ini: -------------------------------------------------------------------------------- 1 | [server:main] 2 | use = egg:waitress#main 3 | host = 0.0.0.0 4 | port = 8888 5 | 6 | [app:main] 7 | use = egg:kinto 8 | kinto.userid_hmac_secret = some-secret-string 9 | multiauth.policies = basicauth 10 | kinto.experimental_collection_schema_validation = true 11 | 12 | kinto.includes = kinto.plugins.default_bucket kinto_attachment 13 | 14 | kinto.attachment.base_path = /tmp 15 | kinto.attachment.base_url = https://cdn.firefox.net/ 16 | kinto.attachment.extra.base_url = https://files.server.com/root/ 17 | 18 | kinto.attachment.folder = {bucket_id}/{collection_id} 19 | 20 | kinto.attachment.extensions = default 21 | -------------------------------------------------------------------------------- /tests/config/s3.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = egg:kinto 3 | multiauth.policies = basicauth 4 | kinto.userid_hmac_secret = some-secret-string 5 | 6 | kinto.includes = kinto.plugins.default_bucket 7 | kinto.plugins.prometheus 8 | kinto_attachment 9 | 10 | kinto.attachment.base_url = https://cdn.firefox.net/ 11 | 12 | kinto.attachment.folder = {bucket_id}/{collection_id} 13 | 14 | kinto.attachment.aws.host = localhost 15 | kinto.attachment.aws.port = 6000 16 | kinto.attachment.aws.is_secure = false 17 | kinto.attachment.aws.use_path_style = true 18 | kinto.attachment.aws.access_key = aws 19 | kinto.attachment.aws.secret_key = aws 20 | kinto.attachment.aws.bucket_name = myfiles 21 | 22 | kinto.attachment.mimetypes = .txt:text/vnd.graphviz 23 | -------------------------------------------------------------------------------- /tests/config/s3_per_resource.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = egg:kinto 3 | multiauth.policies = basicauth 4 | kinto.userid_hmac_secret = some-secret-string 5 | 6 | kinto.includes = kinto.plugins.default_bucket 7 | kinto_attachment 8 | 9 | kinto.attachment.base_url = https://cdn.firefox.net/ 10 | 11 | kinto.attachment.folder = {bucket_id}/{collection_id} 12 | 13 | kinto.attachment.aws.host = localhost 14 | kinto.attachment.aws.port = 6000 15 | kinto.attachment.aws.is_secure = false 16 | kinto.attachment.aws.use_path_style = true 17 | kinto.attachment.aws.access_key = aws 18 | kinto.attachment.aws.secret_key = aws 19 | kinto.attachment.aws.bucket_name = myfiles 20 | 21 | kinto.attachment.resources.fennec.randomize = true 22 | kinto.attachment.resources.fennec.experiments.randomize = false 23 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from . import BaseWebTest 4 | 5 | 6 | class Listener(object): 7 | def __init__(self): 8 | self.received = [] 9 | 10 | def __call__(self, event): 11 | self.received.append(event) 12 | 13 | 14 | listener = Listener() 15 | 16 | 17 | def load_from_config(config, prefix): 18 | return listener 19 | 20 | 21 | class ResourceChangedTest(BaseWebTest, unittest.TestCase): 22 | config = "config/events.ini" 23 | 24 | def test_resource_changed_is_triggered_when_attachment_is_set(self): 25 | before = len(listener.received) 26 | self.upload() 27 | self.assertEqual(len(listener.received), before + 1) 28 | 29 | def test_action_is_create_or_update(self): 30 | self.upload() 31 | self.assertEqual(listener.received[-1].payload["action"], "create") 32 | self.upload() 33 | self.assertEqual(listener.received[-1].payload["action"], "update") 34 | 35 | def test_payload_attribute_are_sound(self): 36 | self.upload() 37 | payload = listener.received[-1].payload 38 | self.assertEqual(payload["uri"], self.endpoint_uri) 39 | self.assertEqual(payload["resource_name"], "record") 40 | self.assertEqual(payload["record_id"], self.record_id) 41 | self.assertEqual(payload["collection_id"], "fonts") 42 | self.assertEqual(payload["bucket_id"], "fennec") 43 | -------------------------------------------------------------------------------- /tests/test_plugin_setup.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pytest 4 | from kinto import main as kinto_main 5 | from pyramid import testing 6 | from pyramid.exceptions import ConfigurationError 7 | from pyramid_storage.gcloud import GoogleCloudStorage 8 | from pyramid_storage.interfaces import IFileStorage 9 | from pyramid_storage.s3 import S3FileStorage 10 | 11 | from kinto_attachment import __version__, includeme 12 | 13 | from . import BaseWebTestLocal 14 | 15 | 16 | class HelloViewTest(BaseWebTestLocal, unittest.TestCase): 17 | def test_capability_is_exposed(self): 18 | resp = self.app.get("/") 19 | capabilities = resp.json["capabilities"] 20 | self.assertIn("attachments", capabilities) 21 | expected = { 22 | "version": __version__, 23 | "description": "Add file attachments to records", 24 | "url": "https://github.com/Kinto/kinto-attachment/", 25 | "base_url": "https://files.server.com/root/", 26 | } 27 | self.assertEqual(expected, capabilities["attachments"]) 28 | 29 | 30 | class IncludeMeTest(unittest.TestCase): 31 | def includeme(self, settings): 32 | config = testing.setUp(settings=settings) 33 | kinto_main(None, config=config) 34 | includeme(config) 35 | return config 36 | 37 | def test_includeme_understand_authorized_resources_settings(self): 38 | config = self.includeme( 39 | settings={ 40 | "attachment.base_path": "/tmp", 41 | "attachment.resources.fennec.keep_old_files": "true", 42 | "attachment.resources.fingerprinting.fonts.randomize": "true", 43 | } 44 | ) 45 | assert isinstance(config.registry.attachment_resources, dict) 46 | assert "/buckets/fennec" in config.registry.attachment_resources 47 | assert "/buckets/fingerprinting/collections/fonts" in config.registry.attachment_resources 48 | 49 | def test_includeme_raises_error_for_malformed_resource_settings(self): 50 | with pytest.raises(ConfigurationError) as excinfo: 51 | self.includeme(settings={"attachment.resources.fen.nec.fonts.keep_old_files": "true"}) 52 | assert str(excinfo.value) == ( 53 | "Configuration rule malformed: `attachment.resources.fen.nec.fonts.keep_old_files`" 54 | ) 55 | 56 | def test_includeme_raises_error_if_wrong_resource_settings_is_defined(self): 57 | with pytest.raises(ConfigurationError) as excinfo: 58 | self.includeme(settings={"attachment.resources.fennec.base_path": "foobar"}) 59 | assert str(excinfo.value) == ( 60 | "`base_path` is not a supported setting name. " 61 | "Read `attachment.resources.fennec.base_path`" 62 | ) 63 | 64 | def test_base_url_is_added_a_trailing_slash(self): 65 | config = self.includeme( 66 | settings={ 67 | "attachment.base_path": "/tmp", 68 | "attachment.base_url": "http://cdn.com", 69 | } 70 | ) 71 | assert config.registry.api_capabilities["attachments"]["base_url"] == "http://cdn.com/" 72 | 73 | def test_gcloud_is_used_if_credentials_setting_is_used(self): 74 | config = self.includeme( 75 | settings={ 76 | "attachment.gcloud.credentials": "/path/to/credentials.json", 77 | "attachment.gcloud.bucket_name": "foo", 78 | } 79 | ) 80 | assert isinstance(config.registry.queryUtility(IFileStorage), GoogleCloudStorage) 81 | 82 | def test_s3_is_used_if_base_path_setting_is_not_used(self): 83 | config = self.includeme( 84 | settings={ 85 | "attachment.aws.access_key": "abc", 86 | "attachment.aws.bucket_name": "foo", 87 | } 88 | ) 89 | assert isinstance(config.registry.queryUtility(IFileStorage), S3FileStorage) 90 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | class _Registry(object): 2 | settings = {"attachment.folder": ""} 3 | attachment_resources = {} 4 | 5 | def save(self, *args, **kw): 6 | return "yeahok" 7 | 8 | def url(self, location): 9 | return "http://localhost/%s" % location 10 | 11 | def create(self, *args, **kw): 12 | pass 13 | 14 | @property 15 | def storage(self): 16 | return self 17 | 18 | 19 | class _Request(object): 20 | registry = _Registry() 21 | matchdict = {"bucket_id": "bucket", "collection_id": "collection"} 22 | 23 | attachment = _Registry() 24 | 25 | def route_path(self, *args, **kw): 26 | return "fullpath" 27 | -------------------------------------------------------------------------------- /tests/test_views_attachment.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import uuid 4 | from unittest import mock 5 | from urllib.parse import urlparse 6 | 7 | from kinto.core.errors import ERRORS 8 | from pyramid_storage.interfaces import IFileStorage 9 | 10 | from kinto_attachment.utils import sha256 11 | 12 | from . import BaseWebTestLocal, BaseWebTestS3, get_user_headers 13 | 14 | 15 | class UploadTest(object): 16 | def test_returns_200_to_record_once_uploaded(self): 17 | self.upload(status=201) 18 | 19 | def test_record_is_created_with_metadata(self): 20 | self.upload() 21 | resp = self.app.get(self.record_uri, headers=self.headers) 22 | self.assertIn(self.file_field, resp.json["data"]) 23 | 24 | def test_record_is_created_with_valid_id(self): 25 | self.record_uri = self.get_record_uri("fennec", "fonts", "logo") 26 | self.endpoint_uri = self.record_uri + "/attachment" 27 | self.app.put_json(self.record_uri, {}, headers=self.headers) 28 | self.upload(status=200) 29 | 30 | def test_returns_200_if_record_already_exists(self): 31 | self.app.put_json(self.record_uri, {}, headers=self.headers) 32 | self.upload(status=200) 33 | 34 | def test_adds_cors_and_location_to_response(self): 35 | response = self.upload() 36 | self.assertEqual(response.headers["Location"], "http://localhost/v1" + self.record_uri) 37 | self.assertIn("Access-Control-Allow-Origin", response.headers) 38 | 39 | def test_has_no_subfolder_if_setting_is_undefined(self): 40 | self.app.app.registry.settings.pop("attachment.folder") 41 | response = self.upload() 42 | record = self.get_record(response) 43 | url = urlparse(record["location"]) 44 | self.assertNotIn("/", url.path[1:]) 45 | 46 | def exists(self, fullurl): 47 | location = fullurl.replace(self.base_url, "") 48 | return self.backend.exists(location) 49 | 50 | def test_previous_attachment_is_removed_on_replacement(self): 51 | first = self.get_record(self.upload()) 52 | self.assertTrue(self.exists(first["location"])) 53 | second = self.get_record(self.upload()) 54 | self.assertFalse(self.exists(first["location"])) 55 | self.assertTrue(self.exists(second["location"])) 56 | 57 | 58 | class LocalUploadTest(UploadTest, BaseWebTestLocal, unittest.TestCase): 59 | def test_file_is_created_on_local_filesystem(self): 60 | attachment = self.upload().json 61 | fullurl = attachment["location"] 62 | relativeurl = fullurl.replace(self.base_url, "") 63 | self.assertTrue(os.path.exists(os.path.join("/tmp", relativeurl))) 64 | 65 | def test_file_is_not_gzipped_on_local_filesystem(self): 66 | resp = self.upload(files=[(self.file_field, b"my-report.pdf", b"--binary--")]) 67 | attachment = resp.json 68 | self.assertTrue(attachment["location"].endswith(".pdf")) 69 | self.assertEqual(attachment["mimetype"], "application/pdf") 70 | relativeurl = attachment["location"].replace(self.base_url, "") 71 | self.assertEqual(attachment["hash"], sha256(b"--binary--")) 72 | self.assertEqual(attachment["size"], len(b"--binary--")) 73 | file_path = os.path.join("/tmp", relativeurl) 74 | self.assertTrue(os.path.exists(file_path)) 75 | with open(file_path, "rb") as f: 76 | self.assertEqual(f.read(), b"--binary--") 77 | 78 | 79 | class S3UploadTest(UploadTest, BaseWebTestS3, unittest.TestCase): 80 | pass 81 | 82 | 83 | class DeleteTest(object): 84 | def setUp(self): 85 | super(DeleteTest, self).setUp() 86 | self.attachment = self.upload().json 87 | 88 | def exists(self, fullurl): 89 | location = fullurl.replace(self.base_url, "") 90 | return self.backend.exists(location) 91 | 92 | def test_attachment_is_removed_on_delete(self): 93 | fullurl = self.attachment["location"] 94 | self.assertTrue(self.exists(fullurl)) 95 | self.app.delete(self.endpoint_uri, headers=self.headers, status=204) 96 | self.assertFalse(self.exists(fullurl)) 97 | 98 | def test_metadata_are_removed_on_delete(self): 99 | self.app.delete(self.endpoint_uri, headers=self.headers, status=204) 100 | resp = self.app.get(self.record_uri, headers=self.headers) 101 | self.assertIsNone(resp.json["data"].get("attachment")) 102 | 103 | def test_link_is_removed_on_delete(self): 104 | storage = self.app.app.registry.storage 105 | links = storage.list_all("", "__attachments__") 106 | self.assertEqual(len(links), self.nb_uploaded_files) 107 | self.app.delete(self.endpoint_uri, headers=self.headers, status=204) 108 | links = storage.list_all("", "__attachments__") 109 | self.assertEqual(len(links), 0) 110 | 111 | def test_attachment_is_removed_when_record_is_deleted(self): 112 | fullurl = self.attachment["location"] 113 | self.assertTrue(self.exists(fullurl)) 114 | self.app.delete(self.record_uri, headers=self.headers) 115 | self.assertFalse(self.exists(fullurl)) 116 | 117 | def test_attachments_are_removed_when_bucket_is_deleted(self): 118 | fullurl = self.attachment["location"] 119 | self.assertTrue(self.exists(fullurl)) 120 | self.app.delete("/buckets/fennec", headers=self.headers) 121 | self.assertFalse(self.exists(fullurl)) 122 | 123 | def test_attachments_are_removed_when_collection_is_deleted(self): 124 | fullurl = self.attachment["location"] 125 | self.assertTrue(self.exists(fullurl)) 126 | self.app.delete("/buckets/fennec/collections/fonts", headers=self.headers) 127 | self.assertFalse(self.exists(fullurl)) 128 | 129 | def test_attachments_links_are_removed_forever(self): 130 | storage = self.app.app.registry.storage 131 | links = storage.list_all("", "__attachments__") 132 | self.assertEqual(len(links), self.nb_uploaded_files) 133 | self.app.delete(self.record_uri, headers=self.headers) 134 | links = storage.list_all("", "__attachments__") 135 | self.assertEqual(len(links), 0) 136 | 137 | def test_no_error_when_other_resource_is_deleted(self): 138 | group_url = "/buckets/default/groups/admins" 139 | self.app.put_json(group_url, {"data": {"members": ["them"]}}, headers=self.headers) 140 | self.app.delete(group_url, headers=self.headers) 141 | 142 | 143 | class LocalDeleteTest(DeleteTest, BaseWebTestLocal, unittest.TestCase): 144 | pass 145 | 146 | 147 | class S3DeleteTest(DeleteTest, BaseWebTestS3, unittest.TestCase): 148 | pass 149 | 150 | 151 | class AttachmentViewTest(object): 152 | def test_only_post_and_options_is_accepted(self): 153 | self.app.get(self.endpoint_uri, headers=self.headers, status=405) 154 | self.app.put(self.endpoint_uri, headers=self.headers, status=405) 155 | self.app.patch(self.endpoint_uri, headers=self.headers, status=405) 156 | headers = self.headers.copy() 157 | headers["Access-Control-Request-Method"] = "POST" 158 | self.app.options(self.endpoint_uri, headers=headers, status=200) 159 | 160 | def test_record_is_updated_with_metadata(self): 161 | existing = {"data": {"author": "frutiger"}} 162 | self.app.put_json(self.record_uri, existing, headers=self.headers) 163 | self.upload() 164 | resp = self.app.get(self.record_uri, headers=self.headers) 165 | self.assertIn(self.file_field, resp.json["data"]) 166 | self.assertIn("author", resp.json["data"]) 167 | 168 | def test_record_metadata_has_hash_hexdigest(self): 169 | r = self.upload() 170 | h = "db511d372e98725a61278e90259c7d4c5484fc7a781d7dcc0c93d53b8929e2ba" 171 | self.assertEqual(self.get_record(r)["hash"], h) 172 | 173 | def test_record_metadata_has_randomized_location(self): 174 | resp = self.upload(files=[(self.file_field, b"my-report.pdf", b"--binary--")]) 175 | record = self.get_record(resp) 176 | self.assertNotIn("report", record["location"]) 177 | 178 | def test_record_location_contains_subfolder(self): 179 | self.upload() 180 | resp = self.app.get(self.record_uri, headers=self.headers) 181 | location = resp.json["data"][self.file_field]["location"] 182 | self.assertIn("fennec/fonts/", location) 183 | 184 | def test_record_metadata_provides_original_filename(self): 185 | resp = self.upload(files=[(self.file_field, b"my-report.pdf", b"--binary--")]) 186 | record = self.get_record(resp) 187 | self.assertEqual("my-report.pdf", record["filename"]) 188 | 189 | def test_record_is_created_with_fields(self): 190 | self.upload(params=[("data", '{"family": "sans"}')]) 191 | resp = self.app.get(self.record_uri, headers=self.headers) 192 | self.assertEqual(resp.json["data"]["family"], "sans") 193 | 194 | def test_record_is_updated_with_fields(self): 195 | existing = {"data": {"author": "frutiger"}} 196 | self.app.put_json(self.record_uri, existing, headers=self.headers) 197 | self.upload(params=[("data", '{"family": "sans"}')]) 198 | resp = self.app.get(self.record_uri, headers=self.headers) 199 | self.assertEqual(resp.json["data"]["family"], "sans") 200 | self.assertEqual(resp.json["data"]["author"], "frutiger") 201 | 202 | def test_record_attachment_metadata_cannot_be_removed_manually(self): 203 | self.upload(params=[("data", '{"family": "sans"}')]) 204 | body = {"data": {"attachment": {"manual": "true"}}} 205 | resp = self.app.patch_json(self.record_uri, body, headers=self.headers, status=400) 206 | self.assertIn("Attachment metadata cannot be modified", resp.json["message"]) 207 | 208 | def test_record_is_created_with_appropriate_permissions(self): 209 | self.upload() 210 | current_principal = ( 211 | "basicauth:c6c27f0c7297ba7d4abd2a70c8a2cb88a06a3bb793817ef2c85fe8a709b08022" 212 | ) 213 | resp = self.app.get(self.record_uri, headers=self.headers) 214 | self.assertEqual(resp.json["permissions"], {"write": [current_principal]}) 215 | 216 | def test_record_permissions_can_also_be_specified(self): 217 | self.upload(params=[("permissions", '{"read": ["system.Everyone"]}')]) 218 | resp = self.app.get(self.record_uri, headers=self.headers) 219 | self.assertIn("system.Everyone", resp.json["permissions"]["read"]) 220 | 221 | # Content Validation. 222 | 223 | def test_records_fields_must_be_valid_json(self): 224 | resp = self.upload(params=[("data", "{>author: 12}")], status=400) 225 | self.assertIn("body: data is not valid JSON", resp.json["message"]) 226 | 227 | def test_permissions_must_be_valid_json(self): 228 | resp = self.upload(params=[("permissions", '{"read": >}')], status=400) 229 | self.assertIn("body: permissions is not valid JSON", resp.json["message"]) 230 | 231 | def test_permissions_must_be_str(self): 232 | files = [ 233 | ("attachment", "image.jpg", b"--fake--"), 234 | ("data", "data", b'{"family": "sans"}'), 235 | ("permissions", "permissions", b'{"read": ["system.Everyone"]}'), 236 | ] 237 | content_type, body = self.app.encode_multipart([], files) 238 | headers = {**self.headers, "Content-Type": content_type} 239 | 240 | resp = self.app.post(self.endpoint_uri, body, headers=headers, status=400) 241 | 242 | self.assertIn( 243 | "body: 'data' field should be passed as form data, not files", resp.json["message"] 244 | ) 245 | 246 | def test_unknown_fields_are_not_accepted(self): 247 | resp = self.upload(params=[("my_field", "a_value")], status=400) 248 | self.assertIn("body: 'my_field' not in ('data', 'permissions')", resp.json["message"]) 249 | 250 | def test_record_fields_are_validated_against_schema(self): 251 | resp = self.upload(params=[("data", '{"author": 12}')], status=400) 252 | self.assertIn("author in body: 12 is not of type ", resp.json["message"]) 253 | 254 | def test_attachment_must_have_a_filename(self): 255 | resp = self.upload(files=[(self.file_field, b"", b"--fake--")], status=400) 256 | self.assertEqual(resp.json["message"], "body: Filename is required.") 257 | 258 | def test_upload_refused_if_extension_not_allowed(self): 259 | resp = self.upload(files=[(self.file_field, b"virus.exe", b"--fake--")], status=400) 260 | self.assertEqual(resp.json["message"], "body: File extension is not allowed.") 261 | 262 | def test_upload_refused_if_field_is_not_attachment(self): 263 | resp = self.upload(files=[("fichierjoint", b"image.jpg", b"--fake--")], status=400) 264 | self.assertEqual(resp.json["message"], "Attachment missing.") 265 | self.assertEqual(resp.json["errno"], ERRORS.INVALID_POSTED_DATA.value) 266 | 267 | def test_upload_refused_if_header_is_not_multipart(self): 268 | self.headers["Content-Type"] = "application/json" 269 | resp = self.app.post(self.endpoint_uri, {}, headers=self.headers, status=400) 270 | self.assertEqual(resp.json["message"], "Content-Type should be multipart/form-data") 271 | self.assertEqual(resp.json["errno"], ERRORS.INVALID_PARAMETERS.value) 272 | 273 | def test_upload_refused_if_header_is_invalid_multipart(self): 274 | self.headers["Content-Type"] = "multipart/form-data" 275 | resp = self.app.post(self.endpoint_uri, {}, headers=self.headers, status=400) 276 | self.assertEqual( 277 | resp.json["message"].replace(": b'", ": '"), "Invalid boundary in multipart form: ''" 278 | ) 279 | self.assertEqual(resp.json["errno"], ERRORS.INVALID_PARAMETERS.value) 280 | 281 | # Permissions. 282 | 283 | def test_upload_refused_if_not_authenticated(self): 284 | self.headers.pop("Authorization") 285 | self.upload(status=401) 286 | 287 | def test_upload_replace_refused_if_not_authenticated(self): 288 | self.upload(status=201) 289 | 290 | self.headers.pop("Authorization") 291 | self.upload(status=401) 292 | 293 | def test_upload_refused_if_not_allowed(self): 294 | self.headers.update(get_user_headers("jean-louis")) 295 | self.upload(status=403) 296 | 297 | def test_upload_replace_refused_if_only_create_allowed(self): 298 | # Allow any authenticated to write in this collection. 299 | perm = {"permissions": {"record:create": ["system.Authenticated"]}} 300 | self.app.patch_json("/buckets/fennec/collections/fonts", perm, headers=self.headers) 301 | self.upload(status=201) 302 | 303 | self.headers.update(get_user_headers("jean-louis")) 304 | self.upload(status=403) 305 | 306 | def test_upload_replace_refused_if_only_bucket_read_is_allowed(self): 307 | # Create a record with attachment. 308 | self.upload(status=201) 309 | 310 | # Now allow anyone to read this bucket. 311 | perm = {"permissions": {"read": ["system.Everyone"]}} 312 | self.app.patch_json("/buckets/fennec", perm, headers=self.headers) 313 | 314 | # And try to replace anonymously. 315 | self.headers.pop("Authorization") 316 | self.upload(status=401) 317 | 318 | def test_upload_replace_refused_if_only_read_is_allowed(self): 319 | # Create a record with attachment. 320 | self.upload(status=201) 321 | 322 | # Now allow anyone to read this collection. 323 | perm_change = [ 324 | {"op": "add", "path": "/permissions", "value": {"read": ["system.Everyone"]}} 325 | ] 326 | self.app.patch_json( 327 | "/buckets/fennec/collections/fonts", 328 | perm_change, 329 | headers={**self.headers, "Content-Type": "application/json-patch+json"}, 330 | ) 331 | 332 | # And try to replace anonymously. 333 | self.headers.pop("Authorization") 334 | self.upload(status=401) 335 | 336 | def test_upload_create_accepted_if_create_allowed(self): 337 | # Allow any authenticated to write in this collection. 338 | perm = {"permissions": {"record:create": ["system.Authenticated"]}} 339 | self.app.patch_json("/buckets/fennec/collections/fonts", perm, headers=self.headers) 340 | 341 | self.headers.update(get_user_headers("jean-louis")) 342 | self.upload(status=201) 343 | 344 | def test_upload_create_accepted_if_write_allowed(self): 345 | # Allow any authenticated to write in this bucket. 346 | perm = {"permissions": {"write": ["system.Authenticated"]}} 347 | self.app.patch_json("/buckets/fennec", perm, headers=self.headers) 348 | 349 | self.headers.update(get_user_headers("jean-louis")) 350 | self.upload(status=201) 351 | 352 | def test_upload_replace_accepted_if_write_allowed(self): 353 | # Allow any authenticated to write in this bucket. 354 | perm = {"permissions": {"write": ["system.Authenticated"]}} 355 | self.app.patch_json("/buckets/fennec", perm, headers=self.headers) 356 | self.upload(status=201) 357 | 358 | self.headers.update(get_user_headers("jean-louis")) 359 | self.upload(status=200) 360 | 361 | 362 | class PerResourceConfigAttachementViewTest(BaseWebTestS3, unittest.TestCase): 363 | config = "config/s3_per_resource.ini" 364 | 365 | def test_file_get_randomize_in_fennec_bucket(self): 366 | r = self.upload() 367 | 368 | self.assertEqual(r.json["filename"], "image.jpg") 369 | self.assertNotIn(r.json["filename"], r.json["location"]) 370 | 371 | def test_file_do_not_get_randomize_in_fennec_experiments_collection(self): 372 | self.create_collection("fennec", "experiments") 373 | record_uri = self.get_record_uri("fennec", "experiments", str(uuid.uuid4())) 374 | self.endpoint_uri = record_uri + "/attachment" 375 | r = self.upload() 376 | 377 | self.assertEqual(r.json["filename"], "image.jpg") 378 | self.assertIn(r.json["filename"], r.json["location"]) 379 | 380 | 381 | class OverridenMimetypesTest(BaseWebTestS3, unittest.TestCase): 382 | config = "config/s3.ini" 383 | 384 | def test_file_mimetype_comes_from_config(self): 385 | resp = self.upload(files=[(self.file_field, b"kinto.txt", b"--binary--")]) 386 | 387 | self.assertEqual(resp.json["mimetype"], "text/vnd.graphviz") 388 | 389 | 390 | class SingleAttachmentViewTest(AttachmentViewTest, BaseWebTestLocal, unittest.TestCase): 391 | pass 392 | 393 | 394 | class DefaultBucketTest(BaseWebTestLocal, unittest.TestCase): 395 | def setUp(self): 396 | super(DefaultBucketTest, self).setUp() 397 | self.record_uri = self.get_record_uri("default", "pix", uuid.uuid4()) 398 | self.endpoint_uri = self.record_uri + "/attachment" 399 | 400 | def test_implicit_collection_creation_on_upload(self): 401 | resp = self.upload() 402 | record_uri = resp.headers["Location"] 403 | self.assertIn("/buckets/b4a52ebc-fe4a-1167-89f3-c792640c70b3", record_uri) 404 | 405 | 406 | class KeepOldFilesTest(BaseWebTestLocal, unittest.TestCase): 407 | def make_app(self): 408 | import webtest 409 | from kinto import DEFAULT_SETTINGS 410 | from kinto import main as testapp 411 | from kinto.core import testing as core_support 412 | 413 | settings = core_support.DEFAULT_SETTINGS.copy() 414 | settings.update(**DEFAULT_SETTINGS) 415 | settings["multiauth.policies"] = "basicauth" 416 | settings["storage_backend"] = "kinto.core.storage.memory" 417 | settings["permission_backend"] = "kinto.core.permission.memory" 418 | settings["userid_hmac_secret"] = "this is not a secret" 419 | settings["includes"] = "kinto_attachment" 420 | 421 | settings["kinto.attachment.base_path"] = "/tmp" 422 | settings["kinto.attachment.base_url"] = "" 423 | settings["kinto.attachment.keep_old_files"] = "true" 424 | 425 | app = webtest.TestApp(testapp({}, **settings)) 426 | app.RequestClass = core_support.get_request_class(prefix="v1") 427 | return app 428 | 429 | def test_files_are_kept_when_attachment_is_replaced(self): 430 | resp = self.upload(status=201) 431 | location1 = resp.json["location"] 432 | resp = self.upload(status=200) 433 | location2 = resp.json["location"] 434 | self.assertNotEqual(location1, location2) 435 | self.assertTrue(self.backend.exists(location2)) 436 | self.assertTrue(self.backend.exists(location1)) 437 | 438 | def test_files_are_kept_when_attachment_is_deleted(self): 439 | resp = self.upload(status=201) 440 | location = resp.json["location"] 441 | self.assertTrue(self.backend.exists(location)) 442 | 443 | self.app.delete(self.record_uri + "/attachment", headers=self.headers) 444 | 445 | self.assertTrue(self.backend.exists(location)) 446 | 447 | def test_files_are_kept_when_record_is_deleted(self): 448 | resp = self.upload(status=201) 449 | location = resp.json["location"] 450 | self.assertTrue(self.backend.exists(location)) 451 | 452 | self.app.delete(self.record_uri, headers=self.headers) 453 | 454 | self.assertTrue(self.backend.exists(location)) 455 | 456 | 457 | class HeartbeartTest(BaseWebTestS3, unittest.TestCase): 458 | def test_attachments_is_added_to_heartbeat_view(self): 459 | resp = self.app.get("/__heartbeat__") 460 | self.assertIn("attachments", resp.json) 461 | 462 | def test_heartbeat_is_false_if_error_happens(self): 463 | storage_impl = self.app.app.registry.queryUtility(IFileStorage) 464 | with mock.patch.object(storage_impl, "delete") as mocked: 465 | mocked.side_effect = ValueError 466 | resp = self.app.get("/__heartbeat__", status=503) 467 | self.assertFalse(resp.json["attachments"]) 468 | 469 | def test_heartbeat_is_true_if_server_is_readonly(self): 470 | patch = mock.patch("pyramid_storage.s3.S3FileStorage.delete") 471 | self.addCleanup(patch.stop) 472 | mocked = patch.start() 473 | mocked.side_effect = ValueError 474 | 475 | with mock.patch.dict(self.app.app.registry.settings, [("readonly", "true")]): 476 | resp = self.app.get("/__heartbeat__") 477 | self.assertTrue(resp.json["attachments"]) 478 | 479 | 480 | class MetricsTest(BaseWebTestS3, unittest.TestCase): 481 | def test_attachments_methods_are_monitored(self): 482 | self.upload(status=201) 483 | 484 | resp = self.app.get("/__metrics__") 485 | 486 | self.assertIn("backend_s3filestorage_seconds", resp.text) 487 | --------------------------------------------------------------------------------