├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── AUTHORS ├── CHANGELOG ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── demo ├── README.rst ├── demoproject │ ├── __init__.py │ ├── apache │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── fixtures │ │ ├── demo.json │ │ └── hello-world.txt │ ├── http │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── lighttpd │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── manage.py │ ├── nginx │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── object │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── path │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── settings.py │ ├── storage │ │ ├── __init__.py │ │ ├── models.py │ │ ├── storage.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── templates │ │ └── home.html │ ├── tests.py │ ├── urls.py │ ├── virtual │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ └── wsgi.py └── setup.py ├── django_downloadview ├── __init__.py ├── apache │ ├── __init__.py │ ├── decorators.py │ ├── middlewares.py │ ├── response.py │ └── tests.py ├── api.py ├── decorators.py ├── exceptions.py ├── files.py ├── io.py ├── lighttpd │ ├── __init__.py │ ├── decorators.py │ ├── middlewares.py │ ├── response.py │ └── tests.py ├── middlewares.py ├── nginx │ ├── __init__.py │ ├── decorators.py │ ├── middlewares.py │ ├── response.py │ ├── settings.py │ └── tests.py ├── response.py ├── shortcuts.py ├── storage.py ├── test.py ├── utils.py └── views │ ├── __init__.py │ ├── base.py │ ├── http.py │ ├── object.py │ ├── path.py │ ├── storage.py │ └── virtual.py ├── docs ├── Makefile ├── about │ ├── alternatives.txt │ ├── authors.txt │ ├── changelog.txt │ ├── index.txt │ ├── license.txt │ └── vision.txt ├── conf.py ├── contributing.txt ├── demo.txt ├── django-sendfile.txt ├── files.txt ├── healthchecks.txt ├── index.txt ├── install.txt ├── optimizations │ ├── apache.txt │ ├── index.txt │ ├── lighttpd.txt │ └── nginx.txt ├── overview.txt ├── responses.txt ├── settings.txt ├── testing.txt └── views │ ├── custom.txt │ ├── http.txt │ ├── index.txt │ ├── object.txt │ ├── path.txt │ ├── storage.txt │ └── virtual.txt ├── setup.py ├── tests ├── __init__.py ├── api.py ├── io.py ├── packaging.py ├── response.py ├── sendfile.py ├── signature.py └── views.py └── tox.ini /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-downloadview' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-downloadview/upload 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 13 | django-version: ['4.2', '5.0', 'main'] 14 | exclude: 15 | # Django 5.0 dropped support for Python <3.10 16 | - django-version: '5.0' 17 | python-version: '3.8' 18 | - django-version: '5.0' 19 | python-version: '3.9' 20 | - django-version: 'main' 21 | python-version: '3.8' 22 | - django-version: 'main' 23 | python-version: '3.9' 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Get pip cache dir 34 | id: pip-cache 35 | run: | 36 | echo "::set-output name=dir::$(pip cache dir)" 37 | 38 | - name: Cache 39 | uses: actions/cache@v2 40 | with: 41 | path: ${{ steps.pip-cache.outputs.dir }} 42 | key: 43 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} 44 | restore-keys: | 45 | ${{ matrix.python-version }}-v1- 46 | 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | python -m pip install --upgrade tox tox-gh-actions 51 | 52 | - name: Tox tests 53 | run: | 54 | tox -v 55 | env: 56 | DJANGO: ${{ matrix.django-version }} 57 | 58 | - name: Upload coverage 59 | uses: codecov/codecov-action@v1 60 | with: 61 | name: Python ${{ matrix.python-version }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local binaries. 2 | /bin/ 3 | 4 | # Libraries. 5 | /lib/ 6 | 7 | # Data files. 8 | /var/ 9 | coverage.xml 10 | .coverage/ 11 | 12 | # Python files. 13 | *.pyc 14 | *.pyo 15 | *.egg-info 16 | 17 | # Tox files. 18 | /.tox/ 19 | .eggs 20 | *.egg-info 21 | 22 | # Virtualenv files (created by tox). 23 | /build/ 24 | /dist/ 25 | 26 | # Virtual environments (created by user). 27 | /venv/ 28 | 29 | # Editors' temporary buffers. 30 | .*.swp 31 | *~ 32 | .idea 33 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | # # Needed for black compatibility 3 | multi_line_output=3 4 | include_trailing_comma=True 5 | force_grid_wrap=0 6 | line_length=88 7 | combine_as_imports=True 8 | 9 | # List sections with django and 10 | known_django=django 11 | known_downloadview=django_downloadview 12 | 13 | sections=FUTURE,STDLIB,DJANGO,DOWNLOADVIEW,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 14 | 15 | # If set, imports will be sorted within their section independent to the import_type. 16 | force_sort_within_sections=True 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-toml 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: mixed-line-ending 10 | - id: file-contents-sorter 11 | files: docs/spelling_wordlist.txt 12 | - repo: https://github.com/pycqa/doc8 13 | rev: v1.1.2 14 | hooks: 15 | - id: doc8 16 | - repo: https://github.com/adamchainz/django-upgrade 17 | rev: 1.22.2 18 | hooks: 19 | - id: django-upgrade 20 | args: [--target-version, "4.2"] 21 | - repo: https://github.com/pre-commit/pygrep-hooks 22 | rev: v1.10.0 23 | hooks: 24 | - id: rst-backticks 25 | - id: rst-directive-colons 26 | - repo: https://github.com/pre-commit/mirrors-prettier 27 | rev: v4.0.0-alpha.8 28 | hooks: 29 | - id: prettier 30 | entry: env PRETTIER_LEGACY_CLI=1 prettier 31 | types_or: [javascript, css] 32 | args: 33 | - --trailing-comma=es5 34 | - repo: https://github.com/pre-commit/mirrors-eslint 35 | rev: v9.17.0 36 | hooks: 37 | - id: eslint 38 | additional_dependencies: 39 | - "eslint@v9.0.0-beta.1" 40 | - "@eslint/js@v9.0.0-beta.1" 41 | - "globals" 42 | files: \.js?$ 43 | types: [file] 44 | args: 45 | - --fix 46 | - repo: https://github.com/astral-sh/ruff-pre-commit 47 | rev: 'v0.8.6' 48 | hooks: 49 | - id: ruff 50 | args: [--fix, --exit-non-zero-on-fix] 51 | - id: ruff-format 52 | - repo: https://github.com/tox-dev/pyproject-fmt 53 | rev: v2.5.0 54 | hooks: 55 | - id: pyproject-fmt 56 | - repo: https://github.com/abravalheri/validate-pyproject 57 | rev: v0.23 58 | hooks: 59 | - id: validate-pyproject 60 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.10" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: . 19 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | ###################### 2 | Authors & contributors 3 | ###################### 4 | 5 | Maintainer: Benoît Bryon 6 | 7 | Original code by `PeopleDoc `_ team: 8 | 9 | * Adam Chainz 10 | * Aleksi Häkli 11 | * Benoît Bryon 12 | * CJ 13 | * David Wolf <68775926+devidw@users.noreply.github.com> 14 | * Davide Setti 15 | * Erik Dykema 16 | * Fabre Florian 17 | * Hasan Ramezani 18 | * Jannis Leidel 19 | * John Hagen 20 | * Mariusz Felisiak 21 | * Martin Bächtold 22 | * Nikhil Benesch 23 | * Omer Katz 24 | * Peter Marheine 25 | * René Leonhardt 26 | * Rémy HUBSCHER 27 | * Tim Gates 28 | * zero13cool 29 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Contributing 3 | ############ 4 | 5 | 6 | .. image:: https://jazzband.co/static/img/jazzband.svg 7 | :target: https://jazzband.co/ 8 | :alt: Jazzband 9 | 10 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 11 | 12 | 13 | This document provides guidelines for people who want to contribute to 14 | ``django-downloadview``. 15 | 16 | 17 | ************** 18 | Create tickets 19 | ************** 20 | 21 | Please use the `bugtracker`_ **before** starting some work: 22 | 23 | * check if the bug or feature request has already been filed. It may have been 24 | answered too! 25 | 26 | * else create a new ticket. 27 | 28 | * if you plan to contribute, tell us, so that we are given an opportunity to 29 | give feedback as soon as possible. 30 | 31 | * Then, in your commit messages, reference the ticket with some 32 | ``refs #TICKET-ID`` syntax. 33 | 34 | 35 | ****************** 36 | Use topic branches 37 | ****************** 38 | 39 | * Work in branches. 40 | 41 | * Prefix your branch with the ticket ID corresponding to the issue. As an 42 | example, if you are working on ticket #23 which is about contribute 43 | documentation, name your branch like ``23-contribute-doc``. 44 | 45 | * If you work in a development branch and want to refresh it with changes from 46 | master, please `rebase`_ or `merge-based rebase`_, i.e. do not merge master. 47 | 48 | 49 | *********** 50 | Fork, clone 51 | *********** 52 | 53 | Clone ``django-downloadview`` repository (adapt to use your own fork): 54 | 55 | .. code:: sh 56 | 57 | git clone git@github.com:jazzband/django-downloadview.git 58 | cd django-downloadview/ 59 | 60 | 61 | ************* 62 | Usual actions 63 | ************* 64 | 65 | The ``Makefile`` is the reference card for usual actions in development 66 | environment: 67 | 68 | * Install development toolkit with `pip`_: ``make develop``. 69 | 70 | * Run tests with `tox`_: ``make test``. 71 | 72 | * Build documentation: ``make documentation``. It builds `Sphinx`_ 73 | documentation in ``var/docs/html/index.html``. 74 | 75 | * Release project with `zest.releaser`_: ``make release``. 76 | 77 | * Cleanup local repository: ``make clean``, ``make distclean`` and 78 | ``make maintainer-clean``. 79 | 80 | See also ``make help``. 81 | 82 | 83 | ********************* 84 | Demo project included 85 | ********************* 86 | 87 | The ``demo`` included in project's repository is part of the tests and 88 | documentation. Maintain it along with code and documentation. 89 | 90 | 91 | .. rubric:: Notes & references 92 | 93 | .. target-notes:: 94 | 95 | .. _`bugtracker`: 96 | https://github.com/jazzband/django-downloadview/issues 97 | .. _`rebase`: http://git-scm.com/book/en/Git-Branching-Rebasing 98 | .. _`merge-based rebase`: https://tech.people-doc.com/psycho-rebasing.html 99 | .. _`pip`: https://pypi.python.org/pypi/pip/ 100 | .. _`tox`: https://tox.readthedocs.io/ 101 | .. _`Sphinx`: https://pypi.python.org/pypi/Sphinx/ 102 | .. _`zest.releaser`: https://pypi.python.org/pypi/zest.releaser/ 103 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | ####### 2 | Install 3 | ####### 4 | 5 | .. note:: 6 | 7 | If you want to install a development environment, please see 8 | :doc:`/contributing`. 9 | 10 | 11 | ************ 12 | Requirements 13 | ************ 14 | 15 | `django-downloadview` has been tested with `Python`_ 3.7, 3.8, 3.9 and 3.10. 16 | Other versions may work, but they are not part of the test suite at the moment. 17 | 18 | Installing `django-downloadview` will automatically trigger the installation of 19 | the following requirements: 20 | 21 | .. literalinclude:: /../setup.py 22 | :language: python 23 | :start-after: BEGIN requirements 24 | :end-before: END requirements 25 | 26 | 27 | ************ 28 | As a library 29 | ************ 30 | 31 | In most cases, you will use `django-downloadview` as a dependency of another 32 | project. In such a case, you should add `django-downloadview` in your main 33 | project's requirements. Typically in :file:`setup.py`: 34 | 35 | .. code:: python 36 | 37 | from setuptools import setup 38 | 39 | setup( 40 | install_requires=[ 41 | 'django-downloadview', 42 | #... 43 | ] 44 | # ... 45 | ) 46 | 47 | Then when you install your main project with your favorite package manager 48 | (like `pip`_), `django-downloadview` and its recursive dependencies will 49 | automatically be installed. 50 | 51 | 52 | ********** 53 | Standalone 54 | ********** 55 | 56 | You can install `django-downloadview` with your favorite Python package 57 | manager. As an example with `pip`_: 58 | 59 | .. code:: sh 60 | 61 | pip install django-downloadview 62 | 63 | 64 | ***** 65 | Check 66 | ***** 67 | 68 | Check `django-downloadview` has been installed: 69 | 70 | .. code:: sh 71 | 72 | python -c "import django_downloadview;print(django_downloadview.__version__)" 73 | 74 | You should get installed `django-downloadview`'s version. 75 | 76 | 77 | .. rubric:: Notes & references 78 | 79 | .. seealso:: 80 | 81 | * :doc:`/settings` 82 | * :doc:`/about/changelog` 83 | * :doc:`/about/license` 84 | 85 | .. target-notes:: 86 | 87 | .. _`Python`: https://www.python.org/ 88 | .. _`pip`: https://pip.pypa.io/ 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ####### 2 | License 3 | ####### 4 | 5 | Copyright (c) 2012-2014, Benoît Bryon. 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, 15 | this list of conditions and the following disclaimer in the documentation 16 | and/or other materials provided with the distribution. 17 | 18 | * Neither the name of django-downloadview nor the names of its contributors 19 | may be used to endorse or promote products derived from this software without 20 | specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 23 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 24 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 25 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 26 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 27 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 28 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 30 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include django_downloadview * 2 | global-exclude *.pyc 3 | include AUTHORS 4 | include CHANGELOG 5 | include CONTRIBUTING.rst 6 | include INSTALL 7 | include LICENSE 8 | include README.rst 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Reference card for usual actions in development environment. 2 | # 3 | # For standard installation of django-downloadview as a library, see INSTALL. 4 | # 5 | # For details about django-downloadview's development environment, see 6 | # CONTRIBUTING.rst. 7 | # 8 | PIP = pip 9 | TOX = tox 10 | BLACK = black 11 | ISORT = isort 12 | 13 | #: help - Display callable targets. 14 | .PHONY: help 15 | help: 16 | @echo "Reference card for usual actions in development environment." 17 | @echo "Here are available targets:" 18 | @egrep -o "^#: (.+)" [Mm]akefile | sed 's/#: /* /' 19 | 20 | 21 | #: develop - Install minimal development utilities. 22 | .PHONY: develop 23 | develop: 24 | $(PIP) install -e . 25 | 26 | 27 | #: clean - Basic cleanup, mostly temporary files. 28 | .PHONY: clean 29 | clean: 30 | find . -name "*.pyc" -delete 31 | find . -name '*.pyo' -delete 32 | find . -name "__pycache__" -delete 33 | 34 | 35 | #: distclean - Remove local builds, such as *.egg-info. 36 | .PHONY: distclean 37 | distclean: clean 38 | rm -rf *.egg 39 | rm -rf *.egg-info 40 | rm -rf demo/*.egg-info 41 | 42 | 43 | #: maintainer-clean - Remove almost everything that can be re-generated. 44 | .PHONY: maintainer-clean 45 | maintainer-clean: distclean 46 | rm -rf build/ 47 | rm -rf dist/ 48 | rm -rf .tox/ 49 | 50 | 51 | #: test - Run test suites. 52 | .PHONY: test 53 | test: 54 | mkdir -p var 55 | $(PIP) install -e .[test] 56 | $(TOX) 57 | 58 | 59 | #: documentation - Build documentation (Sphinx, README, ...) 60 | .PHONY: documentation 61 | documentation: sphinx readme 62 | 63 | 64 | #: sphinx - Build Sphinx documentation (docs). 65 | .PHONY: sphinx 66 | sphinx: 67 | $(TOX) -e sphinx 68 | 69 | 70 | #: readme - Build standalone documentation files (README, CONTRIBUTING...). 71 | .PHONY: readme 72 | readme: 73 | $(TOX) -e readme 74 | 75 | 76 | #: demo - Setup demo project. 77 | .PHONY: demo 78 | demo: 79 | pip install -e . 80 | pip install -e demo 81 | demo migrate --noinput 82 | # Install fixtures. 83 | mkdir -p var/media/object var/media/object-other/ var/media/nginx 84 | cp -r demo/demoproject/fixtures/* var/media/object/ 85 | cp -r demo/demoproject/fixtures/* var/media/object-other/ 86 | cp -r demo/demoproject/fixtures/* var/media/nginx/ 87 | demo loaddata demo.json 88 | 89 | 90 | #: runserver - Run demo server. 91 | .PHONY: runserver 92 | runserver: demo 93 | demo runserver 94 | 95 | .PHONY: black 96 | black: 97 | $(BLACK) demo tests django_downloadview 98 | 99 | .PHONY: isort 100 | isort: 101 | $(ISORT) --recursive django_downloadview tests demo 102 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ################### 2 | django-downloadview 3 | ################### 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://img.shields.io/pypi/v/django-downloadview.svg 10 | :target: https://pypi.python.org/pypi/django-downloadview 11 | 12 | .. image:: https://img.shields.io/pypi/pyversions/django-downloadview.svg 13 | :target: https://pypi.python.org/pypi/django-downloadview 14 | 15 | .. image:: https://img.shields.io/pypi/djversions/django-downloadview.svg 16 | :target: https://pypi.python.org/pypi/django-downloadview 17 | 18 | .. image:: https://img.shields.io/pypi/dm/django-downloadview.svg 19 | :target: https://pypi.python.org/pypi/django-downloadview 20 | 21 | .. image:: https://github.com/jazzband/django-downloadview/workflows/Test/badge.svg 22 | :target: https://github.com/jazzband/django-downloadview/actions 23 | :alt: GitHub Actions 24 | 25 | .. image:: https://codecov.io/gh/jazzband/django-downloadview/branch/master/graph/badge.svg 26 | :target: https://codecov.io/gh/jazzband/django-downloadview 27 | :alt: Coverage 28 | 29 | ``django-downloadview`` makes it easy to serve files with `Django`_: 30 | 31 | * you manage files with Django (permissions, filters, generation, ...); 32 | 33 | * files are stored somewhere or generated somehow (local filesystem, remote 34 | storage, memory...); 35 | 36 | * ``django-downloadview`` helps you stream the files with very little code; 37 | 38 | * ``django-downloadview`` helps you improve performances with reverse proxies, 39 | via mechanisms such as Nginx's X-Accel or Apache's X-Sendfile. 40 | 41 | 42 | ******* 43 | Example 44 | ******* 45 | 46 | Let's serve a file stored in a file field of some model: 47 | 48 | .. code:: python 49 | 50 | from django.conf.urls import url, url_patterns 51 | from django_downloadview import ObjectDownloadView 52 | from demoproject.download.models import Document # A model with a FileField 53 | 54 | # ObjectDownloadView inherits from django.views.generic.BaseDetailView. 55 | download = ObjectDownloadView.as_view(model=Document, file_field='file') 56 | 57 | url_patterns = ('', 58 | url('^download/(?P[A-Za-z0-9_-]+)/$', download, name='download'), 59 | ) 60 | 61 | 62 | ********* 63 | Resources 64 | ********* 65 | 66 | * Documentation: https://django-downloadview.readthedocs.io 67 | * PyPI page: http://pypi.python.org/pypi/django-downloadview 68 | * Code repository: https://github.com/jazzband/django-downloadview 69 | * Bugtracker: https://github.com/jazzband/django-downloadview/issues 70 | * Continuous integration: https://github.com/jazzband/django-downloadview/actions 71 | * Roadmap: https://github.com/jazzband/django-downloadview/milestones 72 | 73 | .. _`Django`: https://djangoproject.com 74 | -------------------------------------------------------------------------------- /demo/README.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Demo project 3 | ############ 4 | 5 | `Demo folder in project's repository`_ contains a Django project to illustrate 6 | ``django-downloadview`` usage. 7 | 8 | 9 | ***************************************** 10 | Documentation includes code from the demo 11 | ***************************************** 12 | 13 | Almost every example in the documentation comes from the demo: 14 | 15 | * discover examples in the documentation; 16 | * browse related code and tests in demo project. 17 | 18 | Examples in documentation are tested via demo project! 19 | 20 | 21 | *********************** 22 | Browse demo code online 23 | *********************** 24 | 25 | See `demo folder in project's repository`_. 26 | 27 | 28 | *************** 29 | Deploy the demo 30 | *************** 31 | 32 | System requirements: 33 | 34 | * `Python`_ version 3.7+, available as ``python`` command. 35 | 36 | .. note:: 37 | 38 | You may use `Virtualenv`_ to make sure the active ``python`` is the right 39 | one. 40 | 41 | * ``make`` and ``wget`` to use the provided :file:`Makefile`. 42 | 43 | Execute: 44 | 45 | .. code-block:: sh 46 | 47 | git clone git@github.com:jazzband/django-downloadview.git 48 | cd django-downloadview/ 49 | make runserver 50 | 51 | It installs and runs the demo server on localhost, port 8000. So have a look 52 | at ``http://localhost:8000/``. 53 | 54 | .. note:: 55 | 56 | If you cannot execute the Makefile, read it and adapt the few commands it 57 | contains to your needs. 58 | 59 | Browse and use :file:`demo/demoproject/` as a sandbox. 60 | 61 | 62 | ********** 63 | References 64 | ********** 65 | 66 | .. target-notes:: 67 | 68 | .. _`demo folder in project's repository`: 69 | https://github.com/jazzband/django-downloadview/tree/master/demo/demoproject/ 70 | 71 | .. _`Python`: http://python.org 72 | .. _`Virtualenv`: http://virtualenv.org 73 | -------------------------------------------------------------------------------- /demo/demoproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-downloadview/31e64b6a768435b9938c6a1ce7ba5d6ec862acac/demo/demoproject/__init__.py -------------------------------------------------------------------------------- /demo/demoproject/apache/__init__.py: -------------------------------------------------------------------------------- 1 | """Apache optimizations.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/apache/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/apache/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.base import ContentFile 4 | import django.test 5 | from django.urls import reverse 6 | 7 | from django_downloadview.apache import assert_x_sendfile 8 | 9 | from demoproject.apache.views import storage, storage_dir 10 | 11 | 12 | def setup_file(): 13 | if not os.path.exists(storage_dir): 14 | os.makedirs(storage_dir) 15 | storage.save("hello-world.txt", ContentFile("Hello world!\n")) 16 | 17 | 18 | class OptimizedByMiddlewareTestCase(django.test.TestCase): 19 | def test_response(self): 20 | """'apache:optimized_by_middleware' returns X-Sendfile response.""" 21 | setup_file() 22 | url = reverse("apache:optimized_by_middleware") 23 | response = self.client.get(url) 24 | assert_x_sendfile( 25 | self, 26 | response, 27 | content_type="text/plain; charset=utf-8", 28 | basename="hello-world.txt", 29 | file_path="/apache-optimized-by-middleware/hello-world.txt", 30 | ) 31 | 32 | 33 | class OptimizedByDecoratorTestCase(django.test.TestCase): 34 | def test_response(self): 35 | """'apache:optimized_by_decorator' returns X-Sendfile response.""" 36 | setup_file() 37 | url = reverse("apache:optimized_by_decorator") 38 | response = self.client.get(url) 39 | assert_x_sendfile( 40 | self, 41 | response, 42 | content_type="text/plain; charset=utf-8", 43 | basename="hello-world.txt", 44 | file_path="/apache-optimized-by-decorator/hello-world.txt", 45 | ) 46 | 47 | 48 | class ModifiedHeadersTestCase(django.test.TestCase): 49 | def test_response(self): 50 | """'apache:modified_headers' returns X-Sendfile response.""" 51 | setup_file() 52 | url = reverse("apache:modified_headers") 53 | response = self.client.get(url) 54 | assert_x_sendfile( 55 | self, 56 | response, 57 | content_type="text/plain; charset=utf-8", 58 | basename="hello-world.txt", 59 | file_path="/apache-modified-headers/hello-world.txt", 60 | ) 61 | self.assertEqual(response["X-Test"], "header") 62 | -------------------------------------------------------------------------------- /demo/demoproject/apache/urls.py: -------------------------------------------------------------------------------- 1 | """URL mapping.""" 2 | 3 | from django.urls import path 4 | 5 | from demoproject.apache import views 6 | 7 | app_name = "apache" 8 | urlpatterns = [ 9 | path( 10 | "optimized-by-middleware/", 11 | views.optimized_by_middleware, 12 | name="optimized_by_middleware", 13 | ), 14 | path( 15 | "optimized-by-decorator/", 16 | views.optimized_by_decorator, 17 | name="optimized_by_decorator", 18 | ), 19 | path( 20 | "modified_headers/", 21 | views.modified_headers, 22 | name="modified_headers", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /demo/demoproject/apache/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.files.storage import FileSystemStorage 5 | 6 | from django_downloadview import StorageDownloadView 7 | from django_downloadview.apache import x_sendfile 8 | 9 | storage_dir = os.path.join(settings.MEDIA_ROOT, "apache") 10 | storage = FileSystemStorage( 11 | location=storage_dir, base_url="".join([settings.MEDIA_URL, "apache/"]) 12 | ) 13 | 14 | 15 | optimized_by_middleware = StorageDownloadView.as_view( 16 | storage=storage, path="hello-world.txt" 17 | ) 18 | 19 | 20 | optimized_by_decorator = x_sendfile( 21 | StorageDownloadView.as_view(storage=storage, path="hello-world.txt"), 22 | source_url=storage.base_url, 23 | destination_dir="/apache-optimized-by-decorator/", 24 | ) 25 | 26 | 27 | def _modified_headers(request): 28 | view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt") 29 | response = view(request) 30 | response["X-Test"] = "header" 31 | return response 32 | 33 | 34 | modified_headers = x_sendfile( 35 | _modified_headers, 36 | source_url=storage.base_url, 37 | destination_dir="/apache-modified-headers/", 38 | ) 39 | -------------------------------------------------------------------------------- /demo/demoproject/fixtures/demo.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "object.document", 5 | "fields": { 6 | "slug": "hello-world", 7 | "file": "object/hello-world.txt" 8 | } 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /demo/demoproject/fixtures/hello-world.txt: -------------------------------------------------------------------------------- 1 | Hello world! 2 | -------------------------------------------------------------------------------- /demo/demoproject/http/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Demo for :class:`django_downloadview.HTTPDownloadView`. 3 | 4 | Code in this package is included in documentation's :doc:`/views/http`. 5 | Make sure to maintain both together. 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /demo/demoproject/http/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/http/tests.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | from django.urls import reverse 3 | 4 | from django_downloadview import assert_download_response 5 | 6 | 7 | class SimpleURLTestCase(django.test.TestCase): 8 | def test_download_response(self): 9 | """'simple_url' serves 'hello-world.txt' from Github.""" 10 | url = reverse("http:simple_url") 11 | response = self.client.get(url) 12 | assert_download_response( 13 | self, 14 | response, 15 | content="Hello world!\n", 16 | basename="hello-world.txt", 17 | mime_type="text/plain", 18 | ) 19 | 20 | 21 | class AvatarTestCase(django.test.TestCase): 22 | def test_download_response(self): 23 | """HTTPDownloadView proxies Content-Type header.""" 24 | url = reverse("http:avatar_url") 25 | response = self.client.get(url) 26 | assert_download_response(self, response, mime_type="image/png") 27 | -------------------------------------------------------------------------------- /demo/demoproject/http/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from demoproject.http import views 4 | 5 | app_name = "http" 6 | urlpatterns = [ 7 | path("simple_url/", views.simple_url, name="simple_url"), 8 | path("avatar_url/", views.avatar_url, name="avatar_url"), 9 | ] 10 | -------------------------------------------------------------------------------- /demo/demoproject/http/views.py: -------------------------------------------------------------------------------- 1 | from django_downloadview import HTTPDownloadView 2 | 3 | 4 | class SimpleURLDownloadView(HTTPDownloadView): 5 | def get_url(self): 6 | """Return URL of hello-world.txt file on GitHub.""" 7 | return ( 8 | "https://raw.githubusercontent.com" 9 | "/jazzband/django-downloadview" 10 | "/b7f660c5e3f37d918b106b02c5af7a887acc0111" 11 | "/demo/demoproject/download/fixtures/hello-world.txt" 12 | ) 13 | 14 | 15 | class GithubAvatarDownloadView(HTTPDownloadView): 16 | def get_url(self): 17 | return "https://avatars0.githubusercontent.com/u/235204" 18 | 19 | 20 | simple_url = SimpleURLDownloadView.as_view() 21 | avatar_url = GithubAvatarDownloadView.as_view() 22 | -------------------------------------------------------------------------------- /demo/demoproject/lighttpd/__init__.py: -------------------------------------------------------------------------------- 1 | """Lighttpd optimizations.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/lighttpd/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/lighttpd/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.base import ContentFile 4 | import django.test 5 | from django.urls import reverse 6 | 7 | from django_downloadview.lighttpd import assert_x_sendfile 8 | 9 | from demoproject.lighttpd.views import storage, storage_dir 10 | 11 | 12 | def setup_file(): 13 | if not os.path.exists(storage_dir): 14 | os.makedirs(storage_dir) 15 | storage.save("hello-world.txt", ContentFile("Hello world!\n")) 16 | 17 | 18 | class OptimizedByMiddlewareTestCase(django.test.TestCase): 19 | def test_response(self): 20 | """'lighttpd:optimized_by_middleware' returns X-Sendfile response.""" 21 | setup_file() 22 | url = reverse("lighttpd:optimized_by_middleware") 23 | response = self.client.get(url) 24 | assert_x_sendfile( 25 | self, 26 | response, 27 | content_type="text/plain; charset=utf-8", 28 | basename="hello-world.txt", 29 | file_path="/lighttpd-optimized-by-middleware/hello-world.txt", 30 | ) 31 | 32 | 33 | class OptimizedByDecoratorTestCase(django.test.TestCase): 34 | def test_response(self): 35 | """'lighttpd:optimized_by_decorator' returns X-Sendfile response.""" 36 | setup_file() 37 | url = reverse("lighttpd:optimized_by_decorator") 38 | response = self.client.get(url) 39 | assert_x_sendfile( 40 | self, 41 | response, 42 | content_type="text/plain; charset=utf-8", 43 | basename="hello-world.txt", 44 | file_path="/lighttpd-optimized-by-decorator/hello-world.txt", 45 | ) 46 | 47 | 48 | class ModifiedHeadersTestCase(django.test.TestCase): 49 | def test_response(self): 50 | """'lighttpd:modified_headers' returns X-Sendfile response.""" 51 | setup_file() 52 | url = reverse("lighttpd:modified_headers") 53 | response = self.client.get(url) 54 | assert_x_sendfile( 55 | self, 56 | response, 57 | content_type="text/plain; charset=utf-8", 58 | basename="hello-world.txt", 59 | file_path="/lighttpd-modified-headers/hello-world.txt", 60 | ) 61 | self.assertEqual(response["X-Test"], "header") 62 | -------------------------------------------------------------------------------- /demo/demoproject/lighttpd/urls.py: -------------------------------------------------------------------------------- 1 | """URL mapping.""" 2 | 3 | from django.urls import path 4 | 5 | from demoproject.lighttpd import views 6 | 7 | app_name = "lighttpd" 8 | urlpatterns = [ 9 | path( 10 | "optimized-by-middleware/", 11 | views.optimized_by_middleware, 12 | name="optimized_by_middleware", 13 | ), 14 | path( 15 | "optimized-by-decorator/", 16 | views.optimized_by_decorator, 17 | name="optimized_by_decorator", 18 | ), 19 | path( 20 | "modified_headers/", 21 | views.modified_headers, 22 | name="modified_headers", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /demo/demoproject/lighttpd/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.files.storage import FileSystemStorage 5 | 6 | from django_downloadview import StorageDownloadView 7 | from django_downloadview.lighttpd import x_sendfile 8 | 9 | storage_dir = os.path.join(settings.MEDIA_ROOT, "lighttpd") 10 | storage = FileSystemStorage( 11 | location=storage_dir, base_url="".join([settings.MEDIA_URL, "lighttpd/"]) 12 | ) 13 | 14 | 15 | optimized_by_middleware = StorageDownloadView.as_view( 16 | storage=storage, path="hello-world.txt" 17 | ) 18 | 19 | 20 | optimized_by_decorator = x_sendfile( 21 | StorageDownloadView.as_view(storage=storage, path="hello-world.txt"), 22 | source_url=storage.base_url, 23 | destination_dir="/lighttpd-optimized-by-decorator/", 24 | ) 25 | 26 | 27 | def _modified_headers(request): 28 | view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt") 29 | response = view(request) 30 | response["X-Test"] = "header" 31 | return response 32 | 33 | 34 | modified_headers = x_sendfile( 35 | _modified_headers, 36 | source_url=storage.base_url, 37 | destination_dir="/lighttpd-modified-headers/", 38 | ) 39 | -------------------------------------------------------------------------------- /demo/demoproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.core.management import execute_from_command_line 6 | 7 | 8 | def main(): 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"{__package__}.settings") 10 | execute_from_command_line(sys.argv) 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /demo/demoproject/nginx/__init__.py: -------------------------------------------------------------------------------- 1 | """Nginx optimizations.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/nginx/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/nginx/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.base import ContentFile 4 | import django.test 5 | from django.urls import reverse 6 | 7 | from django_downloadview.nginx import assert_x_accel_redirect 8 | 9 | from demoproject.nginx.views import storage, storage_dir 10 | 11 | 12 | def setup_file(): 13 | if not os.path.exists(storage_dir): 14 | os.makedirs(storage_dir) 15 | storage.save("hello-world.txt", ContentFile("Hello world!\n")) 16 | 17 | 18 | class OptimizedByMiddlewareTestCase(django.test.TestCase): 19 | def test_response(self): 20 | """'nginx:optimized_by_middleware' returns X-Accel response.""" 21 | setup_file() 22 | url = reverse("nginx:optimized_by_middleware") 23 | response = self.client.get(url) 24 | assert_x_accel_redirect( 25 | self, 26 | response, 27 | content_type="text/plain; charset=utf-8", 28 | charset="utf-8", 29 | basename="hello-world.txt", 30 | redirect_url="/nginx-optimized-by-middleware/hello-world.txt", 31 | expires=None, 32 | with_buffering=None, 33 | limit_rate=None, 34 | ) 35 | 36 | 37 | class OptimizedByDecoratorTestCase(django.test.TestCase): 38 | def test_response(self): 39 | """'nginx:optimized_by_decorator' returns X-Accel response.""" 40 | setup_file() 41 | url = reverse("nginx:optimized_by_decorator") 42 | response = self.client.get(url) 43 | assert_x_accel_redirect( 44 | self, 45 | response, 46 | content_type="text/plain; charset=utf-8", 47 | charset="utf-8", 48 | basename="hello-world.txt", 49 | redirect_url="/nginx-optimized-by-decorator/hello-world.txt", 50 | expires=None, 51 | with_buffering=None, 52 | limit_rate=None, 53 | ) 54 | 55 | 56 | class ModifiedHeadersTestCase(django.test.TestCase): 57 | def test_response(self): 58 | """'nginx:modified_headers' returns X-Sendfile response.""" 59 | setup_file() 60 | url = reverse("nginx:modified_headers") 61 | response = self.client.get(url) 62 | assert_x_accel_redirect( 63 | self, 64 | response, 65 | content_type="text/plain; charset=utf-8", 66 | charset="utf-8", 67 | basename="hello-world.txt", 68 | redirect_url="/nginx-modified-headers/hello-world.txt", 69 | expires=None, 70 | with_buffering=None, 71 | limit_rate=None, 72 | ) 73 | self.assertEqual(response["X-Test"], "header") 74 | -------------------------------------------------------------------------------- /demo/demoproject/nginx/urls.py: -------------------------------------------------------------------------------- 1 | """URL mapping.""" 2 | 3 | from django.urls import path 4 | 5 | from demoproject.nginx import views 6 | 7 | app_name = "nginx" 8 | urlpatterns = [ 9 | path( 10 | "optimized-by-middleware/", 11 | views.optimized_by_middleware, 12 | name="optimized_by_middleware", 13 | ), 14 | path( 15 | "optimized-by-decorator/", 16 | views.optimized_by_decorator, 17 | name="optimized_by_decorator", 18 | ), 19 | path( 20 | "modified_headers/", 21 | views.modified_headers, 22 | name="modified_headers", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /demo/demoproject/nginx/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.files.storage import FileSystemStorage 5 | 6 | from django_downloadview import StorageDownloadView 7 | from django_downloadview.nginx import x_accel_redirect 8 | 9 | storage_dir = os.path.join(settings.MEDIA_ROOT, "nginx") 10 | storage = FileSystemStorage( 11 | location=storage_dir, base_url="".join([settings.MEDIA_URL, "nginx/"]) 12 | ) 13 | 14 | 15 | optimized_by_middleware = StorageDownloadView.as_view( 16 | storage=storage, path="hello-world.txt" 17 | ) 18 | 19 | 20 | optimized_by_decorator = x_accel_redirect( 21 | StorageDownloadView.as_view(storage=storage, path="hello-world.txt"), 22 | source_url=storage.base_url, 23 | destination_url="/nginx-optimized-by-decorator/", 24 | ) 25 | 26 | 27 | def _modified_headers(request): 28 | view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt") 29 | response = view(request) 30 | response["X-Test"] = "header" 31 | return response 32 | 33 | 34 | modified_headers = x_accel_redirect( 35 | _modified_headers, 36 | source_url=storage.base_url, 37 | destination_url="/nginx-modified-headers/", 38 | ) 39 | -------------------------------------------------------------------------------- /demo/demoproject/object/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Demo for :class:`django_downloadview.ObjectDownloadView`. 3 | 4 | Code in this package is included in documentation's :doc:`/views/object`. 5 | Make sure to maintain both together. 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /demo/demoproject/object/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Document(models.Model): 5 | slug = models.SlugField() 6 | file = models.FileField(upload_to="object") 7 | another_file = models.FileField(upload_to="object-other") 8 | basename = models.CharField(max_length=100) 9 | -------------------------------------------------------------------------------- /demo/demoproject/object/tests.py: -------------------------------------------------------------------------------- 1 | from django.core.files.base import ContentFile 2 | import django.test 3 | from django.urls import reverse 4 | 5 | from django_downloadview import assert_download_response, temporary_media_root 6 | 7 | from demoproject.object.models import Document 8 | 9 | # Fixtures. 10 | slug = "hello-world" 11 | basename = "hello-world.txt" 12 | file_name = "file.txt" 13 | another_name = "another_file.txt" 14 | file_content = "Hello world!\n" 15 | another_content = "Goodbye world!\n" 16 | 17 | 18 | def setup_document(): 19 | document = Document(slug=slug, basename=basename) 20 | document.file.save(file_name, ContentFile(file_content), save=False) 21 | document.another_file.save(another_name, ContentFile(another_content), save=False) 22 | document.save() 23 | return document 24 | 25 | 26 | class DefaultFileTestCase(django.test.TestCase): 27 | @temporary_media_root() 28 | def test_download_response(self): 29 | """'default_file' streams Document.file.""" 30 | setup_document() 31 | url = reverse("object:default_file", kwargs={"slug": slug}) 32 | response = self.client.get(url) 33 | assert_download_response( 34 | self, 35 | response, 36 | content=file_content, 37 | basename=file_name, 38 | mime_type="text/plain", 39 | ) 40 | 41 | 42 | class AnotherFileTestCase(django.test.TestCase): 43 | @temporary_media_root() 44 | def test_download_response(self): 45 | """'another_file' streams Document.another_file.""" 46 | setup_document() 47 | url = reverse("object:another_file", kwargs={"slug": slug}) 48 | response = self.client.get(url) 49 | assert_download_response( 50 | self, 51 | response, 52 | content=another_content, 53 | basename=another_name, 54 | mime_type="text/plain", 55 | ) 56 | 57 | 58 | class DeserializedBasenameTestCase(django.test.TestCase): 59 | @temporary_media_root() 60 | def test_download_response(self): 61 | "'deserialized_basename' streams Document.file with custom basename." 62 | setup_document() 63 | url = reverse("object:deserialized_basename", kwargs={"slug": slug}) 64 | response = self.client.get(url) 65 | assert_download_response( 66 | self, 67 | response, 68 | content=file_content, 69 | basename=basename, 70 | mime_type="text/plain", 71 | ) 72 | 73 | 74 | class InlineFileTestCase(django.test.TestCase): 75 | @temporary_media_root() 76 | def test_download_response(self): 77 | "'inline_file_view' streams Document.file inline." 78 | setup_document() 79 | url = reverse("object:inline_file", kwargs={"slug": slug}) 80 | response = self.client.get(url) 81 | assert_download_response( 82 | self, 83 | response, 84 | content=file_content, 85 | mime_type="text/plain", 86 | attachment=False, 87 | ) 88 | -------------------------------------------------------------------------------- /demo/demoproject/object/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from demoproject.object import views 4 | 5 | app_name = "object" 6 | urlpatterns = [ 7 | re_path( 8 | r"^default-file/(?P[a-zA-Z0-9_-]+)/$", 9 | views.default_file_view, 10 | name="default_file", 11 | ), 12 | re_path( 13 | r"^another-file/(?P[a-zA-Z0-9_-]+)/$", 14 | views.another_file_view, 15 | name="another_file", 16 | ), 17 | re_path( 18 | r"^deserialized_basename/(?P[a-zA-Z0-9_-]+)/$", 19 | views.deserialized_basename_view, 20 | name="deserialized_basename", 21 | ), 22 | re_path( 23 | r"^inline-file/(?P[a-zA-Z0-9_-]+)/$", 24 | views.inline_file_view, 25 | name="inline_file", 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /demo/demoproject/object/views.py: -------------------------------------------------------------------------------- 1 | from django_downloadview import ObjectDownloadView 2 | 3 | from demoproject.object.models import Document 4 | 5 | #: Serve ``file`` attribute of ``Document`` model. 6 | default_file_view = ObjectDownloadView.as_view(model=Document) 7 | 8 | #: Serve ``another_file`` attribute of ``Document`` model. 9 | another_file_view = ObjectDownloadView.as_view( 10 | model=Document, file_field="another_file" 11 | ) 12 | 13 | #: Serve ``file`` attribute of ``Document`` model, using client-side filename 14 | #: from model. 15 | deserialized_basename_view = ObjectDownloadView.as_view( 16 | model=Document, basename_field="basename" 17 | ) 18 | 19 | #: Serve ``file`` attribute of ``Document`` model, inline (not as attachment). 20 | inline_file_view = ObjectDownloadView.as_view(model=Document, attachment=False) 21 | -------------------------------------------------------------------------------- /demo/demoproject/path/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Demo for :class:`django_downloadview.PathDownloadView`. 3 | 4 | Code in this package is included in documentation's :doc:`/views/path`. 5 | Make sure to maintain both together. 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /demo/demoproject/path/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/path/tests.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | from django.urls import reverse 3 | 4 | from django_downloadview import assert_download_response 5 | 6 | 7 | class StaticPathTestCase(django.test.TestCase): 8 | def test_download_response(self): 9 | """'static_path' serves 'fixtures/hello-world.txt'.""" 10 | url = reverse("path:static_path") 11 | response = self.client.get(url) 12 | assert_download_response( 13 | self, 14 | response, 15 | content="Hello world!\n", 16 | basename="hello-world.txt", 17 | mime_type="text/plain", 18 | ) 19 | 20 | 21 | class DynamicPathTestCase(django.test.TestCase): 22 | def test_download_response(self): 23 | """'dynamic_path' serves 'fixtures/{path}'.""" 24 | url = reverse("path:dynamic_path", kwargs={"path": "hello-world.txt"}) 25 | response = self.client.get(url) 26 | assert_download_response( 27 | self, 28 | response, 29 | content="Hello world!\n", 30 | basename="hello-world.txt", 31 | mime_type="text/plain", 32 | ) 33 | -------------------------------------------------------------------------------- /demo/demoproject/path/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from demoproject.path import views 4 | 5 | app_name = "path" 6 | urlpatterns = [ 7 | path("static-path/", views.static_path, name="static_path"), 8 | re_path( 9 | r"^dynamic-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$", 10 | views.dynamic_path, 11 | name="dynamic_path", 12 | ), 13 | ] 14 | -------------------------------------------------------------------------------- /demo/demoproject/path/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django_downloadview import PathDownloadView 4 | 5 | # Let's initialize some fixtures. 6 | app_dir = os.path.dirname(os.path.abspath(__file__)) 7 | project_dir = os.path.dirname(app_dir) 8 | fixtures_dir = os.path.join(project_dir, "fixtures") 9 | #: Path to a text file that says 'Hello world!'. 10 | hello_world_path = os.path.join(fixtures_dir, "hello-world.txt") 11 | 12 | #: Serve ``fixtures/hello-world.txt`` file. 13 | static_path = PathDownloadView.as_view(path=hello_world_path) 14 | 15 | 16 | class DynamicPathDownloadView(PathDownloadView): 17 | """Serve file in ``settings.MEDIA_ROOT``. 18 | 19 | .. warning:: 20 | 21 | Make sure to prevent "../" in path via URL patterns. 22 | 23 | .. note:: 24 | 25 | This particular setup would be easier to perform with 26 | :class:`StorageDownloadView` 27 | 28 | """ 29 | 30 | def get_path(self): 31 | """Return path inside fixtures directory.""" 32 | # Get path from URL resolvers or as_view kwarg. 33 | relative_path = super().get_path() 34 | # Make it absolute. 35 | absolute_path = os.path.join(fixtures_dir, relative_path) 36 | return absolute_path 37 | 38 | 39 | dynamic_path = DynamicPathDownloadView.as_view() 40 | -------------------------------------------------------------------------------- /demo/demoproject/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for django-downloadview demo project.""" 2 | 3 | import os 4 | 5 | 6 | # Configure some relative directories. 7 | demoproject_dir = os.path.dirname(os.path.abspath(__file__)) 8 | demo_dir = os.path.dirname(demoproject_dir) 9 | root_dir = os.path.dirname(demo_dir) 10 | data_dir = os.path.join(root_dir, "var") 11 | cfg_dir = os.path.join(root_dir, "etc") 12 | 13 | 14 | # Mandatory settings. 15 | ROOT_URLCONF = "demoproject.urls" 16 | WSGI_APPLICATION = "demoproject.wsgi.application" 17 | 18 | 19 | # Database. 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.sqlite3", 23 | "NAME": os.path.join(data_dir, "db.sqlite"), 24 | } 25 | } 26 | 27 | 28 | # Required. 29 | SECRET_KEY = "This is a secret made public on project's repository." 30 | 31 | # Media and static files. 32 | MEDIA_ROOT = os.path.join(data_dir, "media") 33 | MEDIA_URL = "/media/" 34 | STATIC_ROOT = os.path.join(data_dir, "static") 35 | STATIC_URL = "/static/" 36 | 37 | 38 | # Applications. 39 | INSTALLED_APPS = ( 40 | # The actual django-downloadview demo. 41 | "demoproject", 42 | "demoproject.object", # Demo around ObjectDownloadView 43 | "demoproject.storage", # Demo around StorageDownloadView 44 | "demoproject.path", # Demo around PathDownloadView 45 | "demoproject.http", # Demo around HTTPDownloadView 46 | "demoproject.virtual", # Demo around VirtualDownloadView 47 | "demoproject.nginx", # Sample optimizations for Nginx X-Accel. 48 | "demoproject.apache", # Sample optimizations for Apache X-Sendfile. 49 | "demoproject.lighttpd", # Sample optimizations for Lighttpd X-Sendfile. 50 | # Standard Django applications. 51 | "django.contrib.auth", 52 | "django.contrib.contenttypes", 53 | "django.contrib.sessions", 54 | "django.contrib.sites", 55 | "django.contrib.messages", 56 | "django.contrib.staticfiles", 57 | ) 58 | 59 | 60 | # BEGIN middlewares 61 | MIDDLEWARE = [ 62 | "django.middleware.common.CommonMiddleware", 63 | "django.contrib.sessions.middleware.SessionMiddleware", 64 | "django.middleware.csrf.CsrfViewMiddleware", 65 | "django.contrib.auth.middleware.AuthenticationMiddleware", 66 | "django.contrib.messages.middleware.MessageMiddleware", 67 | "django_downloadview.SmartDownloadMiddleware", 68 | ] 69 | # END middlewares 70 | 71 | 72 | # Specific configuration for django_downloadview.SmartDownloadMiddleware. 73 | # BEGIN backend 74 | DOWNLOADVIEW_BACKEND = "django_downloadview.nginx.XAccelRedirectMiddleware" 75 | # END backend 76 | """Could also be: 77 | DOWNLOADVIEW_BACKEND = 'django_downloadview.apache.XSendfileMiddleware' 78 | DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware' 79 | """ 80 | 81 | # BEGIN rules 82 | DOWNLOADVIEW_RULES = [ 83 | { 84 | "source_url": "/media/nginx/", 85 | "destination_url": "/nginx-optimized-by-middleware/", 86 | }, 87 | ] 88 | # END rules 89 | DOWNLOADVIEW_RULES += [ 90 | { 91 | "source_url": "/media/apache/", 92 | "destination_dir": "/apache-optimized-by-middleware/", 93 | # Bypass global default backend with additional argument "backend". 94 | # Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be 95 | # enough. Here, the django_downloadview demo project needs to 96 | # demonstrate usage of several backends. 97 | "backend": "django_downloadview.apache.XSendfileMiddleware", 98 | }, 99 | { 100 | "source_url": "/media/lighttpd/", 101 | "destination_dir": "/lighttpd-optimized-by-middleware/", 102 | # Bypass global default backend with additional argument "backend". 103 | # Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be 104 | # enough. Here, the django_downloadview demo project needs to 105 | # demonstrate usage of several backends. 106 | "backend": "django_downloadview.lighttpd.XSendfileMiddleware", 107 | }, 108 | ] 109 | 110 | 111 | # Test/development settings. 112 | DEBUG = True 113 | 114 | 115 | TEMPLATES = [ 116 | { 117 | "BACKEND": "django.template.backends.django.DjangoTemplates", 118 | "DIRS": [os.path.join(os.path.dirname(__file__), "templates")], 119 | "OPTIONS": { 120 | "debug": DEBUG, 121 | "context_processors": [ 122 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 123 | # list if you haven't customized them: 124 | "django.contrib.auth.context_processors.auth", 125 | "django.template.context_processors.debug", 126 | "django.template.context_processors.i18n", 127 | "django.template.context_processors.media", 128 | "django.template.context_processors.static", 129 | "django.template.context_processors.tz", 130 | "django.contrib.messages.context_processors.messages", 131 | ], 132 | }, 133 | }, 134 | ] 135 | -------------------------------------------------------------------------------- /demo/demoproject/storage/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Demo for :class:`django_downloadview.StorageDownloadView`. 3 | 4 | Code in this package is included in documentation's :doc:`/views/storage`. 5 | Make sure to maintain both together. 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /demo/demoproject/storage/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/storage/storage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | 3 | storage = FileSystemStorage() 4 | -------------------------------------------------------------------------------- /demo/demoproject/storage/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from django.core.files.base import ContentFile 5 | from django.http.response import HttpResponseNotModified 6 | import django.test 7 | from django.urls import reverse 8 | 9 | from django_downloadview import ( 10 | assert_download_response, 11 | setup_view, 12 | temporary_media_root, 13 | ) 14 | 15 | from demoproject.storage import views 16 | 17 | # Fixtures. 18 | file_content = "Hello world!\n" 19 | 20 | 21 | def setup_file(path): 22 | views.storage.save(path, ContentFile(file_content)) 23 | 24 | 25 | class StaticPathTestCase(django.test.TestCase): 26 | @temporary_media_root() 27 | def test_download_response(self): 28 | """'storage:static_path' streams file by path.""" 29 | setup_file("1.txt") 30 | url = reverse("storage:static_path", kwargs={"path": "1.txt"}) 31 | response = self.client.get(url) 32 | assert_download_response( 33 | self, 34 | response, 35 | content=file_content, 36 | basename="1.txt", 37 | mime_type="text/plain", 38 | ) 39 | 40 | @temporary_media_root() 41 | def test_not_modified_download_response(self): 42 | """'storage:static_path' sends not modified response if unmodified.""" 43 | setup_file("1.txt") 44 | url = reverse("storage:static_path", kwargs={"path": "1.txt"}) 45 | year = datetime.date.today().year + 4 46 | response = self.client.get( 47 | url, headers={"if-modified-since": f"Sat, 29 Oct {year} 19:43:31 GMT"} 48 | ) 49 | self.assertTrue(isinstance(response, HttpResponseNotModified)) 50 | 51 | @temporary_media_root() 52 | def test_modified_since_download_response(self): 53 | """'storage:static_path' streams file if modified.""" 54 | setup_file("1.txt") 55 | url = reverse("storage:static_path", kwargs={"path": "1.txt"}) 56 | response = self.client.get( 57 | url, headers={"if-modified-since": "Sat, 29 Oct 1980 19:43:31 GMT"} 58 | ) 59 | assert_download_response( 60 | self, 61 | response, 62 | content=file_content, 63 | basename="1.txt", 64 | mime_type="text/plain", 65 | ) 66 | 67 | 68 | class DynamicPathIntegrationTestCase(django.test.TestCase): 69 | """Integration tests around ``storage:dynamic_path`` URL.""" 70 | 71 | @temporary_media_root() 72 | def test_download_response(self): 73 | """'dynamic_path' streams file by generated path. 74 | 75 | As we use ``self.client``, this test involves the whole Django stack, 76 | including settings, middlewares, decorators... So we need to setup a 77 | file, the storage, and an URL. 78 | 79 | This test actually asserts the URL ``storage:dynamic_path`` streams a 80 | file in storage. 81 | 82 | """ 83 | setup_file("1.TXT") 84 | url = reverse("storage:dynamic_path", kwargs={"path": "1.txt"}) 85 | response = self.client.get(url) 86 | assert_download_response( 87 | self, 88 | response, 89 | content=file_content, 90 | basename="1.TXT", 91 | mime_type="text/plain", 92 | ) 93 | 94 | 95 | class DynamicPathUnitTestCase(unittest.TestCase): 96 | """Unit tests around ``views.DynamicStorageDownloadView``.""" 97 | 98 | def test_get_path(self): 99 | """DynamicStorageDownloadView.get_path() returns uppercase path. 100 | 101 | Uses :func:`~django_downloadview.test.setup_view` to target only 102 | overriden methods. 103 | 104 | This test does not involve URLconf, middlewares or decorators. It is 105 | fast. It has clear scope. It does not assert ``storage:dynamic_path`` 106 | URL works. It targets only custom ``DynamicStorageDownloadView`` class. 107 | 108 | """ 109 | view = setup_view( 110 | views.DynamicStorageDownloadView(), 111 | django.test.RequestFactory().get("/fake-url"), 112 | path="dummy path", 113 | ) 114 | path = view.get_path() 115 | self.assertEqual(path, "DUMMY PATH") 116 | -------------------------------------------------------------------------------- /demo/demoproject/storage/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from demoproject.storage import views 4 | 5 | app_name = "storage" 6 | urlpatterns = [ 7 | re_path( 8 | r"^static-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$", 9 | views.static_path, 10 | name="static_path", 11 | ), 12 | re_path( 13 | r"^dynamic-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$", 14 | views.dynamic_path, 15 | name="dynamic_path", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /demo/demoproject/storage/views.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | 3 | from django_downloadview import StorageDownloadView 4 | 5 | storage = FileSystemStorage() 6 | 7 | 8 | #: Serve file using ``path`` argument. 9 | static_path = StorageDownloadView.as_view(storage=storage) 10 | 11 | 12 | class DynamicStorageDownloadView(StorageDownloadView): 13 | """Serve file of storage by path.upper().""" 14 | 15 | def get_path(self): 16 | """Return uppercase path.""" 17 | return super().get_path().upper() 18 | 19 | 20 | dynamic_path = DynamicStorageDownloadView.as_view(storage=storage) 21 | -------------------------------------------------------------------------------- /demo/demoproject/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | django-downloadview demo 4 | 5 | 6 |

Welcome to django-downloadview demo!

7 |

Here are some demo links. Browse the code to see how they are implemented

8 | 9 |

Serving files with Django

10 |

In the following views, Django streams the files, no optimization 11 | has been setup.

12 | 17 | 18 |

Optimized downloads

19 |

In the following views, Django delegates actual streaming to another 20 | server, for improved performances.

21 |

Since nginx and other servers aren't installed on the demo, you 22 | will get raw "X-Sendfile" responses. Look at the headers!

23 |
    24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /demo/demoproject/tests.py: -------------------------------------------------------------------------------- 1 | """Test suite for demoproject.download.""" 2 | 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | 7 | class HomeViewTestCase(TestCase): 8 | """Test homepage.""" 9 | 10 | def test_get(self): 11 | """Homepage returns HTTP 200.""" 12 | home_url = reverse("home") 13 | response = self.client.get(home_url) 14 | self.assertEqual(response.status_code, 200) 15 | -------------------------------------------------------------------------------- /demo/demoproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from django.views.generic import TemplateView 3 | 4 | home = TemplateView.as_view(template_name="home.html") 5 | 6 | 7 | urlpatterns = [ 8 | # ObjectDownloadView. 9 | path( 10 | "object/", 11 | include("demoproject.object.urls", namespace="object"), 12 | ), 13 | # StorageDownloadView. 14 | path( 15 | "storage/", 16 | include("demoproject.storage.urls", namespace="storage"), 17 | ), 18 | # PathDownloadView. 19 | path("path/", include("demoproject.path.urls", namespace="path")), 20 | # HTTPDownloadView. 21 | path("http/", include("demoproject.http.urls", namespace="http")), 22 | # VirtualDownloadView. 23 | path( 24 | "virtual/", 25 | include("demoproject.virtual.urls", namespace="virtual"), 26 | ), 27 | # Nginx optimizations. 28 | path( 29 | "nginx/", 30 | include("demoproject.nginx.urls", namespace="nginx"), 31 | ), 32 | # Apache optimizations. 33 | path( 34 | "apache/", 35 | include("demoproject.apache.urls", namespace="apache"), 36 | ), 37 | # Lighttpd optimizations. 38 | path( 39 | "lighttpd/", 40 | include("demoproject.lighttpd.urls", namespace="lighttpd"), 41 | ), 42 | # An informative homepage. 43 | path("", home, name="home"), 44 | ] 45 | -------------------------------------------------------------------------------- /demo/demoproject/virtual/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Demo for :class:`django_downloadview.VirtualDownloadView`. 3 | 4 | Code in this package is included in documentation's :doc:`/views/virtual`. 5 | Make sure to maintain both together. 6 | 7 | """ 8 | -------------------------------------------------------------------------------- /demo/demoproject/virtual/models.py: -------------------------------------------------------------------------------- 1 | """Required to make a Django application.""" 2 | -------------------------------------------------------------------------------- /demo/demoproject/virtual/tests.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | from django.urls import reverse 3 | 4 | from django_downloadview import assert_download_response 5 | 6 | 7 | class TextTestCase(django.test.TestCase): 8 | def test_download_response(self): 9 | """'virtual:text' serves 'hello-world.txt' from unicode.""" 10 | url = reverse("virtual:text") 11 | response = self.client.get(url) 12 | assert_download_response( 13 | self, 14 | response, 15 | content="Hello world!\n", 16 | basename="hello-world.txt", 17 | mime_type="text/plain", 18 | ) 19 | 20 | 21 | class StringIOTestCase(django.test.TestCase): 22 | def test_download_response(self): 23 | """'virtual:stringio' serves 'hello-world.txt' from stringio.""" 24 | url = reverse("virtual:stringio") 25 | response = self.client.get(url) 26 | assert_download_response( 27 | self, 28 | response, 29 | content="Hello world!\n", 30 | basename="hello-world.txt", 31 | mime_type="text/plain", 32 | ) 33 | 34 | 35 | class GeneratedTestCase(django.test.TestCase): 36 | def test_download_response(self): 37 | """'virtual:generated' serves 'hello-world.txt' from generator.""" 38 | url = reverse("virtual:generated") 39 | response = self.client.get(url) 40 | assert_download_response( 41 | self, 42 | response, 43 | content="Hello world!\n", 44 | basename="hello-world.txt", 45 | mime_type="text/plain", 46 | ) 47 | -------------------------------------------------------------------------------- /demo/demoproject/virtual/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from demoproject.virtual import views 4 | 5 | app_name = "virtual" 6 | urlpatterns = [ 7 | path("text/", views.TextDownloadView.as_view(), name="text"), 8 | path("stringio/", views.StringIODownloadView.as_view(), name="stringio"), 9 | path("gerenated/", views.GeneratedDownloadView.as_view(), name="generated"), 10 | ] 11 | -------------------------------------------------------------------------------- /demo/demoproject/virtual/views.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.files.base import ContentFile 4 | 5 | from django_downloadview import TextIteratorIO, VirtualDownloadView, VirtualFile 6 | 7 | 8 | class TextDownloadView(VirtualDownloadView): 9 | def get_file(self): 10 | """Return :class:`django.core.files.base.ContentFile` object.""" 11 | return ContentFile(b"Hello world!\n", name="hello-world.txt") 12 | 13 | 14 | class StringIODownloadView(VirtualDownloadView): 15 | def get_file(self): 16 | """Return wrapper on ``six.StringIO`` object.""" 17 | file_obj = StringIO("Hello world!\n") 18 | return VirtualFile(file_obj, name="hello-world.txt") 19 | 20 | 21 | def generate_hello(): 22 | yield "Hello " 23 | yield "world!" 24 | yield "\n" 25 | 26 | 27 | class GeneratedDownloadView(VirtualDownloadView): 28 | def get_file(self): 29 | """Return wrapper on ``StringIteratorIO`` object.""" 30 | file_obj = TextIteratorIO(generate_hello()) 31 | return VirtualFile(file_obj, name="hello-world.txt") 32 | -------------------------------------------------------------------------------- /demo/demoproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for Django-DownloadView demo project. 2 | 3 | This module contains the WSGI application used by Django's development server 4 | and any production WSGI deployments. It should expose a module-level variable 5 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 6 | this application via the ``WSGI_APPLICATION`` setting. 7 | 8 | Usually you will have the standard Django WSGI application here, but it also 9 | might make sense to replace the whole Django WSGI application with a custom one 10 | that later delegates to the Django one. For example, you could introduce WSGI 11 | middleware here, or combine a Django application with an application of another 12 | framework. 13 | 14 | """ 15 | 16 | import os 17 | 18 | from django.core.wsgi import get_wsgi_application 19 | 20 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__) 21 | 22 | # This application object is used by any WSGI server configured to use this 23 | # file. This includes Django's development server, if the WSGI_APPLICATION 24 | # setting points here. 25 | application = get_wsgi_application() 26 | 27 | # Apply WSGI middleware here. 28 | # from helloworld.wsgi import HelloWorldApplication 29 | # application = HelloWorldApplication(application) 30 | -------------------------------------------------------------------------------- /demo/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | setup( 7 | name="django-downloadview-demo", 8 | version="1.0", 9 | description="Serve files with Django and reverse-proxies.", 10 | long_description=open(os.path.join(here, "README.rst")).read(), 11 | classifiers=[ 12 | "Development Status :: 5 - Production/Stable", 13 | "License :: OSI Approved :: BSD License", 14 | "Programming Language :: Python :: 3", 15 | "Framework :: Django", 16 | ], 17 | author="Benoît Bryon", 18 | author_email="benoit@marmelune.net", 19 | url="https://django-downloadview.readthedocs.io/", 20 | license="BSD", 21 | packages=["demoproject"], 22 | include_package_data=True, 23 | zip_safe=False, 24 | install_requires=["django-downloadview", "pytest-django"], 25 | entry_points={"console_scripts": ["demo = demoproject.manage:main"]}, 26 | ) 27 | -------------------------------------------------------------------------------- /django_downloadview/__init__.py: -------------------------------------------------------------------------------- 1 | """Serve files with Django and reverse proxies.""" 2 | 3 | from django_downloadview.api import * # NoQA 4 | 5 | import importlib.metadata 6 | 7 | #: Module version, as defined in PEP-0396. 8 | __version__ = importlib.metadata.version(__package__.replace("-", "_")) 9 | -------------------------------------------------------------------------------- /django_downloadview/apache/__init__.py: -------------------------------------------------------------------------------- 1 | """Optimizations for Apache. 2 | 3 | See also `documentation of mod_xsendfile for Apache 4 | `_ and :doc:`narrative documentation about 5 | Apache optimizations `. 6 | 7 | """ 8 | 9 | # API shortcuts. 10 | from django_downloadview.apache.decorators import x_sendfile # NoQA 11 | from django_downloadview.apache.middlewares import XSendfileMiddleware # NoQA 12 | from django_downloadview.apache.response import XSendfileResponse # NoQA 13 | from django_downloadview.apache.tests import assert_x_sendfile # NoQA 14 | -------------------------------------------------------------------------------- /django_downloadview/apache/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators to apply Apache X-Sendfile on a specific view.""" 2 | 3 | from django_downloadview.apache.middlewares import XSendfileMiddleware 4 | from django_downloadview.decorators import DownloadDecorator 5 | 6 | 7 | def x_sendfile(view_func, *args, **kwargs): 8 | """Apply 9 | :class:`~django_downloadview.apache.middlewares.XSendfileMiddleware` to 10 | ``view_func``. 11 | 12 | Proxies (``*args``, ``**kwargs``) to middleware constructor. 13 | 14 | """ 15 | decorator = DownloadDecorator(XSendfileMiddleware) 16 | return decorator(view_func, *args, **kwargs) 17 | -------------------------------------------------------------------------------- /django_downloadview/apache/middlewares.py: -------------------------------------------------------------------------------- 1 | from django_downloadview.apache.response import XSendfileResponse 2 | from django_downloadview.middlewares import ( 3 | NoRedirectionMatch, 4 | ProxiedDownloadMiddleware, 5 | ) 6 | 7 | 8 | class XSendfileMiddleware(ProxiedDownloadMiddleware): 9 | """Configurable middleware, for use in decorators or in global middlewares. 10 | 11 | Standard Django middlewares are configured globally via settings. Instances 12 | of this class are to be configured individually. It makes it possible to 13 | use this class as the factory in 14 | :py:class:`django_downloadview.decorators.DownloadDecorator`. 15 | 16 | """ 17 | 18 | def __init__( 19 | self, get_response=None, source_dir=None, source_url=None, destination_dir=None 20 | ): 21 | """Constructor.""" 22 | super().__init__(get_response, source_dir, source_url, destination_dir) 23 | 24 | def process_download_response(self, request, response): 25 | """Replace DownloadResponse instances by XSendfileResponse ones.""" 26 | try: 27 | redirect_url = self.get_redirect_url(response) 28 | except NoRedirectionMatch: 29 | return response 30 | return XSendfileResponse( 31 | file_path=redirect_url, 32 | content_type=response["Content-Type"], 33 | basename=response.basename, 34 | attachment=response.attachment, 35 | headers=response.headers, 36 | ) 37 | -------------------------------------------------------------------------------- /django_downloadview/apache/response.py: -------------------------------------------------------------------------------- 1 | """Apache's specific responses.""" 2 | 3 | import os.path 4 | 5 | from django_downloadview.response import ProxiedDownloadResponse, content_disposition 6 | 7 | 8 | class XSendfileResponse(ProxiedDownloadResponse): 9 | "Delegates serving file to Apache via X-Sendfile header." 10 | 11 | def __init__( 12 | self, file_path, content_type, basename=None, attachment=True, headers=None 13 | ): 14 | """Return a HttpResponse with headers for Apache X-Sendfile.""" 15 | # content-type must be provided only as keyword argument to response 16 | if headers and content_type: 17 | headers.pop("Content-Type", None) 18 | super().__init__(content_type=content_type, headers=headers) 19 | if attachment: 20 | self.basename = basename or os.path.basename(file_path) 21 | self["Content-Disposition"] = content_disposition(self.basename) 22 | self["X-Sendfile"] = file_path 23 | -------------------------------------------------------------------------------- /django_downloadview/apache/tests.py: -------------------------------------------------------------------------------- 1 | from django_downloadview.apache.response import XSendfileResponse 2 | 3 | 4 | class XSendfileValidator: 5 | """Utility class to validate XSendfileResponse instances. 6 | 7 | See also :py:func:`assert_x_sendfile` shortcut function. 8 | 9 | """ 10 | 11 | def __call__(self, test_case, response, **assertions): 12 | """Assert that ``response`` is a valid X-Sendfile response. 13 | 14 | Optional ``assertions`` dictionary can be used to check additional 15 | items: 16 | 17 | * ``basename``: the basename of the file in the response. 18 | 19 | * ``content_type``: the value of "Content-Type" header. 20 | 21 | * ``file_path``: the value of "X-Sendfile" header. 22 | 23 | """ 24 | self.assert_x_sendfile_response(test_case, response) 25 | for key, value in assertions.items(): 26 | assert_func = getattr(self, "assert_%s" % key) 27 | assert_func(test_case, response, value) 28 | 29 | def assert_x_sendfile_response(self, test_case, response): 30 | test_case.assertTrue(isinstance(response, XSendfileResponse)) 31 | 32 | def assert_basename(self, test_case, response, value): 33 | test_case.assertEqual(response.basename, value) 34 | 35 | def assert_content_type(self, test_case, response, value): 36 | test_case.assertEqual(response["Content-Type"], value) 37 | 38 | def assert_file_path(self, test_case, response, value): 39 | test_case.assertEqual(response["X-Sendfile"], value) 40 | 41 | def assert_attachment(self, test_case, response, value): 42 | header = "Content-Disposition" 43 | if value: 44 | test_case.assertTrue(response[header].startswith("attachment")) 45 | else: 46 | test_case.assertFalse(header in response) 47 | 48 | 49 | def assert_x_sendfile(test_case, response, **assertions): 50 | """Make ``test_case`` assert that ``response`` is a XSendfileResponse. 51 | 52 | Optional ``assertions`` dictionary can be used to check additional items: 53 | 54 | * ``basename``: the basename of the file in the response. 55 | 56 | * ``content_type``: the value of "Content-Type" header. 57 | 58 | * ``file_path``: the value of "X-Sendfile" header. 59 | 60 | """ 61 | validator = XSendfileValidator() 62 | return validator(test_case, response, **assertions) 63 | -------------------------------------------------------------------------------- /django_downloadview/api.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """Declaration of API shortcuts.""" 3 | 4 | from django_downloadview.files import HTTPFile, StorageFile, VirtualFile 5 | from django_downloadview.io import BytesIteratorIO, TextIteratorIO 6 | from django_downloadview.middlewares import ( 7 | BaseDownloadMiddleware, 8 | DownloadDispatcherMiddleware, 9 | SmartDownloadMiddleware, 10 | ) 11 | from django_downloadview.response import DownloadResponse, ProxiedDownloadResponse 12 | from django_downloadview.shortcuts import sendfile 13 | from django_downloadview.test import ( 14 | assert_download_response, 15 | setup_view, 16 | temporary_media_root, 17 | ) 18 | from django_downloadview.views import ( 19 | BaseDownloadView, 20 | DownloadMixin, 21 | HTTPDownloadView, 22 | ObjectDownloadView, 23 | PathDownloadView, 24 | StorageDownloadView, 25 | VirtualDownloadView, 26 | ) 27 | 28 | # Backward compatibility. 29 | StringIteratorIO = TextIteratorIO 30 | -------------------------------------------------------------------------------- /django_downloadview/decorators.py: -------------------------------------------------------------------------------- 1 | """View decorators. 2 | 3 | See also decorators provided by server-specific modules, such as 4 | :func:`django_downloadview.nginx.x_accel_redirect`. 5 | 6 | """ 7 | 8 | from functools import wraps 9 | 10 | from django.conf import settings 11 | from django.core.exceptions import PermissionDenied 12 | from django.core.signing import BadSignature, SignatureExpired, TimestampSigner 13 | 14 | 15 | class DownloadDecorator(object): 16 | """View decorator factory to apply middleware to ``view_func``'s response. 17 | 18 | Middleware instance is built from ``middleware_factory`` with ``*args`` and 19 | ``**kwargs``. Middleware factory is typically a class, such as some 20 | :py:class:`django_downloadview.BaseDownloadMiddleware` subclass. 21 | 22 | Response is built from view, then the middleware's ``process_response`` 23 | method is applied on response. 24 | 25 | """ 26 | 27 | def __init__(self, middleware_factory): 28 | """Create a download view decorator.""" 29 | self.middleware_factory = middleware_factory 30 | 31 | def __call__(self, view_func, *middleware_args, **middleware_kwargs): 32 | """Return ``view_func`` decorated with response middleware.""" 33 | 34 | def decorated(request, *view_args, **view_kwargs): 35 | """Return view's response modified by middleware.""" 36 | response = view_func(request, *view_args, **view_kwargs) 37 | middleware = self.middleware_factory(*middleware_args, **middleware_kwargs) 38 | return middleware.process_response(request, response) 39 | 40 | return decorated 41 | 42 | 43 | def _signature_is_valid(request): 44 | """ 45 | Validator that raises a PermissionDenied error on invalid and 46 | mismatching signatures. 47 | """ 48 | 49 | signer = TimestampSigner() 50 | signature = request.GET.get("X-Signature") 51 | expiration = getattr(settings, "DOWNLOADVIEW_URL_EXPIRATION", None) 52 | 53 | try: 54 | signature_path = signer.unsign(signature, max_age=expiration) 55 | except SignatureExpired as e: 56 | raise PermissionDenied("Signature expired") from e 57 | except BadSignature as e: 58 | raise PermissionDenied("Signature invalid") from e 59 | except Exception as e: 60 | raise PermissionDenied("Signature error") from e 61 | 62 | if request.path != signature_path: 63 | raise PermissionDenied("Signature mismatch") 64 | 65 | 66 | def signature_required(function): 67 | """ 68 | Decorator that checks for X-Signature query parameter to authorize access to views. 69 | """ 70 | 71 | @wraps(function) 72 | def decorator(request, *args, **kwargs): 73 | _signature_is_valid(request) 74 | return function(request, *args, **kwargs) 75 | 76 | return decorator 77 | -------------------------------------------------------------------------------- /django_downloadview/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions.""" 2 | 3 | 4 | class FileNotFound(IOError): 5 | """Requested file does not exist. 6 | 7 | This exception is to be raised when operations (such as read) fail because 8 | file does not exist (whatever the storage or location). 9 | 10 | """ 11 | -------------------------------------------------------------------------------- /django_downloadview/io.py: -------------------------------------------------------------------------------- 1 | """Low-level IO operations, for use with file wrappers.""" 2 | 3 | import io 4 | 5 | from django.utils.encoding import force_bytes, force_str 6 | 7 | 8 | class TextIteratorIO(io.TextIOBase): 9 | """A dynamically generated TextIO-like object. 10 | 11 | Original code by Matt Joiner from: 12 | 13 | * http://stackoverflow.com/questions/12593576/ 14 | * https://gist.github.com/anacrolix/3788413 15 | 16 | """ 17 | 18 | def __init__(self, iterator): 19 | #: Iterator/generator for content. 20 | self._iter = iterator 21 | 22 | #: Internal buffer. 23 | self._left = "" 24 | 25 | def readable(self): 26 | return True 27 | 28 | def _read1(self, n=None): 29 | while not self._left: 30 | try: 31 | self._left = next(self._iter) 32 | except StopIteration: 33 | break 34 | else: 35 | # Make sure we handle text. 36 | self._left = force_str(self._left) 37 | ret = self._left[:n] 38 | self._left = self._left[len(ret) :] 39 | return ret 40 | 41 | def read(self, n=None): 42 | """Return content up to ``n`` length.""" 43 | chunks = [] 44 | if n is None or n < 0: 45 | while True: 46 | m = self._read1() 47 | if not m: 48 | break 49 | chunks.append(m) 50 | else: 51 | while n > 0: 52 | m = self._read1(n) 53 | if not m: 54 | break 55 | n -= len(m) 56 | chunks.append(m) 57 | return "".join(chunks) 58 | 59 | def readline(self): 60 | chunks = [] 61 | while True: 62 | i = self._left.find("\n") 63 | if i == -1: 64 | chunks.append(self._left) 65 | try: 66 | self._left = next(self._iter) 67 | except StopIteration: 68 | self._left = "" 69 | break 70 | else: 71 | chunks.append(self._left[: i + 1]) 72 | self._left = self._left[i + 1 :] 73 | break 74 | return "".join(chunks) 75 | 76 | 77 | class BytesIteratorIO(io.BytesIO): 78 | """A dynamically generated BytesIO-like object. 79 | 80 | Original code by Matt Joiner from: 81 | 82 | * http://stackoverflow.com/questions/12593576/ 83 | * https://gist.github.com/anacrolix/3788413 84 | 85 | """ 86 | 87 | def __init__(self, iterator): 88 | #: Iterator/generator for content. 89 | self._iter = iterator 90 | 91 | #: Internal buffer. 92 | self._left = b"" 93 | 94 | def readable(self): 95 | return True 96 | 97 | def _read1(self, n=None): 98 | while not self._left: 99 | try: 100 | self._left = next(self._iter) 101 | except StopIteration: 102 | break 103 | else: 104 | # Make sure we handle text. 105 | self._left = force_bytes(self._left) 106 | ret = self._left[:n] 107 | self._left = self._left[len(ret) :] 108 | return ret 109 | 110 | def read(self, n=None): 111 | """Return content up to ``n`` length.""" 112 | chunks = [] 113 | if n is None or n < 0: 114 | while True: 115 | m = self._read1() 116 | if not m: 117 | break 118 | chunks.append(m) 119 | else: 120 | while n > 0: 121 | m = self._read1(n) 122 | if not m: 123 | break 124 | n -= len(m) 125 | chunks.append(m) 126 | return b"".join(chunks) 127 | 128 | def readline(self): 129 | chunks = [] 130 | while True: 131 | i = self._left.find(b"\n") 132 | if i == -1: 133 | chunks.append(self._left) 134 | try: 135 | self._left = next(self._iter) 136 | except StopIteration: 137 | self._left = b"" 138 | break 139 | else: 140 | chunks.append(self._left[: i + 1]) 141 | self._left = self._left[i + 1 :] 142 | break 143 | return b"".join(chunks) 144 | -------------------------------------------------------------------------------- /django_downloadview/lighttpd/__init__.py: -------------------------------------------------------------------------------- 1 | """Optimizations for Lighttpd. 2 | 3 | See also `documentation of X-Sendfile for Lighttpd 4 | `_ and 5 | :doc:`narrative documentation about Lighttpd optimizations 6 | `. 7 | 8 | """ 9 | 10 | # API shortcuts. 11 | from django_downloadview.lighttpd.decorators import x_sendfile # NoQA 12 | from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA 13 | from django_downloadview.lighttpd.response import XSendfileResponse # NoQA 14 | from django_downloadview.lighttpd.tests import assert_x_sendfile # NoQA 15 | -------------------------------------------------------------------------------- /django_downloadview/lighttpd/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators to apply Lighttpd X-Sendfile on a specific view.""" 2 | 3 | from django_downloadview.decorators import DownloadDecorator 4 | from django_downloadview.lighttpd.middlewares import XSendfileMiddleware 5 | 6 | 7 | def x_sendfile(view_func, *args, **kwargs): 8 | """Apply 9 | :class:`~django_downloadview.lighttpd.middlewares.XSendfileMiddleware` to 10 | ``view_func``. 11 | 12 | Proxies (``*args``, ``**kwargs``) to middleware constructor. 13 | 14 | """ 15 | decorator = DownloadDecorator(XSendfileMiddleware) 16 | return decorator(view_func, *args, **kwargs) 17 | -------------------------------------------------------------------------------- /django_downloadview/lighttpd/middlewares.py: -------------------------------------------------------------------------------- 1 | from django_downloadview.lighttpd.response import XSendfileResponse 2 | from django_downloadview.middlewares import ( 3 | NoRedirectionMatch, 4 | ProxiedDownloadMiddleware, 5 | ) 6 | 7 | 8 | class XSendfileMiddleware(ProxiedDownloadMiddleware): 9 | """Configurable middleware, for use in decorators or in global middlewares. 10 | 11 | Standard Django middlewares are configured globally via settings. Instances 12 | of this class are to be configured individually. It makes it possible to 13 | use this class as the factory in 14 | :py:class:`django_downloadview.decorators.DownloadDecorator`. 15 | 16 | """ 17 | 18 | def __init__( 19 | self, get_response=None, source_dir=None, source_url=None, destination_dir=None 20 | ): 21 | """Constructor.""" 22 | super().__init__(get_response, source_dir, source_url, destination_dir) 23 | 24 | def process_download_response(self, request, response): 25 | """Replace DownloadResponse instances by XSendfileResponse ones.""" 26 | try: 27 | redirect_url = self.get_redirect_url(response) 28 | except NoRedirectionMatch: 29 | return response 30 | return XSendfileResponse( 31 | file_path=redirect_url, 32 | content_type=response["Content-Type"], 33 | basename=response.basename, 34 | attachment=response.attachment, 35 | headers=response.headers, 36 | ) 37 | -------------------------------------------------------------------------------- /django_downloadview/lighttpd/response.py: -------------------------------------------------------------------------------- 1 | """Lighttpd's specific responses.""" 2 | 3 | import os.path 4 | 5 | from django_downloadview.response import ProxiedDownloadResponse, content_disposition 6 | 7 | 8 | class XSendfileResponse(ProxiedDownloadResponse): 9 | "Delegates serving file to Lighttpd via X-Sendfile header." 10 | 11 | def __init__( 12 | self, file_path, content_type, basename=None, attachment=True, headers=None 13 | ): 14 | """Return a HttpResponse with headers for Lighttpd X-Sendfile.""" 15 | # content-type must be porvided only as keyword argument to response 16 | if headers and content_type: 17 | headers.pop("Content-Type", None) 18 | super().__init__(content_type=content_type, headers=headers) 19 | if attachment: 20 | self.basename = basename or os.path.basename(file_path) 21 | self["Content-Disposition"] = content_disposition(self.basename) 22 | self["X-Sendfile"] = file_path 23 | -------------------------------------------------------------------------------- /django_downloadview/lighttpd/tests.py: -------------------------------------------------------------------------------- 1 | import django_downloadview.apache.tests 2 | from django_downloadview.lighttpd.response import XSendfileResponse 3 | 4 | 5 | class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator): 6 | """Utility class to validate XSendfileResponse instances. 7 | 8 | See also :py:func:`assert_x_sendfile` shortcut function. 9 | 10 | """ 11 | 12 | def assert_x_sendfile_response(self, test_case, response): 13 | test_case.assertTrue(isinstance(response, XSendfileResponse)) 14 | 15 | 16 | def assert_x_sendfile(test_case, response, **assertions): 17 | """Make ``test_case`` assert that ``response`` is a XSendfileResponse. 18 | 19 | Optional ``assertions`` dictionary can be used to check additional items: 20 | 21 | * ``basename``: the basename of the file in the response. 22 | 23 | * ``content_type``: the value of "Content-Type" header. 24 | 25 | * ``file_path``: the value of "X-Sendfile" header. 26 | 27 | """ 28 | validator = XSendfileValidator() 29 | return validator(test_case, response, **assertions) 30 | -------------------------------------------------------------------------------- /django_downloadview/nginx/__init__.py: -------------------------------------------------------------------------------- 1 | """Optimizations for Nginx. 2 | 3 | See also `Nginx X-accel documentation `_ and 4 | :doc:`narrative documentation about Nginx optimizations 5 | `. 6 | 7 | """ 8 | 9 | # API shortcuts. 10 | from django_downloadview.nginx.decorators import x_accel_redirect # NoQA 11 | from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware # NoQA 12 | from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA 13 | from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA 14 | -------------------------------------------------------------------------------- /django_downloadview/nginx/decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators to apply Nginx X-Accel on a specific view.""" 2 | 3 | from django_downloadview.decorators import DownloadDecorator 4 | from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware 5 | 6 | 7 | def x_accel_redirect(view_func, *args, **kwargs): 8 | """Apply 9 | :class:`~django_downloadview.nginx.middlewares.XAccelRedirectMiddleware` to 10 | ``view_func``. 11 | 12 | Proxies (``*args``, ``**kwargs``) to middleware constructor. 13 | 14 | """ 15 | decorator = DownloadDecorator(XAccelRedirectMiddleware) 16 | return decorator(view_func, *args, **kwargs) 17 | -------------------------------------------------------------------------------- /django_downloadview/nginx/middlewares.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | from django_downloadview.middlewares import ( 7 | NoRedirectionMatch, 8 | ProxiedDownloadMiddleware, 9 | ) 10 | from django_downloadview.nginx.response import XAccelRedirectResponse 11 | 12 | 13 | class XAccelRedirectMiddleware(ProxiedDownloadMiddleware): 14 | """Configurable middleware, for use in decorators or in global middlewares. 15 | 16 | Standard Django middlewares are configured globally via settings. Instances 17 | of this class are to be configured individually. It makes it possible to 18 | use this class as the factory in 19 | :py:class:`django_downloadview.decorators.DownloadDecorator`. 20 | 21 | """ 22 | 23 | def __init__( 24 | self, 25 | get_response=None, 26 | source_dir=None, 27 | source_url=None, 28 | destination_url=None, 29 | expires=None, 30 | with_buffering=None, 31 | limit_rate=None, 32 | media_root=None, 33 | media_url=None, 34 | ): 35 | """Constructor.""" 36 | if media_url is not None: 37 | warnings.warn( 38 | "%s ``media_url`` is deprecated. Use " 39 | "``destination_url`` instead." % self.__class__.__name__, 40 | DeprecationWarning, 41 | ) 42 | if destination_url is None: 43 | destination_url = media_url 44 | else: 45 | destination_url = destination_url 46 | else: 47 | destination_url = destination_url 48 | if media_root is not None: 49 | warnings.warn( 50 | "%s ``media_root`` is deprecated. Use " 51 | "``source_dir`` instead." % self.__class__.__name__, 52 | DeprecationWarning, 53 | ) 54 | if source_dir is None: 55 | source_dir = media_root 56 | else: 57 | source_dir = source_dir 58 | else: 59 | source_dir = source_dir 60 | 61 | super().__init__(get_response, source_dir, source_url, destination_url) 62 | 63 | self.expires = expires 64 | self.with_buffering = with_buffering 65 | self.limit_rate = limit_rate 66 | 67 | def process_download_response(self, request, response): 68 | """Replace DownloadResponse instances by NginxDownloadResponse ones.""" 69 | try: 70 | redirect_url = self.get_redirect_url(response) 71 | except NoRedirectionMatch: 72 | return response 73 | if self.expires: 74 | expires = self.expires 75 | else: 76 | try: 77 | expires = response.expires 78 | except AttributeError: 79 | expires = None 80 | return XAccelRedirectResponse( 81 | redirect_url=redirect_url, 82 | content_type=response["Content-Type"], 83 | basename=response.basename, 84 | expires=expires, 85 | with_buffering=self.with_buffering, 86 | limit_rate=self.limit_rate, 87 | attachment=response.attachment, 88 | headers=response.headers, 89 | ) 90 | 91 | 92 | class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware): 93 | """Apply X-Accel-Redirect globally, via Django settings. 94 | 95 | Available settings are: 96 | 97 | NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL: 98 | The string at the beginning of URLs to replace with 99 | ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. 100 | If ``None``, then URLs aren't captured. 101 | Defaults to ``settings.MEDIA_URL``. 102 | 103 | NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR: 104 | The string at the beginning of filenames (path) to replace with 105 | ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. 106 | If ``None``, then filenames aren't captured. 107 | Defaults to ``settings.MEDIA_ROOT``. 108 | 109 | NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL: 110 | The base URL where requests are proxied to. 111 | If ``None`` an ImproperlyConfigured exception is raised. 112 | 113 | .. note:: 114 | 115 | The following settings are deprecated since version 1.1. 116 | URLs can be used as redirection source since 1.1, and then "MEDIA_ROOT" 117 | and "MEDIA_URL" became too confuse. 118 | 119 | NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT: 120 | Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR``. 121 | 122 | NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL: 123 | Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. 124 | 125 | """ 126 | 127 | def __init__(self, get_response=None): 128 | """Use Django settings as configuration.""" 129 | if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None: 130 | raise ImproperlyConfigured( 131 | "settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is " 132 | "required by %s middleware" % self.__class__.__name__ 133 | ) 134 | super().__init__( 135 | get_response=get_response, 136 | source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR, 137 | source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL, 138 | destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL, 139 | expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES, 140 | with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING, 141 | limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE, 142 | ) 143 | -------------------------------------------------------------------------------- /django_downloadview/nginx/response.py: -------------------------------------------------------------------------------- 1 | """Nginx's specific responses.""" 2 | 3 | from datetime import timedelta 4 | 5 | from django.utils.timezone import now 6 | 7 | from django_downloadview.response import ProxiedDownloadResponse, content_disposition 8 | from django_downloadview.utils import content_type_to_charset, url_basename 9 | 10 | 11 | class XAccelRedirectResponse(ProxiedDownloadResponse): 12 | "Http response that delegates serving file to Nginx via X-Accel headers." 13 | 14 | def __init__( 15 | self, 16 | redirect_url, 17 | content_type, 18 | basename=None, 19 | expires=None, 20 | with_buffering=None, 21 | limit_rate=None, 22 | attachment=True, 23 | headers=None, 24 | ): 25 | """Return a HttpResponse with headers for Nginx X-Accel-Redirect.""" 26 | # content-type must be porvided only as keyword argument to response 27 | if headers and content_type: 28 | headers.pop("Content-Type", None) 29 | super().__init__(content_type=content_type, headers=headers) 30 | if attachment: 31 | self.basename = basename or url_basename(redirect_url, content_type) 32 | self["Content-Disposition"] = content_disposition(self.basename) 33 | self["X-Accel-Redirect"] = redirect_url 34 | self["X-Accel-Charset"] = content_type_to_charset(content_type) 35 | if with_buffering is not None: 36 | self["X-Accel-Buffering"] = with_buffering and "yes" or "no" 37 | if expires: 38 | expire_seconds = timedelta(expires - now()).seconds 39 | self["X-Accel-Expires"] = expire_seconds 40 | elif expires is not None: # We explicitely want it off. 41 | self["X-Accel-Expires"] = "off" 42 | if limit_rate is not None: 43 | self["X-Accel-Limit-Rate"] = limit_rate and "%d" % limit_rate or "off" 44 | -------------------------------------------------------------------------------- /django_downloadview/nginx/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings around Nginx X-Accel. 2 | 3 | .. warning:: 4 | 5 | These settings are deprecated since version 1.3. You can now provide custom 6 | configuration via `DOWNLOADVIEW_BACKEND` setting. See :doc:`/settings` 7 | for details. 8 | 9 | """ 10 | 11 | import warnings 12 | 13 | from django.conf import settings 14 | from django.core.exceptions import ImproperlyConfigured 15 | 16 | # In version 1.3, former XAccelRedirectMiddleware has been renamed to 17 | # SingleXAccelRedirectMiddleware. So tell the users. 18 | deprecated_middleware = "django_downloadview.nginx.XAccelRedirectMiddleware" 19 | 20 | 21 | if deprecated_middleware in settings.MIDDLEWARE: 22 | raise ImproperlyConfigured( 23 | "{deprecated_middleware} middleware has been renamed as of " 24 | "django-downloadview version 1.3. You may use " 25 | '"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, ' 26 | 'or upgrade to "django_downloadview.SmartDownloadDispatcher". ' 27 | ) 28 | 29 | 30 | deprecated_msg = ( 31 | "settings.{deprecated} is deprecated. You should combine " 32 | '"django_downloadview.SmartDownloadDispatcher" with ' 33 | "with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead." 34 | ) 35 | 36 | 37 | #: Default value for X-Accel-Buffering header. 38 | #: Also default value for 39 | #: ``settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING``. 40 | #: 41 | #: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Buffering 42 | #: 43 | #: Default value is None, which means "let Nginx choose", i.e. use Nginx 44 | #: defaults or specific configuration. 45 | #: 46 | #: If set to ``False``, Nginx buffering is disabled. 47 | #: If set to ``True``, Nginx buffering is enabled. 48 | DEFAULT_WITH_BUFFERING = None 49 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING" 50 | if hasattr(settings, setting_name): 51 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 52 | if not hasattr(settings, setting_name): 53 | setattr(settings, setting_name, DEFAULT_WITH_BUFFERING) 54 | 55 | 56 | #: Default value for X-Accel-Limit-Rate header. 57 | #: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE``. 58 | #: 59 | #: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Rate 60 | #: 61 | #: Default value is None, which means "let Nginx choose", i.e. use Nginx 62 | #: defaults or specific configuration. 63 | #: 64 | #: If set to ``False``, Nginx limit rate is disabled. 65 | #: Else, it indicates the limit rate in bytes. 66 | DEFAULT_LIMIT_RATE = None 67 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE" 68 | if hasattr(settings, setting_name): 69 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 70 | if not hasattr(settings, setting_name): 71 | setattr(settings, setting_name, DEFAULT_LIMIT_RATE) 72 | 73 | 74 | #: Default value for X-Accel-Limit-Expires header. 75 | #: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES``. 76 | #: 77 | #: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Expires 78 | #: 79 | #: Default value is None, which means "let Nginx choose", i.e. use Nginx 80 | #: defaults or specific configuration. 81 | #: 82 | #: If set to ``False``, Nginx buffering is disabled. 83 | #: Else, it indicates the expiration delay, in seconds. 84 | DEFAULT_EXPIRES = None 85 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES" 86 | if hasattr(settings, setting_name): 87 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 88 | if not hasattr(settings, setting_name): 89 | setattr(settings, setting_name, DEFAULT_EXPIRES) 90 | 91 | 92 | #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR. 93 | DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT 94 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT" 95 | if hasattr(settings, setting_name): 96 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 97 | DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT 98 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR" 99 | if hasattr(settings, setting_name): 100 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 101 | if not hasattr(settings, setting_name): 102 | setattr(settings, setting_name, DEFAULT_SOURCE_DIR) 103 | 104 | 105 | #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL. 106 | DEFAULT_SOURCE_URL = settings.MEDIA_URL 107 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL" 108 | if hasattr(settings, setting_name): 109 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 110 | if not hasattr(settings, setting_name): 111 | setattr(settings, setting_name, DEFAULT_SOURCE_URL) 112 | 113 | 114 | #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL. 115 | DEFAULT_DESTINATION_URL = None 116 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL" 117 | if hasattr(settings, setting_name): 118 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 119 | DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL 120 | setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL" 121 | if hasattr(settings, setting_name): 122 | warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) 123 | if not hasattr(settings, setting_name): 124 | setattr(settings, setting_name, DEFAULT_DESTINATION_URL) 125 | -------------------------------------------------------------------------------- /django_downloadview/nginx/tests.py: -------------------------------------------------------------------------------- 1 | from django_downloadview.nginx.response import XAccelRedirectResponse 2 | 3 | 4 | class XAccelRedirectValidator: 5 | """Utility class to validate XAccelRedirectResponse instances. 6 | 7 | See also :py:func:`assert_x_accel_redirect` shortcut function. 8 | 9 | """ 10 | 11 | def __call__(self, test_case, response, **assertions): 12 | """Assert that ``response`` is a valid X-Accel-Redirect response. 13 | 14 | Optional ``assertions`` dictionary can be used to check additional 15 | items: 16 | 17 | * ``basename``: the basename of the file in the response. 18 | 19 | * ``content_type``: the value of "Content-Type" header. 20 | 21 | * ``redirect_url``: the value of "X-Accel-Redirect" header. 22 | 23 | * ``charset``: the value of ``X-Accel-Charset`` header. 24 | 25 | * ``with_buffering``: the value of ``X-Accel-Buffering`` header. 26 | If ``False``, then makes sure that the header disables buffering. 27 | If ``None``, then makes sure that the header is not set. 28 | 29 | * ``expires``: the value of ``X-Accel-Expires`` header. 30 | If ``False``, then makes sure that the header disables expiration. 31 | If ``None``, then makes sure that the header is not set. 32 | 33 | * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. 34 | If ``False``, then makes sure that the header disables limit rate. 35 | If ``None``, then makes sure that the header is not set. 36 | 37 | """ 38 | self.assert_x_accel_redirect_response(test_case, response) 39 | for key, value in assertions.items(): 40 | assert_func = getattr(self, "assert_%s" % key) 41 | assert_func(test_case, response, value) 42 | 43 | def assert_x_accel_redirect_response(self, test_case, response): 44 | test_case.assertTrue(isinstance(response, XAccelRedirectResponse)) 45 | 46 | def assert_basename(self, test_case, response, value): 47 | test_case.assertEqual(response.basename, value) 48 | 49 | def assert_content_type(self, test_case, response, value): 50 | test_case.assertEqual(response["Content-Type"], value) 51 | 52 | def assert_redirect_url(self, test_case, response, value): 53 | test_case.assertEqual(response["X-Accel-Redirect"], value) 54 | 55 | def assert_charset(self, test_case, response, value): 56 | test_case.assertEqual(response["X-Accel-Charset"], value) 57 | 58 | def assert_with_buffering(self, test_case, response, value): 59 | header = "X-Accel-Buffering" 60 | if value is None: 61 | test_case.assertFalse(header in response) 62 | elif value: 63 | test_case.assertEqual(header, "yes") 64 | else: 65 | test_case.assertEqual(header, "no") 66 | 67 | def assert_expires(self, test_case, response, value): 68 | header = "X-Accel-Expires" 69 | if value is None: 70 | test_case.assertFalse(header in response) 71 | elif not value: 72 | test_case.assertEqual(header, "off") 73 | else: 74 | test_case.assertEqual(header, value) 75 | 76 | def assert_limit_rate(self, test_case, response, value): 77 | header = "X-Accel-Limit-Rate" 78 | if value is None: 79 | test_case.assertFalse(header in response) 80 | elif not value: 81 | test_case.assertEqual(header, "off") 82 | else: 83 | test_case.assertEqual(header, value) 84 | 85 | def assert_attachment(self, test_case, response, value): 86 | header = "Content-Disposition" 87 | if value: 88 | test_case.assertTrue(response[header].startswith("attachment")) 89 | else: 90 | test_case.assertFalse(header in response) 91 | 92 | 93 | def assert_x_accel_redirect(test_case, response, **assertions): 94 | """Make ``test_case`` assert that ``response`` is a XAccelRedirectResponse. 95 | 96 | Optional ``assertions`` dictionary can be used to check additional items: 97 | 98 | * ``basename``: the basename of the file in the response. 99 | 100 | * ``content_type``: the value of "Content-Type" header. 101 | 102 | * ``redirect_url``: the value of "X-Accel-Redirect" header. 103 | 104 | * ``charset``: the value of ``X-Accel-Charset`` header. 105 | 106 | * ``with_buffering``: the value of ``X-Accel-Buffering`` header. 107 | If ``False``, then makes sure that the header disables buffering. 108 | If ``None``, then makes sure that the header is not set. 109 | 110 | * ``expires``: the value of ``X-Accel-Expires`` header. 111 | If ``False``, then makes sure that the header disables expiration. 112 | If ``None``, then makes sure that the header is not set. 113 | 114 | * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. 115 | If ``False``, then makes sure that the header disables limit rate. 116 | If ``None``, then makes sure that the header is not set. 117 | 118 | """ 119 | validator = XAccelRedirectValidator() 120 | return validator(test_case, response, **assertions) 121 | -------------------------------------------------------------------------------- /django_downloadview/shortcuts.py: -------------------------------------------------------------------------------- 1 | """Port of django-sendfile in django-downloadview.""" 2 | 3 | from django_downloadview.views.path import PathDownloadView 4 | 5 | 6 | def sendfile( 7 | request, 8 | filename, 9 | attachment=False, 10 | attachment_filename=None, 11 | mimetype=None, 12 | encoding=None, 13 | ): 14 | """Port of django-sendfile's API in django-downloadview. 15 | 16 | Instantiates a :class:`~django_downloadview.views.path.PathDownloadView` to 17 | stream the file by ``filename``. 18 | 19 | """ 20 | view = PathDownloadView.as_view( 21 | path=filename, 22 | attachment=attachment, 23 | basename=attachment_filename, 24 | mimetype=mimetype, 25 | encoding=encoding, 26 | ) 27 | return view(request) 28 | -------------------------------------------------------------------------------- /django_downloadview/storage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | from django.core.signing import TimestampSigner 3 | 4 | 5 | class SignedURLMixin: 6 | """ 7 | Mixin for generating signed file URLs with compatible storage backends. 8 | 9 | Adds X-Signature query parameters to the normal URLs generated by the storage class. 10 | """ 11 | 12 | def url(self, name): 13 | path = super().url(name) 14 | signer = TimestampSigner() 15 | signature = signer.sign(path) 16 | return "{}?X-Signature={}".format(path, signature) 17 | 18 | 19 | class SignedFileSystemStorage(SignedURLMixin, FileSystemStorage): 20 | """ 21 | Specialized filesystem storage that signs file URLs for clients. 22 | """ 23 | -------------------------------------------------------------------------------- /django_downloadview/test.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | 4 | from django.conf import settings 5 | from django.test.utils import override_settings 6 | from django.utils.encoding import force_bytes 7 | 8 | from django_downloadview.middlewares import is_download_response 9 | from django_downloadview.response import encode_basename_ascii, encode_basename_utf8 10 | 11 | 12 | def setup_view(view, request, *args, **kwargs): 13 | """Mimic ``as_view()``, but returns view instance. 14 | 15 | Use this function to get view instances on which you can run unit tests, 16 | by testing specific methods. 17 | 18 | This is an early implementation of 19 | https://code.djangoproject.com/ticket/20456 20 | 21 | ``view`` 22 | A view instance, such as ``TemplateView(template_name='dummy.html')``. 23 | Initialization arguments are the same you would pass to ``as_view()``. 24 | 25 | ``request`` 26 | A request object, typically built with 27 | :class:`~django.test.client.RequestFactory`. 28 | 29 | ``args`` and ``kwargs`` 30 | "URLconf" positional and keyword arguments, the same you would pass to 31 | :func:`~django.core.urlresolvers.reverse`. 32 | 33 | """ 34 | view.request = request 35 | view.args = args 36 | view.kwargs = kwargs 37 | return view 38 | 39 | 40 | class temporary_media_root(override_settings): 41 | """Temporarily override settings.MEDIA_ROOT with a temporary directory. 42 | 43 | The temporary directory is automatically created and destroyed. 44 | 45 | Use this function as a context manager: 46 | 47 | >>> from django_downloadview.test import temporary_media_root 48 | >>> from django.conf import settings # NoQA 49 | >>> global_media_root = settings.MEDIA_ROOT 50 | >>> with temporary_media_root(): 51 | ... global_media_root == settings.MEDIA_ROOT 52 | False 53 | >>> global_media_root == settings.MEDIA_ROOT 54 | True 55 | 56 | Or as a decorator: 57 | 58 | >>> @temporary_media_root() 59 | ... def use_temporary_media_root(): 60 | ... return settings.MEDIA_ROOT 61 | >>> tmp_media_root = use_temporary_media_root() 62 | >>> global_media_root == tmp_media_root 63 | False 64 | >>> global_media_root == settings.MEDIA_ROOT 65 | True 66 | 67 | """ 68 | 69 | def enable(self): 70 | """Create a temporary directory and use it to override 71 | settings.MEDIA_ROOT.""" 72 | tmp_dir = tempfile.mkdtemp() 73 | self.options["MEDIA_ROOT"] = tmp_dir 74 | super().enable() 75 | 76 | def disable(self): 77 | """Remove directory settings.MEDIA_ROOT then restore original 78 | setting.""" 79 | shutil.rmtree(settings.MEDIA_ROOT) 80 | super().disable() 81 | 82 | 83 | class DownloadResponseValidator(object): 84 | """Utility class to validate DownloadResponse instances.""" 85 | 86 | def __call__(self, test_case, response, **assertions): 87 | """Assert that ``response`` is a valid DownloadResponse instance. 88 | 89 | Optional ``assertions`` dictionary can be used to check additional 90 | items: 91 | 92 | * ``basename``: the basename of the file in the response. 93 | 94 | * ``content_type``: the value of "Content-Type" header. 95 | 96 | * ``mime_type``: the MIME type part of "Content-Type" header (without 97 | charset). 98 | 99 | * ``content``: the contents of the file. 100 | 101 | * ``attachment``: whether the file is returned as attachment or not. 102 | 103 | """ 104 | self.assert_download_response(test_case, response) 105 | for key, value in assertions.items(): 106 | assert_func = getattr(self, "assert_%s" % key) 107 | assert_func(test_case, response, value) 108 | 109 | def assert_download_response(self, test_case, response): 110 | test_case.assertTrue(is_download_response(response)) 111 | 112 | def assert_basename(self, test_case, response, value): 113 | """Implies ``attachement is True``.""" 114 | ascii_name = encode_basename_ascii(value) 115 | utf8_name = encode_basename_utf8(value) 116 | check_utf8 = False 117 | check_ascii = False 118 | if ascii_name == utf8_name: # Only ASCII characters. 119 | check_ascii = True 120 | if "filename*=" in response["Content-Disposition"]: 121 | check_utf8 = True 122 | else: 123 | check_utf8 = True 124 | if "filename=" in response["Content-Disposition"]: 125 | check_ascii = True 126 | if check_ascii: 127 | test_case.assertIn( 128 | f'filename="{ascii_name}"', 129 | response["Content-Disposition"], 130 | ) 131 | if check_utf8: 132 | test_case.assertIn( 133 | f"filename*=UTF-8''{utf8_name}", 134 | response["Content-Disposition"], 135 | ) 136 | 137 | def assert_content_type(self, test_case, response, value): 138 | test_case.assertEqual(response["Content-Type"], value) 139 | 140 | def assert_mime_type(self, test_case, response, value): 141 | test_case.assertTrue(response["Content-Type"].startswith(value)) 142 | 143 | def assert_content(self, test_case, response, value): 144 | """Assert value equals response's content (byte comparison).""" 145 | parts = [force_bytes(s) for s in response.streaming_content] 146 | test_case.assertEqual(b"".join(parts), force_bytes(value)) 147 | 148 | def assert_attachment(self, test_case, response, value): 149 | if value: 150 | test_case.assertTrue("attachment;" in response["Content-Disposition"]) 151 | else: 152 | test_case.assertTrue( 153 | "Content-Disposition" not in response 154 | or "attachment;" not in response["Content-Disposition"] 155 | ) 156 | 157 | 158 | def assert_download_response(test_case, response, **assertions): 159 | """Make ``test_case`` assert that ``response`` meets ``assertions``. 160 | 161 | Optional ``assertions`` dictionary can be used to check additional items: 162 | 163 | * ``basename``: the basename of the file in the response. 164 | 165 | * ``content_type``: the value of "Content-Type" header. 166 | 167 | * ``mime_type``: the MIME type part of "Content-Type" header (without 168 | charset). 169 | 170 | * ``content``: the contents of the file. 171 | 172 | * ``attachment``: whether the file is returned as attachment or not. 173 | 174 | """ 175 | validator = DownloadResponseValidator() 176 | return validator(test_case, response, **assertions) 177 | -------------------------------------------------------------------------------- /django_downloadview/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions that may be implemented in external packages.""" 2 | 3 | import re 4 | 5 | charset_pattern = re.compile(r"charset=(?P.+)$", re.I | re.U) 6 | 7 | 8 | def content_type_to_charset(content_type): 9 | """Return charset part of content-type header. 10 | 11 | >>> from django_downloadview.utils import content_type_to_charset 12 | >>> content_type_to_charset('text/html; charset=utf-8') 13 | 'utf-8' 14 | 15 | """ 16 | match = re.search(charset_pattern, content_type) 17 | if match: 18 | return match.group("charset") 19 | 20 | 21 | def url_basename(url, content_type): 22 | """Return best-guess basename from URL and content-type. 23 | 24 | >>> from django_downloadview.utils import url_basename 25 | 26 | If URL contains extension, it is kept as-is. 27 | 28 | >>> print(url_basename(u'/path/to/somefile.rst', 'text/plain')) 29 | somefile.rst 30 | 31 | """ 32 | return url.split("/")[-1] 33 | 34 | 35 | def import_member(import_string): 36 | """Import one member of Python module by path. 37 | 38 | >>> import os.path 39 | >>> imported = import_member('os.path.supports_unicode_filenames') 40 | >>> os.path.supports_unicode_filenames is imported 41 | True 42 | 43 | """ 44 | module_name, factory_name = str(import_string).rsplit(".", 1) 45 | module = __import__(module_name, globals(), locals(), [factory_name], 0) 46 | return getattr(module, factory_name) 47 | -------------------------------------------------------------------------------- /django_downloadview/views/__init__.py: -------------------------------------------------------------------------------- 1 | """Views to stream files.""" 2 | 3 | # API shortcuts. 4 | from django_downloadview.views.base import BaseDownloadView, DownloadMixin # NoQA 5 | from django_downloadview.views.http import HTTPDownloadView # NoQA 6 | from django_downloadview.views.object import ObjectDownloadView # NoQA 7 | from django_downloadview.views.path import PathDownloadView # NoQA 8 | from django_downloadview.views.storage import StorageDownloadView # NoQA 9 | from django_downloadview.views.virtual import VirtualDownloadView # NoQA 10 | -------------------------------------------------------------------------------- /django_downloadview/views/http.py: -------------------------------------------------------------------------------- 1 | """Stream files given an URL, i.e. files you want to proxy.""" 2 | 3 | from django_downloadview.files import HTTPFile 4 | from django_downloadview.views.base import BaseDownloadView 5 | 6 | import requests 7 | 8 | 9 | class HTTPDownloadView(BaseDownloadView): 10 | """Proxy files that live on remote servers.""" 11 | 12 | #: URL to download (the one we are proxying). 13 | url = "" 14 | 15 | #: Additional keyword arguments for request handler. 16 | request_kwargs = {} 17 | 18 | def get_request_factory(self): 19 | """Return request factory to perform actual HTTP request. 20 | 21 | Default implementation returns :func:`requests.get` callable. 22 | 23 | """ 24 | return requests.get 25 | 26 | def get_request_kwargs(self): 27 | """Return keyword arguments for use with :meth:`get_request_factory`. 28 | 29 | Default implementation returns :attr:`request_kwargs`. 30 | 31 | """ 32 | return self.request_kwargs 33 | 34 | def get_url(self): 35 | """Return remote file URL (the one we are proxying). 36 | 37 | Default implementation returns :attr:`url`. 38 | 39 | """ 40 | return self.url 41 | 42 | def get_file(self): 43 | """Return wrapper which has an ``url`` attribute.""" 44 | return HTTPFile( 45 | request_factory=self.get_request_factory(), 46 | name=self.get_basename(), 47 | url=self.get_url(), 48 | **self.get_request_kwargs(), 49 | ) 50 | -------------------------------------------------------------------------------- /django_downloadview/views/object.py: -------------------------------------------------------------------------------- 1 | """Stream files that live in models.""" 2 | 3 | from django.views.generic.detail import SingleObjectMixin 4 | 5 | from django_downloadview.exceptions import FileNotFound 6 | from django_downloadview.views.base import BaseDownloadView 7 | 8 | 9 | class ObjectDownloadView(SingleObjectMixin, BaseDownloadView): 10 | """Serve file fields from models. 11 | 12 | This class extends :class:`~django.views.generic.detail.SingleObjectMixin`, 13 | so you can use its arguments to target the instance to operate on: 14 | ``slug``, ``slug_kwarg``, ``model``, ``queryset``... 15 | 16 | In addition to :class:`~django.views.generic.detail.SingleObjectMixin` 17 | arguments, you can set arguments related to the file to be downloaded: 18 | 19 | * :attr:`file_field`; 20 | * :attr:`basename_field`; 21 | * :attr:`encoding_field`; 22 | * :attr:`mime_type_field`; 23 | * :attr:`charset_field`; 24 | * :attr:`modification_time_field`; 25 | * :attr:`size_field`. 26 | 27 | :attr:`file_field` is the main one. Other arguments are provided for 28 | convenience, in case your model holds some (deserialized) metadata about 29 | the file, such as its basename, its modification time, its MIME type... 30 | These fields may be particularly handy if your file storage is not the 31 | local filesystem. 32 | 33 | """ 34 | 35 | #: Name of the model's attribute which contains the file to be streamed. 36 | #: Typically the name of a FileField. 37 | file_field = "file" 38 | 39 | #: Optional name of the model's attribute which contains the basename. 40 | basename_field = None 41 | 42 | #: Optional name of the model's attribute which contains the encoding. 43 | encoding_field = None 44 | 45 | #: Optional name of the model's attribute which contains the MIME type. 46 | mime_type_field = None 47 | 48 | #: Optional name of the model's attribute which contains the charset. 49 | charset_field = None 50 | 51 | #: Optional name of the model's attribute which contains the modification 52 | # time. 53 | modification_time_field = None 54 | 55 | #: Optional name of the model's attribute which contains the size. 56 | size_field = None 57 | 58 | def get_file(self): 59 | """Return :class:`~django.db.models.fields.files.FieldFile` instance. 60 | 61 | The file wrapper is model's field specified as :attr:`file_field`. It 62 | is typically a :class:`~django.db.models.fields.files.FieldFile` or 63 | subclass. 64 | 65 | Raises :class:`~django_downloadview.exceptions.FileNotFound` if 66 | instance's field is empty. 67 | 68 | Additional attributes are set on the file wrapper if :attr:`encoding`, 69 | :attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or 70 | :attr:`size` are configured. 71 | 72 | """ 73 | file_instance = getattr(self.object, self.file_field) 74 | if not file_instance: 75 | raise FileNotFound( 76 | f'Field="{self.file_field}" on object="{self.object}" is empty' 77 | ) 78 | for field in ("encoding", "mime_type", "charset", "modification_time", "size"): 79 | model_field = getattr(self, "%s_field" % field, False) 80 | if model_field: 81 | value = getattr(self.object, model_field) 82 | setattr(file_instance, field, value) 83 | return file_instance 84 | 85 | def get_basename(self): 86 | """Return client-side filename.""" 87 | basename = super().get_basename() 88 | if basename is None: 89 | field = "basename" 90 | model_field = getattr(self, "%s_field" % field, False) 91 | if model_field: 92 | basename = getattr(self.object, model_field) 93 | return basename 94 | 95 | def get(self, request, *args, **kwargs): 96 | self.object = self.get_object() 97 | return super().get(request, *args, **kwargs) 98 | -------------------------------------------------------------------------------- /django_downloadview/views/path.py: -------------------------------------------------------------------------------- 1 | """:class:`PathDownloadView`.""" 2 | 3 | import os 4 | 5 | from django.core.files import File 6 | 7 | from django_downloadview.exceptions import FileNotFound 8 | from django_downloadview.views.base import BaseDownloadView 9 | 10 | 11 | class PathDownloadView(BaseDownloadView): 12 | """Serve a file using filename.""" 13 | 14 | #: Server-side name (including path) of the file to serve. 15 | #: 16 | #: Filename is supposed to be an absolute filename of a file located on the 17 | #: local filesystem. 18 | path = None 19 | 20 | #: Name of the URL argument that contains path. 21 | path_url_kwarg = "path" 22 | 23 | def get_path(self): 24 | """Return actual path of the file to serve. 25 | 26 | Default implementation simply returns view's :py:attr:`path`. 27 | 28 | Override this method if you want custom implementation. 29 | As an example, :py:attr:`path` could be relative and your custom 30 | :py:meth:`get_path` implementation makes it absolute. 31 | 32 | """ 33 | return self.kwargs.get(self.path_url_kwarg, self.path) 34 | 35 | def get_file(self): 36 | """Use path to return wrapper around file to serve.""" 37 | filename = self.get_path() 38 | if not os.path.isfile(filename): 39 | raise FileNotFound(f'File "{filename}" does not exists') 40 | return File(open(filename, "rb")) 41 | -------------------------------------------------------------------------------- /django_downloadview/views/storage.py: -------------------------------------------------------------------------------- 1 | """Stream files from storage.""" 2 | 3 | from django.core.files.storage import DefaultStorage 4 | 5 | from django_downloadview.files import StorageFile 6 | from django_downloadview.views.path import PathDownloadView 7 | 8 | 9 | class StorageDownloadView(PathDownloadView): 10 | """Serve a file using storage and filename.""" 11 | 12 | #: Storage the file to serve belongs to. 13 | storage = DefaultStorage() 14 | 15 | #: Path to the file to serve relative to storage. 16 | path = None # Override docstring. 17 | 18 | def get_file(self): 19 | """Return :class:`~django_downloadview.files.StorageFile` instance.""" 20 | return StorageFile(self.storage, self.get_path()) 21 | -------------------------------------------------------------------------------- /django_downloadview/views/virtual.py: -------------------------------------------------------------------------------- 1 | """Stream files that you generate or that live in memory.""" 2 | 3 | from django_downloadview.views.base import BaseDownloadView 4 | 5 | 6 | class VirtualDownloadView(BaseDownloadView): 7 | """Serve not-on-disk or generated-on-the-fly file. 8 | 9 | Override the :py:meth:`get_file` method to customize file wrapper. 10 | 11 | """ 12 | 13 | def was_modified_since(self, file_instance, since): 14 | """Delegate to file wrapper's was_modified_since, or return True. 15 | 16 | This is the implementation of an edge case: when files are generated 17 | on the fly, we cannot guess whether they have been modified or not. 18 | If the file wrapper implements ``was_modified_since()`` method, then we 19 | trust it. Otherwise it is safer to suppose that the file has been 20 | modified. 21 | 22 | This behaviour prevents file size to be computed on the Django side. 23 | Because computing file size means iterating over all the file contents, 24 | and we want to avoid that whenever possible. As an example, it could 25 | reduce all the benefits of working with dynamic file generators... 26 | which is a major feature of virtual files. 27 | 28 | """ 29 | try: 30 | return file_instance.was_modified_since(since) 31 | except (AttributeError, NotImplementedError): 32 | return True 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = ../var/docs 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-downloadview.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-downloadview.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-downloadview" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-downloadview" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/about/alternatives.txt: -------------------------------------------------------------------------------- 1 | ################################# 2 | Alternatives and related projects 3 | ################################# 4 | 5 | This document presents other projects that provide similar or complementary 6 | functionalities. It focuses on differences with django-downloadview. 7 | 8 | There is a comparison grid on djangopackages.com: 9 | https://www.djangopackages.com/grids/g/file-streaming/. 10 | 11 | Here are additional highlights... 12 | 13 | 14 | ************************* 15 | Django's static file view 16 | ************************* 17 | 18 | `django.contrib.staticfiles provides a view to serve files`_. It is simple and 19 | quite naive by design: it is meant for development, not for production. 20 | See `Django ticket #2131`_: advanced file streaming is left to third-party 21 | applications. 22 | 23 | `django-downloadview` is such a third-party application. 24 | 25 | 26 | *************** 27 | django-sendfile 28 | *************** 29 | 30 | `django-sendfile`_ is a wrapper around web-server specific methods for sending 31 | files to web clients. 32 | 33 | .. note:: 34 | 35 | :func:`django_downloadview.shortcuts.sendfile` is a port of 36 | `django-sendfile`'s main function. See :doc:`/django-sendfile` for details. 37 | 38 | ``django-senfile``'s main focus is simplicity: API is made of a single 39 | ``sendfile()`` function you call inside your views: 40 | 41 | .. code:: python 42 | 43 | from sendfile import sendfile 44 | 45 | def hello_world(request): 46 | """Send 'hello-world.pdf' file as a response.""" 47 | return sendfile(request, '/path/to/hello-world.pdf') 48 | 49 | The download response type depends on the chosen backend, which could 50 | be Django, Lighttpd's X-Sendfile, Nginx's X-Accel... depending your settings: 51 | 52 | .. code:: python 53 | 54 | SENDFILE_BACKEND = 'sendfile.backends.nginx' # sendfile() will return 55 | # X-Accel responses. 56 | # Additional settings for sendfile's nginx backend. 57 | SENDFILE_ROOT = '/path/to' 58 | SENDFILE_URL = '/proxied-download' 59 | 60 | Here are main differences between the two projects: 61 | 62 | * ``django-sendfile`` supports only files that live on local filesystem (i.e. 63 | where ``os.path.exists`` returns ``True``). Whereas ``django-downloadview`` 64 | allows you to serve or proxy files stored in various locations, including 65 | remote ones. 66 | 67 | * ``django-sendfile`` uses a single global configuration (i.e. 68 | ``settings.SENDFILE_ROOT``), thus optimizations are limited to a single 69 | root folder. Whereas ``django-downloadview``'s 70 | ``DownloadDispatcherMiddleware`` supports multiple configurations. 71 | 72 | 73 | .. rubric:: References 74 | 75 | .. target-notes:: 76 | 77 | .. _`django.contrib.staticfiles provides a view to serve files`: 78 | https://docs.djangoproject.com/en/3.0/ref/contrib/staticfiles/#static-file-development-view 79 | .. _`Django ticket #2131`: https://code.djangoproject.com/ticket/2131 80 | .. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile 81 | -------------------------------------------------------------------------------- /docs/about/authors.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../../AUTHORS 2 | -------------------------------------------------------------------------------- /docs/about/changelog.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG 2 | -------------------------------------------------------------------------------- /docs/about/index.txt: -------------------------------------------------------------------------------- 1 | ######################### 2 | About django-downloadview 3 | ######################### 4 | 5 | .. toctree:: 6 | 7 | vision 8 | alternatives 9 | license 10 | authors 11 | changelog 12 | -------------------------------------------------------------------------------- /docs/about/license.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../../LICENSE 2 | -------------------------------------------------------------------------------- /docs/about/vision.txt: -------------------------------------------------------------------------------- 1 | ###### 2 | Vision 3 | ###### 4 | 5 | `django-downloadview` tries to simplify the development of "download" views 6 | using `Django`_ framework. It provides generic views that cover most common 7 | patterns. 8 | 9 | Django is not the best solution to serve files: reverse proxies are far more 10 | efficient. `django-downloadview` makes it easy to implement this best-practice. 11 | 12 | Tests matter: `django-downloadview` provides tools to test download views and 13 | optimizations. 14 | 15 | 16 | .. rubric:: Notes & references 17 | 18 | .. seealso:: 19 | 20 | * :doc:`/about/alternatives` 21 | * `roadmap 22 | `_ 23 | 24 | .. target-notes:: 25 | 26 | .. _`Django`: https://www.djangoproject.com 27 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """django-downloadview documentation build configuration file.""" 3 | 4 | import re 5 | 6 | import importlib.metadata 7 | 8 | # Minimal Django settings. Required to use sphinx.ext.autodoc, because 9 | # django-downloadview depends on Django... 10 | from django.conf import settings 11 | 12 | settings.configure( 13 | DATABASES={}, # Required to load ``django.views.generic``. 14 | ) 15 | 16 | 17 | # -- General configuration ---------------------------------------------------- 18 | 19 | # Extensions. 20 | extensions = [ 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.autosummary", 23 | "sphinx.ext.doctest", 24 | "sphinx.ext.coverage", 25 | "sphinx.ext.intersphinx", 26 | ] 27 | 28 | # Add any paths that contain templates here, relative to this directory. 29 | templates_path = ["_templates"] 30 | 31 | # The suffix of source filenames. 32 | source_suffix = ".txt" 33 | 34 | # The encoding of source files. 35 | source_encoding = "utf-8" 36 | 37 | # The master toctree document. 38 | master_doc = "index" 39 | 40 | # General information about the project. 41 | project = "django-downloadview" 42 | project_slug = re.sub(r"([\w_.-]+)", "-", project) 43 | copyright = "2012-2015, Benoît Bryon" 44 | author = "Benoît Bryon" 45 | author_slug = re.sub(r"([\w_.-]+)", "-", author) 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | 51 | # The full version, including alpha/beta/rc tags. 52 | release = importlib.metadata.version("django-downloadview") 53 | # The short X.Y version. 54 | version = ".".join(release.split(".")[:2]) 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | language = "en" 59 | 60 | # List of patterns, relative to source directory, that match files and 61 | # directories to ignore when looking for source files. 62 | exclude_patterns = ["_build"] 63 | 64 | # The name of the Pygments (syntax highlighting) style to use. 65 | pygments_style = "sphinx" 66 | 67 | 68 | # -- Options for HTML output -------------------------------------------------- 69 | 70 | # The theme to use for HTML and HTML Help pages. See the documentation for 71 | # a list of builtin themes. 72 | html_theme = "alabaster" 73 | 74 | # Add any paths that contain custom static files (such as style sheets) here, 75 | # relative to this directory. They are copied after the builtin static files, 76 | # so a file named "default.css" will overwrite the builtin "default.css". 77 | html_static_path = [] 78 | 79 | # Custom sidebar templates, maps document names to template names. 80 | html_sidebars = { 81 | "**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"], 82 | } 83 | 84 | # Output file base name for HTML help builder. 85 | htmlhelp_basename = "{project}doc".format(project=project_slug) 86 | 87 | 88 | # -- Options for sphinx.ext.intersphinx --------------------------------------- 89 | 90 | intersphinx_mapping = { 91 | "python": ("https://docs.python.org/3", None), 92 | "django": ( 93 | "https://docs.djangoproject.com/en/3.1/", 94 | "https://docs.djangoproject.com/en/3.1/_objects/", 95 | ), 96 | "requests": ("https://requests.readthedocs.io/en/master/", None), 97 | } 98 | 99 | 100 | # -- Options for LaTeX output ------------------------------------------------- 101 | 102 | latex_elements = {} 103 | 104 | # Grouping the document tree into LaTeX files. List of tuples 105 | # (source start file, target name, title, author, documentclass 106 | # [howto/manual]). 107 | latex_documents = [ 108 | ( 109 | "index", 110 | "{project}.tex".format(project=project_slug), 111 | "{project} Documentation".format(project=project), 112 | author, 113 | "manual", 114 | ), 115 | ] 116 | 117 | 118 | # -- Options for manual page output ------------------------------------------- 119 | 120 | # One entry per manual page. List of tuples 121 | # (source start file, name, description, authors, manual section). 122 | man_pages = [ 123 | ("index", project, "{project} Documentation".format(project=project), [author], 1) 124 | ] 125 | 126 | 127 | # -- Options for Texinfo output ----------------------------------------------- 128 | 129 | # Grouping the document tree into Texinfo files. List of tuples 130 | # (source start file, target name, title, author, 131 | # dir menu entry, description, category) 132 | texinfo_documents = [ 133 | ( 134 | "index", 135 | project_slug, 136 | "{project} Documentation".format(project=project), 137 | author, 138 | project, 139 | "One line description of project.", 140 | "Miscellaneous", 141 | ), 142 | ] 143 | -------------------------------------------------------------------------------- /docs/contributing.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/demo.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../demo/README.rst 2 | -------------------------------------------------------------------------------- /docs/django-sendfile.txt: -------------------------------------------------------------------------------- 1 | ############################## 2 | Migrating from django-sendfile 3 | ############################## 4 | 5 | `django-sendfile`_ is a wrapper around web-server specific methods for sending 6 | files to web clients. See :doc:`/about/alternatives` for details about this 7 | project. 8 | 9 | `django-downloadview` provides a :func:`port of django-sendfile's main function 10 | `. 11 | 12 | .. warning:: 13 | 14 | `django-downloadview` can replace the following `django-sendfile`'s 15 | backends: ``nginx``, ``xsendfile``, ``simple``. But it currently cannot 16 | replace ``mod_wsgi`` backend. 17 | 18 | Here are tips to migrate from `django-sendfile` to `django-downloadview`... 19 | 20 | 1. In your project's and apps dependencies, replace ``django-sendfile`` by 21 | ``django-downloadview``. 22 | 23 | 2. In your Python scripts, replace ``import sendfile`` and ``from sendfile`` 24 | by ``import django_downloadview`` and ``from django_downloadview``. 25 | You get something like ``from django_downloadview import sendfile`` 26 | 27 | 3. Adapt your settings as explained in :doc:`/settings`. Pay attention to: 28 | 29 | * replace ``sendfile`` by ``django_downloadview`` in ``INSTALLED_APPS``. 30 | * replace ``SENDFILE_BACKEND`` by ``DOWNLOADVIEW_BACKEND`` 31 | * setup ``DOWNLOADVIEW_RULES``. It replaces ``SENDFILE_ROOT`` and can do 32 | more. 33 | * register ``django_downloadview.SmartDownloadMiddleware`` in 34 | ``MIDDLEWARE``. 35 | 36 | 4. Change your tests if any. You can no longer use `django-senfile`'s 37 | ``development`` backend. See :doc:`/testing` for `django-downloadview`'s 38 | toolkit. 39 | 40 | 5. Here you are! ... or please report your story/bug at `django-downloadview's 41 | bugtracker`_ ;) 42 | 43 | 44 | ************* 45 | API reference 46 | ************* 47 | 48 | .. autofunction:: django_downloadview.shortcuts.sendfile 49 | 50 | 51 | .. rubric:: References 52 | 53 | .. target-notes:: 54 | 55 | .. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile 56 | .. _`django-downloadview's bugtracker`: 57 | https://github.com/jazzband/django-downloadview/issues 58 | -------------------------------------------------------------------------------- /docs/files.txt: -------------------------------------------------------------------------------- 1 | ############# 2 | File wrappers 3 | ############# 4 | 5 | .. module:: django_downloadview.files 6 | 7 | A view return :class:`~django_downloadview.response.DownloadResponse` which 8 | itself carries a file wrapper. Here are file wrappers distributed by Django 9 | and django-downloadview. 10 | 11 | 12 | ***************** 13 | Django's builtins 14 | ***************** 15 | 16 | `Django itself provides some file wrappers`_ you can use within 17 | ``django-downloadview``: 18 | 19 | * :class:`django.core.files.File` wraps a file that live on local 20 | filesystem, initialized with a path. ``django-downloadview`` uses this 21 | wrapper in :doc:`/views/path`. 22 | 23 | * :class:`django.db.models.fields.files.FieldFile` wraps a file that is 24 | managed in a model. ``django-downloadview`` uses this wrapper in 25 | :doc:`/views/object`. 26 | 27 | * :class:`django.core.files.base.ContentFile` wraps a bytes, string or 28 | unicode object. You may use it with :doc:`VirtualDownloadView 29 | `. 30 | 31 | 32 | **************************** 33 | django-downloadview builtins 34 | **************************** 35 | 36 | ``django-downloadview`` implements additional file wrappers: 37 | 38 | * :class:`StorageFile` wraps a file that is 39 | managed via a storage (but not necessarily via a model). 40 | :doc:`/views/storage` uses this wrapper. 41 | 42 | * :class:`HTTPFile` wraps a file that lives at 43 | some (remote) location, initialized with an URL. 44 | :doc:`/views/http` uses this wrapper. 45 | 46 | * :class:`VirtualFile` wraps a file that lives in 47 | memory, i.e. built as a string. 48 | This is a convenient wrapper to use in :doc:`/views/virtual` subclasses. 49 | 50 | 51 | ********************** 52 | Low-level IO utilities 53 | ********************** 54 | 55 | `django-downloadview` provides two classes to implement file-like objects 56 | whose content is dynamically generated: 57 | 58 | * :class:`~django_downloadview.io.TextIteratorIO` for generated text; 59 | * :class:`~django_downloadview.io.BytesIteratorIO` for generated bytes. 60 | 61 | These classes may be handy to serve dynamically generated files. See 62 | :doc:`/views/virtual` for details. 63 | 64 | .. tip:: 65 | 66 | **Text or bytes?** (formerly "unicode or str?") As `django-downloadview` 67 | is meant to serve files, as opposed to read or parse files, what matters 68 | is file contents is preserved. `django-downloadview` tends to handle files 69 | in binary mode and as bytes. 70 | 71 | 72 | ************* 73 | API reference 74 | ************* 75 | 76 | StorageFile 77 | =========== 78 | 79 | .. autoclass:: StorageFile 80 | :members: 81 | :undoc-members: 82 | :show-inheritance: 83 | :member-order: bysource 84 | 85 | HTTPFile 86 | ======== 87 | 88 | .. autoclass:: HTTPFile 89 | :members: 90 | :undoc-members: 91 | :show-inheritance: 92 | :member-order: bysource 93 | 94 | 95 | VirtualFile 96 | =========== 97 | 98 | .. autoclass:: VirtualFile 99 | :members: 100 | :undoc-members: 101 | :show-inheritance: 102 | :member-order: bysource 103 | 104 | 105 | BytesIteratorIO 106 | =============== 107 | 108 | .. autoclass:: django_downloadview.io.BytesIteratorIO 109 | :members: 110 | :undoc-members: 111 | :show-inheritance: 112 | :member-order: bysource 113 | 114 | 115 | TextIteratorIO 116 | ============== 117 | 118 | .. autoclass:: django_downloadview.io.TextIteratorIO 119 | :members: 120 | :undoc-members: 121 | :show-inheritance: 122 | :member-order: bysource 123 | 124 | 125 | .. rubric:: Notes & references 126 | 127 | .. target-notes:: 128 | 129 | .. _`Django itself provides some file wrappers`: 130 | https://docs.djangoproject.com/en/3.0/ref/files/file/ 131 | -------------------------------------------------------------------------------- /docs/healthchecks.txt: -------------------------------------------------------------------------------- 1 | ################## 2 | Write healthchecks 3 | ################## 4 | 5 | In the previous :doc:`testing ` topic, you made sure the views and 6 | middlewares work as expected... within a test environment. 7 | 8 | One common issue when deploying in production is that the reverse-proxy's 9 | configuration does not fit. You cannot check that within test environment. 10 | 11 | **Healthchecks are made to diagnose issues in live (production) environments**. 12 | 13 | 14 | ************************ 15 | Introducing healthchecks 16 | ************************ 17 | 18 | Healthchecks (sometimes called "smoke tests" or "diagnosis") are assertions you 19 | run on a live (typically production) service, as opposed to fake/mock service 20 | used during tests (unit, integration, functional). 21 | 22 | See `hospital`_ and `django-doctor`_ projects about writing healthchecks for 23 | Python and Django. 24 | 25 | 26 | ******************** 27 | Typical healthchecks 28 | ******************** 29 | 30 | Here is a typical healthcheck setup for download views with reverse-proxy 31 | optimizations. 32 | 33 | When you run this healthcheck suite, you get a good overview if a problem 34 | occurs: you can compare expected results and learn which part (Django, 35 | reverse-proxy or remote storage) is guilty. 36 | 37 | .. note:: 38 | 39 | In the examples below, we use "localhost" and ports "80" (reverse-proxy) or 40 | "8000" (Django). Adapt them to your configuration. 41 | 42 | Check storage 43 | ============= 44 | 45 | Put a dummy file on the storage Django uses. 46 | 47 | The write a healthcheck that asserts you can read the dummy file from storage. 48 | 49 | **On success, you know remote storage is ok.** 50 | 51 | Issues may involve permissions or communications (remote storage). 52 | 53 | .. note:: 54 | 55 | This healthcheck may be outside Django. 56 | 57 | Check Django VS storage 58 | ======================= 59 | 60 | Implement a download view dedicated to healthchecks. It is typically a public 61 | (but not referenced) view that streams a dummy file from real storage. 62 | Let's say you register it as ``/healthcheck-utils/download/`` URL. 63 | 64 | Write a healthcheck that asserts ``GET 65 | http://localhost:8000/healtcheck-utils/download/`` (notice the `8000` port: 66 | local Django server) returns the expected reverse-proxy response (X-Accel, 67 | X-Sendfile...). 68 | 69 | **On success, you know there is no configuration issue on the Django side.** 70 | 71 | Check reverse proxy VS storage 72 | ============================== 73 | 74 | Write a location in your reverse-proxy's configuration that proxy-pass to a 75 | dummy file on storage. 76 | 77 | Write a healthcheck that asserts this location returns the expected dummy file. 78 | 79 | **On success, you know the reverse proxy can serve files from storage.** 80 | 81 | Check them all together 82 | ======================= 83 | 84 | We just checked all parts separately, so let's make sure they can work 85 | together. 86 | Configure the reverse-proxy so that `/healthcheck-utils/download/` is proxied 87 | to Django. Then write a healthcheck that asserts ``GET 88 | http://localhost:80/healthcheck-utils/download`` (notice the `80` port: 89 | reverse-proxy server) returns the expected dummy file. 90 | 91 | **On success, you know everything is ok.** 92 | 93 | On failure, there is an issue in the X-Accel/X-Sendfile configuration. 94 | 95 | .. note:: 96 | 97 | This last healthcheck should be the first one to run, i.e. if it passes, 98 | others should pass too. The others are useful when this one fails. 99 | 100 | 101 | .. rubric:: Notes & references 102 | 103 | .. target-notes:: 104 | 105 | .. _`hospital`: https://pypi.python.org/pypi/hospital 106 | .. _`django-doctor`: https://pypi.python.org/pypi/django-doctor 107 | -------------------------------------------------------------------------------- /docs/index.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | ******** 5 | Contents 6 | ******** 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :titlesonly: 11 | 12 | overview 13 | install 14 | settings 15 | views/index 16 | optimizations/index 17 | testing 18 | healthchecks 19 | files 20 | responses 21 | django-sendfile 22 | demo 23 | about/index 24 | contributing 25 | -------------------------------------------------------------------------------- /docs/install.txt: -------------------------------------------------------------------------------- 1 | .. include:: ../INSTALL 2 | -------------------------------------------------------------------------------- /docs/optimizations/apache.txt: -------------------------------------------------------------------------------- 1 | ###### 2 | Apache 3 | ###### 4 | 5 | If you serve Django behind Apache, then you can delegate the file streaming 6 | to Apache and get increased performance: 7 | 8 | * lower resources used by Python/Django workers ; 9 | * faster download. 10 | 11 | See `Apache mod_xsendfile documentation`_ for details. 12 | 13 | 14 | ***************** 15 | Known limitations 16 | ***************** 17 | 18 | * Apache needs access to the resource by path on local filesystem. 19 | * Thus only files that live on local filesystem can be streamed by Apache. 20 | 21 | 22 | ************ 23 | Given a view 24 | ************ 25 | 26 | Let's consider the following view: 27 | 28 | .. literalinclude:: /../demo/demoproject/apache/views.py 29 | :language: python 30 | :lines: 1-6, 8-16 31 | 32 | What is important here is that the files will have an ``url`` property 33 | implemented by storage. Let's setup an optimization rule based on that URL. 34 | 35 | .. note:: 36 | 37 | It is generally easier to setup rules based on URL rather than based on 38 | name in filesystem. This is because path is generally relative to storage, 39 | whereas URL usually contains some storage identifier, i.e. it is easier to 40 | target a specific location by URL rather than by filesystem name. 41 | 42 | 43 | *************************** 44 | Setup XSendfile middlewares 45 | *************************** 46 | 47 | Make sure ``django_downloadview.SmartDownloadMiddleware`` is in 48 | ``MIDDLEWARE`` of your `Django` settings. 49 | 50 | Example: 51 | 52 | .. literalinclude:: /../demo/demoproject/settings.py 53 | :language: python 54 | :lines: 63-70 55 | 56 | Then set ``django_downloadview.apache.XSendfileMiddleware`` as 57 | ``DOWNLOADVIEW_BACKEND``: 58 | 59 | .. literalinclude:: /../demo/demoproject/settings.py 60 | :language: python 61 | :lines: 79 62 | 63 | Then register as many ``DOWNLOADVIEW_RULES`` as you wish: 64 | 65 | .. literalinclude:: /../demo/demoproject/settings.py 66 | :language: python 67 | :lines: 84, 92-100, 110 68 | 69 | Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed 70 | to the middleware factory. In the example above, we capture responses by 71 | ``source_url`` and convert them to internal redirects to ``destination_dir``. 72 | 73 | .. autoclass:: django_downloadview.apache.middlewares.XSendfileMiddleware 74 | :members: 75 | :inherited-members: 76 | :undoc-members: 77 | :show-inheritance: 78 | :member-order: bysource 79 | 80 | 81 | **************************************** 82 | Per-view setup with x_sendfile decorator 83 | **************************************** 84 | 85 | Middlewares should be enough for most use cases, but you may want per-view 86 | configuration. For `Apache`, there is ``x_sendfile``: 87 | 88 | .. autofunction:: django_downloadview.apache.decorators.x_sendfile 89 | 90 | As an example: 91 | 92 | .. literalinclude:: /../demo/demoproject/apache/views.py 93 | :language: python 94 | :lines: 1-7, 17- 95 | 96 | 97 | ************************************* 98 | Test responses with assert_x_sendfile 99 | ************************************* 100 | 101 | Use :func:`~django_downloadview.apache.decorators.assert_x_sendfile` 102 | function as a shortcut in your tests. 103 | 104 | .. literalinclude:: /../demo/demoproject/apache/tests.py 105 | :language: python 106 | 107 | .. autofunction:: django_downloadview.apache.tests.assert_x_sendfile 108 | 109 | The tests above assert the `Django` part is OK. Now let's configure `Apache`. 110 | 111 | 112 | ************ 113 | Setup Apache 114 | ************ 115 | 116 | See `Apache mod_xsendfile documentation`_ for details. 117 | 118 | 119 | ********************************************* 120 | Assert everything goes fine with healthchecks 121 | ********************************************* 122 | 123 | :doc:`Healthchecks ` are the best way to check the complete 124 | setup. 125 | 126 | 127 | .. rubric:: References 128 | 129 | .. target-notes:: 130 | 131 | .. _`Apache mod_xsendfile documentation`: https://tn123.org/mod_xsendfile/ 132 | -------------------------------------------------------------------------------- /docs/optimizations/index.txt: -------------------------------------------------------------------------------- 1 | ################## 2 | Optimize streaming 3 | ################## 4 | 5 | Some reverse proxies allow applications to delegate actual download to the 6 | proxy: 7 | 8 | * with Django, manage permissions, generate files... 9 | * let the reverse proxy serve the file. 10 | 11 | As a result, you get increased performance: reverse proxies are more efficient 12 | than Django at serving static files. 13 | 14 | 15 | *********************** 16 | Supported features grid 17 | *********************** 18 | 19 | Supported features depend on backend. Given the file you want to stream, the 20 | backend may or may not be able to handle it: 21 | 22 | +-----------------------+-------------------------+-------------------------+-------------------------+ 23 | | View / File | :doc:`nginx` | :doc:`apache` | :doc:`lighttpd` | 24 | +=======================+=========================+=========================+=========================+ 25 | | :doc:`/views/path` | Yes, local filesystem. | Yes, local filesystem. | Yes, local filesystem. | 26 | +-----------------------+-------------------------+-------------------------+-------------------------+ 27 | | :doc:`/views/storage` | Yes, local and remote. | Yes, local filesystem. | Yes, local filesystem. | 28 | +-----------------------+-------------------------+-------------------------+-------------------------+ 29 | | :doc:`/views/object` | Yes, local and remote. | Yes, local filesystem. | Yes, local filesystem. | 30 | +-----------------------+-------------------------+-------------------------+-------------------------+ 31 | | :doc:`/views/http` | Yes. | No. | No. | 32 | +-----------------------+-------------------------+-------------------------+-------------------------+ 33 | | :doc:`/views/virtual` | No. | No. | No. | 34 | +-----------------------+-------------------------+-------------------------+-------------------------+ 35 | 36 | As an example, :doc:`Nginx X-Accel ` handles URL for 37 | internal redirects, so it can manage 38 | :class:`~django_downloadview.files.HTTPFile`; whereas :doc:`Apache X-Sendfile 39 | ` handles absolute path, so it can only deal with files 40 | on local filesystem. 41 | 42 | There are currently no optimizations to stream in-memory files, since they only 43 | live on Django side, i.e. they do not persist after Django returned a response. 44 | Note: there is `a feature request about "local cache" for streamed files`_. 45 | 46 | 47 | ***************** 48 | How does it work? 49 | ***************** 50 | 51 | View return some :class:`~django_downloadview.response.DownloadResponse` 52 | instance, which itself carries a :doc:`file wrapper `. 53 | 54 | `django-downloadview` provides response middlewares and decorators that are 55 | able to capture :class:`~django_downloadview.response.DownloadResponse` 56 | instances and convert them to 57 | :class:`~django_downloadview.response.ProxiedDownloadResponse`. 58 | 59 | The :class:`~django_downloadview.response.ProxiedDownloadResponse` is specific 60 | to the reverse-proxy (backend): it tells the reverse proxy to stream some 61 | resource. 62 | 63 | .. note:: 64 | 65 | The feature is inspired by :mod:`Django's TemplateResponse 66 | ` 67 | 68 | 69 | *********************** 70 | Available optimizations 71 | *********************** 72 | 73 | Here are optimizations builtin `django_downloadview`: 74 | 75 | .. toctree:: 76 | :titlesonly: 77 | 78 | nginx 79 | apache 80 | lighttpd 81 | 82 | .. note:: If you need support for additional optimizations, `tell us`_! 83 | 84 | 85 | .. rubric:: Notes & references 86 | 87 | .. target-notes:: 88 | 89 | .. _`tell us`: 90 | https://github.com/jazzband/django-downloadview/issues?labels=optimizations 91 | .. _`a feature request about "local cache" for streamed files`: 92 | https://github.com/jazzband/django-downloadview/issues/70 93 | -------------------------------------------------------------------------------- /docs/optimizations/lighttpd.txt: -------------------------------------------------------------------------------- 1 | ######## 2 | Lighttpd 3 | ######## 4 | 5 | If you serve Django behind `Lighttpd`, then you can delegate the file streaming 6 | to `Lighttpd` and get increased performance: 7 | 8 | * lower resources used by Python/Django workers ; 9 | * faster download. 10 | 11 | See `Lighttpd X-Sendfile documentation`_ for details. 12 | 13 | .. note:: 14 | 15 | Currently, `django_downloadview` supports ``X-Sendfile``, but not 16 | ``X-Sendfile2``. If you need ``X-Sendfile2`` or know how to handle it, 17 | check `X-Sendfile2 feature request on django_downloadview's bugtracker`_. 18 | 19 | 20 | ***************** 21 | Known limitations 22 | ***************** 23 | 24 | * Lighttpd needs access to the resource by path on local filesystem. 25 | * Thus only files that live on local filesystem can be streamed by Lighttpd. 26 | 27 | 28 | ************ 29 | Given a view 30 | ************ 31 | 32 | Let's consider the following view: 33 | 34 | .. literalinclude:: /../demo/demoproject/lighttpd/views.py 35 | :language: python 36 | :lines: 1-6, 8-17 37 | 38 | What is important here is that the files will have an ``url`` property 39 | implemented by storage. Let's setup an optimization rule based on that URL. 40 | 41 | .. note:: 42 | 43 | It is generally easier to setup rules based on URL rather than based on 44 | name in filesystem. This is because path is generally relative to storage, 45 | whereas URL usually contains some storage identifier, i.e. it is easier to 46 | target a specific location by URL rather than by filesystem name. 47 | 48 | 49 | *************************** 50 | Setup XSendfile middlewares 51 | *************************** 52 | 53 | Make sure ``django_downloadview.SmartDownloadMiddleware`` is in 54 | ``MIDDLEWARE`` of your `Django` settings. 55 | 56 | Example: 57 | 58 | .. literalinclude:: /../demo/demoproject/settings.py 59 | :language: python 60 | :lines: 63-70 61 | 62 | Then set ``django_downloadview.lighttpd.XSendfileMiddleware`` as 63 | ``DOWNLOADVIEW_BACKEND``: 64 | 65 | .. literalinclude:: /../demo/demoproject/settings.py 66 | :language: python 67 | :lines: 80 68 | 69 | Then register as many ``DOWNLOADVIEW_RULES`` as you wish: 70 | 71 | .. literalinclude:: /../demo/demoproject/settings.py 72 | :language: python 73 | :lines: 84, 101-110 74 | 75 | Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed 76 | to the middleware factory. In the example above, we capture responses by 77 | ``source_url`` and convert them to internal redirects to ``destination_dir``. 78 | 79 | .. autoclass:: django_downloadview.lighttpd.middlewares.XSendfileMiddleware 80 | :members: 81 | :inherited-members: 82 | :undoc-members: 83 | :show-inheritance: 84 | :member-order: bysource 85 | 86 | 87 | **************************************** 88 | Per-view setup with x_sendfile decorator 89 | **************************************** 90 | 91 | Middlewares should be enough for most use cases, but you may want per-view 92 | configuration. For `Lighttpd`, there is ``x_sendfile``: 93 | 94 | .. autofunction:: django_downloadview.lighttpd.decorators.x_sendfile 95 | 96 | As an example: 97 | 98 | .. literalinclude:: /../demo/demoproject/lighttpd/views.py 99 | :language: python 100 | :lines: 1-7, 18- 101 | 102 | 103 | ************************************* 104 | Test responses with assert_x_sendfile 105 | ************************************* 106 | 107 | Use :func:`~django_downloadview.lighttpd.decorators.assert_x_sendfile` 108 | function as a shortcut in your tests. 109 | 110 | .. literalinclude:: /../demo/demoproject/lighttpd/tests.py 111 | :language: python 112 | 113 | .. autofunction:: django_downloadview.lighttpd.tests.assert_x_sendfile 114 | 115 | The tests above assert the `Django` part is OK. Now let's configure `Lighttpd`. 116 | 117 | 118 | ************** 119 | Setup Lighttpd 120 | ************** 121 | 122 | See `Lighttpd X-Sendfile documentation`_ for details. 123 | 124 | 125 | ********************************************* 126 | Assert everything goes fine with healthchecks 127 | ********************************************* 128 | 129 | :doc:`Healthchecks ` are the best way to check the complete 130 | setup. 131 | 132 | 133 | .. rubric:: References 134 | 135 | .. target-notes:: 136 | 137 | .. _`Lighttpd X-Sendfile documentation`: 138 | http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file 139 | .. _`X-Sendfile2 feature request on django_downloadview's bugtracker`: 140 | https://github.com/jazzband/django-downloadview/issues/67 141 | -------------------------------------------------------------------------------- /docs/optimizations/nginx.txt: -------------------------------------------------------------------------------- 1 | ##### 2 | Nginx 3 | ##### 4 | 5 | If you serve Django behind Nginx, then you can delegate the file streaming 6 | to Nginx and get increased performance: 7 | 8 | * lower resources used by Python/Django workers ; 9 | * faster download. 10 | 11 | See `Nginx X-accel documentation`_ for details. 12 | 13 | 14 | ***************** 15 | Known limitations 16 | ***************** 17 | 18 | * Nginx needs access to the resource by URL (proxy) or path (location). 19 | * Thus :class:`~django_downloadview.files.VirtualFile` and any generated files 20 | cannot be streamed by Nginx. 21 | 22 | 23 | ************ 24 | Given a view 25 | ************ 26 | 27 | Let's consider the following view: 28 | 29 | .. literalinclude:: /../demo/demoproject/nginx/views.py 30 | :language: python 31 | :lines: 1-6, 8-17 32 | 33 | What is important here is that the files will have an ``url`` property 34 | implemented by storage. Let's setup an optimization rule based on that URL. 35 | 36 | .. note:: 37 | 38 | It is generally easier to setup rules based on URL rather than based on 39 | name in filesystem. This is because path is generally relative to storage, 40 | whereas URL usually contains some storage identifier, i.e. it is easier to 41 | target a specific location by URL rather than by filesystem name. 42 | 43 | 44 | ******************************** 45 | Setup XAccelRedirect middlewares 46 | ******************************** 47 | 48 | Make sure ``django_downloadview.SmartDownloadMiddleware`` is in 49 | ``MIDDLEWARE`` of your `Django` settings. 50 | 51 | Example: 52 | 53 | .. literalinclude:: /../demo/demoproject/settings.py 54 | :language: python 55 | :lines: 62-69 56 | 57 | Then set ``django_downloadview.nginx.XAccelRedirectMiddleware`` as 58 | ``DOWNLOADVIEW_BACKEND``: 59 | 60 | .. literalinclude:: /../demo/demoproject/settings.py 61 | :language: python 62 | :lines: 75 63 | 64 | Then register as many ``DOWNLOADVIEW_RULES`` as you wish: 65 | 66 | .. literalinclude:: /../demo/demoproject/settings.py 67 | :language: python 68 | :lines: 83-88 69 | 70 | Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed 71 | to the middleware factory. In the example above, we capture responses by 72 | ``source_url`` and convert them to internal redirects to ``destination_url``. 73 | 74 | .. autoclass:: django_downloadview.nginx.middlewares.XAccelRedirectMiddleware 75 | :members: 76 | :inherited-members: 77 | :undoc-members: 78 | :show-inheritance: 79 | :member-order: bysource 80 | 81 | 82 | ********************************************** 83 | Per-view setup with x_accel_redirect decorator 84 | ********************************************** 85 | 86 | Middlewares should be enough for most use cases, but you may want per-view 87 | configuration. For `nginx`, there is ``x_accel_redirect``: 88 | 89 | .. autofunction:: django_downloadview.nginx.decorators.x_accel_redirect 90 | 91 | As an example: 92 | 93 | .. literalinclude:: /../demo/demoproject/nginx/views.py 94 | :language: python 95 | :lines: 1-7, 17- 96 | 97 | 98 | ******************************************* 99 | Test responses with assert_x_accel_redirect 100 | ******************************************* 101 | 102 | Use :func:`~django_downloadview.nginx.decorators.assert_x_accel_redirect` 103 | function as a shortcut in your tests. 104 | 105 | .. literalinclude:: /../demo/demoproject/nginx/tests.py 106 | :language: python 107 | 108 | .. autofunction:: django_downloadview.nginx.tests.assert_x_accel_redirect 109 | 110 | The tests above assert the `Django` part is OK. Now let's configure `nginx`. 111 | 112 | 113 | *********** 114 | Setup Nginx 115 | *********** 116 | 117 | See `Nginx X-accel documentation`_ for details. 118 | 119 | Here is what you could have in :file:`/etc/nginx/sites-available/default`: 120 | 121 | .. code-block:: nginx 122 | 123 | charset utf-8; 124 | 125 | # Django-powered service. 126 | upstream frontend { 127 | server 127.0.0.1:8000 fail_timeout=0; 128 | } 129 | 130 | server { 131 | listen 80 default; 132 | 133 | # File-download proxy. 134 | # 135 | # Will serve /var/www/files/myfile.tar.gz when passed URI 136 | # like /optimized-download/myfile.tar.gz 137 | # 138 | # See http://wiki.nginx.org/X-accel 139 | # and https://django-downloadview.readthedocs.io 140 | # 141 | location /proxied-download { 142 | internal; 143 | # Location to files on disk. 144 | alias /var/www/files/; 145 | } 146 | 147 | # Proxy to Django-powered frontend. 148 | location / { 149 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 150 | proxy_set_header Host $http_host; 151 | proxy_redirect off; 152 | proxy_pass http://frontend; 153 | } 154 | } 155 | 156 | ... where specific configuration is the ``location /optimized-download`` 157 | section. 158 | 159 | .. note:: 160 | 161 | ``/proxied-download`` has the ``internal`` flag, so this location is not 162 | available for the client, i.e. users are not able to download files via 163 | ``/optimized-download/``. 164 | 165 | 166 | ********************************************* 167 | Assert everything goes fine with healthchecks 168 | ********************************************* 169 | 170 | :doc:`Healthchecks ` are the best way to check the complete 171 | setup. 172 | 173 | 174 | ************* 175 | Common issues 176 | ************* 177 | 178 | ``Unknown charset "utf-8" to override`` 179 | ======================================= 180 | 181 | Add ``charset utf-8;`` in your nginx configuration file. 182 | 183 | ``open() "path/to/something" failed (2: No such file or directory)`` 184 | ==================================================================== 185 | 186 | Check your ``settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR`` in Django 187 | configuration VS ``alias`` in nginx configuration: in a standard configuration, 188 | they should be equal. 189 | 190 | 191 | .. rubric:: References 192 | 193 | .. target-notes:: 194 | 195 | .. _`Nginx X-accel documentation`: http://wiki.nginx.org/X-accel 196 | -------------------------------------------------------------------------------- /docs/overview.txt: -------------------------------------------------------------------------------- 1 | ################## 2 | Overview, concepts 3 | ################## 4 | 5 | Given: 6 | 7 | * you manage files with Django (permissions, filters, generation, ...) 8 | 9 | * files are stored somewhere or generated somehow (local filesystem, remote 10 | storage, memory...) 11 | 12 | As a developer, you want to serve files quick and efficiently. 13 | 14 | Here is an overview of `django-downloadview`'s answer... 15 | 16 | 17 | ************************************ 18 | Generic views cover commons patterns 19 | ************************************ 20 | 21 | Choose the generic view depending on the file you want to serve: 22 | 23 | * :doc:`/views/object`: file field in a model; 24 | * :doc:`/views/storage`: file in a storage; 25 | * :doc:`/views/path`: absolute filename on local filesystem; 26 | * :doc:`/views/http`: file at URL (the resource is proxied); 27 | * :doc:`/views/virtual`: bytes, text, file-like objects, generated files... 28 | 29 | 30 | ************************************************* 31 | Generic views and mixins allow easy customization 32 | ************************************************* 33 | 34 | If your use case is a bit specific, you can easily extend the views above or 35 | :doc:`create your own based on mixins `. 36 | 37 | 38 | ***************************** 39 | Views return DownloadResponse 40 | ***************************** 41 | 42 | Views return :py:class:`~django_downloadview.response.DownloadResponse`. It is 43 | a special :py:class:`django.http.StreamingHttpResponse` where content is 44 | encapsulated in a file wrapper. 45 | 46 | Learn more in :doc:`responses`. 47 | 48 | 49 | *********************************** 50 | DownloadResponse carry file wrapper 51 | *********************************** 52 | 53 | Views instanciate a :doc:`file wrapper ` and use it to initialize 54 | responses. 55 | 56 | **File wrappers describe files**: they carry files properties such as name, 57 | size, encoding... 58 | 59 | **File wrappers implement loading and iterating over file content**. Whenever 60 | possible, file wrappers do not embed file data, in order to save memory. 61 | 62 | Learn more about available file wrappers in :doc:`files`. 63 | 64 | 65 | ***************************************************************** 66 | Middlewares convert DownloadResponse into ProxiedDownloadResponse 67 | ***************************************************************** 68 | 69 | Before WSGI application use file wrapper and actually use file contents, 70 | middlewares or decorators) are given the opportunity to capture 71 | :class:`~django_downloadview.response.DownloadResponse` instances. 72 | 73 | Let's take this opportunity to optimize file loading and streaming! 74 | 75 | A good optimization it to delegate streaming to a reverse proxy, such as 76 | `nginx`_ via `X-Accel`_ internal redirects. This way, Django doesn't load file 77 | content in memory. 78 | 79 | `django_downloadview` provides middlewares that convert 80 | :class:`~django_downloadview.response.DownloadResponse` into 81 | :class:`~django_downloadview.response.ProxiedDownloadResponse`. 82 | 83 | Learn more in :doc:`optimizations/index`. 84 | 85 | 86 | *************** 87 | Testing matters 88 | *************** 89 | 90 | `django-downloadview` also helps you :doc:`test the views you customized 91 | `. 92 | 93 | You may also :doc:`write healthchecks ` to make sure everything 94 | goes fine in live environments. 95 | 96 | 97 | ************ 98 | What's next? 99 | ************ 100 | 101 | Let's :doc:`install django-downloadview `. 102 | 103 | 104 | .. rubric:: Notes & references 105 | 106 | .. target-notes:: 107 | 108 | .. _`nginx`: http://nginx.org 109 | .. _`X-Accel`: http://wiki.nginx.org/X-accel 110 | -------------------------------------------------------------------------------- /docs/responses.txt: -------------------------------------------------------------------------------- 1 | ######### 2 | Responses 3 | ######### 4 | 5 | .. currentmodule:: django_downloadview.response 6 | 7 | Views return :class:`DownloadResponse`. 8 | 9 | Middlewares (and decorators) are given the opportunity to capture responses and 10 | convert them to :class:`ProxiedDownloadResponse`. 11 | 12 | 13 | **************** 14 | DownloadResponse 15 | **************** 16 | 17 | .. autoclass:: DownloadResponse 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | :member-order: bysource 22 | 23 | 24 | *********************** 25 | ProxiedDownloadResponse 26 | *********************** 27 | 28 | .. autoclass:: ProxiedDownloadResponse 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | :member-order: bysource 33 | -------------------------------------------------------------------------------- /docs/settings.txt: -------------------------------------------------------------------------------- 1 | ######### 2 | Configure 3 | ######### 4 | 5 | Here is the list of Django settings for `django-downloadview`. 6 | 7 | 8 | ************** 9 | INSTALLED_APPS 10 | ************** 11 | 12 | There is no need to register this application in ``INSTALLED_APPS``. 13 | 14 | 15 | ****************** 16 | MIDDLEWARE 17 | ****************** 18 | 19 | If you plan to setup :doc:`reverse-proxy optimizations `, 20 | add ``django_downloadview.SmartDownloadMiddleware`` to ``MIDDLEWARE``. 21 | It is a response middleware. Move it after middlewares that compute the 22 | response content such as gzip middleware. 23 | 24 | Example: 25 | 26 | .. literalinclude:: /../demo/demoproject/settings.py 27 | :language: python 28 | :start-after: BEGIN middlewares 29 | :end-before: END middlewares 30 | 31 | 32 | ******************** 33 | DEFAULT_FILE_STORAGE 34 | ******************** 35 | 36 | django-downloadview offers a built-in signed file storage, which cryptographically 37 | signs requested file URLs with the Django's built-in TimeStampSigner. 38 | 39 | To utilize the signed storage views you can configure 40 | 41 | .. code:: python 42 | 43 | DEFAULT_FILE_STORAGE='django_downloadview.storage.SignedStorage' 44 | 45 | The signed file storage system inserts a ``X-Signature`` header to the requested file 46 | URLs, and they can then be verified with the supplied ``signature_required`` wrapper function: 47 | 48 | .. code:: python 49 | 50 | from django.conf.urls import url, url_patterns 51 | 52 | from django_downloadview import ObjectDownloadView 53 | from django_downloadview.decorators import signature_required 54 | 55 | from demoproject.download.models import Document # A model with a FileField 56 | 57 | # ObjectDownloadView inherits from django.views.generic.BaseDetailView. 58 | download = ObjectDownloadView.as_view(model=Document, file_field='file') 59 | 60 | urlpatterns = [ 61 | path('download//', signature_required(download)), 62 | ] 63 | 64 | Make sure to test the desired functionality after configuration. 65 | 66 | *************************** 67 | DOWNLOADVIEW_URL_EXPIRATION 68 | *************************** 69 | 70 | Number of seconds signed download URLs are valid before expiring. 71 | 72 | Default value for this flag is None and URLs never expire. 73 | 74 | ******************** 75 | DOWNLOADVIEW_BACKEND 76 | ******************** 77 | 78 | This setting is used by 79 | :class:`~django_downloadview.middlewares.SmartDownloadMiddleware`. 80 | It is the import string of a callable (typically a class) of an optimization 81 | backend (typically a :class:`~django_downloadview.BaseDownloadMiddleware` 82 | subclass). 83 | 84 | Example: 85 | 86 | .. literalinclude:: /../demo/demoproject/settings.py 87 | :language: python 88 | :start-after: BEGIN backend 89 | :end-before: END backend 90 | 91 | See :doc:`/optimizations/index` for a list of available backends (middlewares). 92 | 93 | When ``django_downloadview.SmartDownloadMiddleware`` is in your 94 | ``MIDDLEWARE``, this setting must be explicitely configured (no default 95 | value). Else, you can ignore this setting. 96 | 97 | 98 | ****************** 99 | DOWNLOADVIEW_RULES 100 | ****************** 101 | 102 | This setting is used by 103 | :class:`~django_downloadview.middlewares.SmartDownloadMiddleware`. 104 | It is a list of positional arguments or keyword arguments that will be used to 105 | instanciate class mentioned as ``DOWNLOADVIEW_BACKEND``. 106 | 107 | Each item in the list can be either a list of positional arguments, or a 108 | dictionary of keyword arguments. One item cannot contain both positional and 109 | keyword arguments. 110 | 111 | Here is an example containing one rule using keyword arguments: 112 | 113 | .. literalinclude:: /../demo/demoproject/settings.py 114 | :language: python 115 | :start-after: BEGIN rules 116 | :end-before: END rules 117 | 118 | See :doc:`/optimizations/index` for details about builtin backends 119 | (middlewares) and their options. 120 | 121 | When ``django_downloadview.SmartDownloadMiddleware`` is in your 122 | ``MIDDLEWARE``, this setting must be explicitely configured (no default 123 | value). Else, you can ignore this setting. 124 | -------------------------------------------------------------------------------- /docs/testing.txt: -------------------------------------------------------------------------------- 1 | ########### 2 | Write tests 3 | ########### 4 | 5 | `django_downloadview` embeds test utilities: 6 | 7 | * :func:`~django_downloadview.test.temporary_media_root` 8 | * :func:`~django_downloadview.test.assert_download_response` 9 | * :func:`~django_downloadview.test.setup_view` 10 | * :func:`~django_downloadview.nginx.tests.assert_x_accel_redirect` 11 | 12 | 13 | ******************** 14 | temporary_media_root 15 | ******************** 16 | 17 | .. autofunction:: django_downloadview.test.temporary_media_root 18 | 19 | 20 | ************************ 21 | assert_download_response 22 | ************************ 23 | 24 | .. autofunction:: django_downloadview.test.assert_download_response 25 | 26 | Examples, related to :doc:`StorageDownloadView demo `: 27 | 28 | .. literalinclude:: /../demo/demoproject/storage/tests.py 29 | :language: python 30 | :lines: 3-7, 9-57 31 | 32 | 33 | ********** 34 | setup_view 35 | ********** 36 | 37 | .. autofunction:: django_downloadview.test.setup_view 38 | 39 | Example, related to :doc:`StorageDownloadView demo `: 40 | 41 | .. literalinclude:: /../demo/demoproject/storage/tests.py 42 | :language: python 43 | :lines: 1-2, 8-12, 59- 44 | -------------------------------------------------------------------------------- /docs/views/custom.txt: -------------------------------------------------------------------------------- 1 | ################## 2 | Make your own view 3 | ################## 4 | 5 | .. currentmodule:: django_downloadview.views.base 6 | 7 | 8 | ************* 9 | DownloadMixin 10 | ************* 11 | 12 | The :py:class:`django_downloadview.views.DownloadMixin` class is not a view. It 13 | is a base class which you can inherit of to create custom download views. 14 | 15 | ``DownloadMixin`` is a base of `BaseDownloadView`_, which itself is a base of 16 | all other django_downloadview's builtin views. 17 | 18 | .. autoclass:: DownloadMixin 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | :member-order: bysource 23 | 24 | 25 | **************** 26 | BaseDownloadView 27 | **************** 28 | 29 | The :py:class:`django_downloadview.views.BaseDownloadView` class is a base 30 | class to create download views. It inherits `DownloadMixin`_ and 31 | :py:class:`django.views.generic.base.View`. 32 | 33 | The only thing it does is to implement 34 | :py:meth:`get `: it triggers 35 | :py:meth:`DownloadMixin's render_to_response 36 | `. 37 | 38 | .. autoclass:: BaseDownloadView 39 | :members: 40 | :undoc-members: 41 | :show-inheritance: 42 | :member-order: bysource 43 | 44 | 45 | *********************************************** 46 | Serving a file inline rather than as attachment 47 | *********************************************** 48 | 49 | Use :attr:`~DownloadMixin.attachment` to make a view serve a file inline rather 50 | than as attachment, i.e. to display the file as if it was an internal part of a 51 | page rather than triggering "Save file as..." prompt. 52 | 53 | See details in :attr:`attachment API documentation 54 | <~DownloadMixin.attachment>`. 55 | 56 | .. literalinclude:: /../demo/demoproject/object/views.py 57 | :language: python 58 | :lines: 1-2, 19 59 | 60 | 61 | ************************************ 62 | Handling http not modified responses 63 | ************************************ 64 | 65 | Sometimes, you know the latest date and time the content was generated at, and 66 | you know a new request would generate exactly the same content. In such a case, 67 | you should implement :py:meth:`~VirtualDownloadView.was_modified_since` in your 68 | view. 69 | 70 | .. note:: 71 | 72 | Default :py:meth:`~VirtualDownloadView.was_modified_since` implementation 73 | trusts file wrapper's ``was_modified_since`` if any. Else (if calling 74 | ``was_modified_since()`` raises ``NotImplementedError`` or 75 | ``AttributeError``) it returns ``True``, i.e. it assumes the file was 76 | modified. 77 | 78 | As an example, the download views above always generate "Hello world!"... so, 79 | if the client already downloaded it, we can safely return some HTTP "304 Not 80 | Modified" response: 81 | 82 | .. code:: python 83 | 84 | from django.core.files.base import ContentFile 85 | from django_downloadview import VirtualDownloadView 86 | 87 | class TextDownloadView(VirtualDownloadView): 88 | def get_file(self): 89 | """Return :class:`django.core.files.base.ContentFile` object.""" 90 | return ContentFile("Hello world!", name='hello-world.txt') 91 | 92 | def was_modified_since(self, file_instance, since): 93 | return False # Never modified, always "Hello world!". 94 | -------------------------------------------------------------------------------- /docs/views/http.txt: -------------------------------------------------------------------------------- 1 | ################ 2 | HTTPDownloadView 3 | ################ 4 | 5 | .. py:module:: django_downloadview.views.http 6 | 7 | :class:`HTTPDownloadView` **serves a file given an URL.**, i.e. it acts like 8 | a proxy. 9 | 10 | This view is particularly handy when: 11 | 12 | * the client does not have access to the file resource, while your Django 13 | server does. 14 | 15 | * the client does trust your server, your server trusts a third-party, you do 16 | not want to bother the client with the third-party. 17 | 18 | 19 | ************** 20 | Simple example 21 | ************** 22 | 23 | Setup a view to stream files given URL: 24 | 25 | .. literalinclude:: /../demo/demoproject/http/views.py 26 | :language: python 27 | 28 | 29 | ************ 30 | Base options 31 | ************ 32 | 33 | :class:`HTTPDownloadView` inherits from 34 | :class:`~django_downloadview.views.base.DownloadMixin`, which has various 35 | options such as :attr:`~django_downloadview.views.base.DownloadMixin.basename` 36 | or :attr:`~django_downloadview.views.base.DownloadMixin.attachment`. 37 | 38 | 39 | ************* 40 | API reference 41 | ************* 42 | 43 | .. autoclass:: HTTPDownloadView 44 | :members: 45 | :undoc-members: 46 | :show-inheritance: 47 | :member-order: bysource 48 | -------------------------------------------------------------------------------- /docs/views/index.txt: -------------------------------------------------------------------------------- 1 | ########### 2 | Setup views 3 | ########### 4 | 5 | Setup views depending on your needs: 6 | 7 | * :doc:`/views/object` when you have a model with a file field; 8 | * :doc:`/views/storage` when you manage files in a storage; 9 | * :doc:`/views/path` when you have an absolute filename on local filesystem; 10 | * :doc:`/views/http` when you have an URL (the resource is proxied); 11 | * :doc:`/views/virtual` when you generate a file dynamically; 12 | * :doc:`bases and mixins ` to make your own. 13 | 14 | .. toctree:: 15 | :hidden: 16 | 17 | object 18 | storage 19 | path 20 | http 21 | virtual 22 | custom 23 | -------------------------------------------------------------------------------- /docs/views/object.txt: -------------------------------------------------------------------------------- 1 | ################## 2 | ObjectDownloadView 3 | ################## 4 | 5 | .. py:module:: django_downloadview.views.object 6 | 7 | :class:`ObjectDownloadView` **serves files managed in models with file fields** 8 | such as :class:`~django.db.models.FileField` or 9 | :class:`~django.db.models.ImageField`. 10 | 11 | Use this view like Django's builtin 12 | :class:`~django.views.generic.detail.DetailView`. 13 | 14 | Additional options allow you to store file metadata (size, content-type, ...) 15 | in the model, as deserialized fields. 16 | 17 | 18 | ************** 19 | Simple example 20 | ************** 21 | 22 | Given a model with a :class:`~django.db.models.FileField`: 23 | 24 | .. literalinclude:: /../demo/demoproject/object/models.py 25 | :language: python 26 | :lines: 1-6 27 | 28 | 29 | Setup a view to stream the ``file`` attribute: 30 | 31 | .. literalinclude:: /../demo/demoproject/object/views.py 32 | :language: python 33 | :lines: 1-6 34 | 35 | :class:`~django_downloadview.views.object.ObjectDownloadView` inherits from 36 | :class:`~django.views.generic.detail.BaseDetailView`, i.e. it expects either 37 | ``slug`` or ``pk``: 38 | 39 | .. literalinclude:: /../demo/demoproject/object/urls.py 40 | :language: python 41 | :lines: 1-7, 8-11, 27 42 | 43 | 44 | ************ 45 | Base options 46 | ************ 47 | 48 | :class:`ObjectDownloadView` inherits from 49 | :class:`~django_downloadview.views.base.DownloadMixin`, which has various 50 | options such as :attr:`~django_downloadview.views.base.DownloadMixin.basename` 51 | or :attr:`~django_downloadview.views.base.DownloadMixin.attachment`. 52 | 53 | 54 | *************************** 55 | Serving specific file field 56 | *************************** 57 | 58 | If your model holds several file fields, or if the file field name is not 59 | "file", you can use :attr:`ObjectDownloadView.file_field` to specify the field 60 | to use. 61 | 62 | Here is a model where there are two file fields: 63 | 64 | .. literalinclude:: /../demo/demoproject/object/models.py 65 | :language: python 66 | :lines: 1-6, 7 67 | 68 | Then here is the code to serve "another_file" instead of the default "file": 69 | 70 | .. literalinclude:: /../demo/demoproject/object/views.py 71 | :language: python 72 | :lines: 1-4, 8-11 73 | 74 | 75 | ********************************** 76 | Mapping file attributes to model's 77 | ********************************** 78 | 79 | Sometimes, you use Django model to store file's metadata. Some of this metadata 80 | can be used when you serve the file. 81 | 82 | As an example, let's consider the client-side basename lives in model and not 83 | in storage: 84 | 85 | .. literalinclude:: /../demo/demoproject/object/models.py 86 | :language: python 87 | :lines: 1-6, 8 88 | 89 | Then you can configure the :attr:`ObjectDownloadView.basename_field` option: 90 | 91 | .. literalinclude:: /../demo/demoproject/object/views.py 92 | :language: python 93 | :lines: 1-4, 13-17 94 | 95 | .. note:: 96 | 97 | ``basename`` could have been a model's property instead of a ``CharField``. 98 | 99 | See details below for a full list of options. 100 | 101 | 102 | ************* 103 | API reference 104 | ************* 105 | 106 | .. autoclass:: ObjectDownloadView 107 | :members: 108 | :undoc-members: 109 | :show-inheritance: 110 | :member-order: bysource 111 | -------------------------------------------------------------------------------- /docs/views/path.txt: -------------------------------------------------------------------------------- 1 | ################ 2 | PathDownloadView 3 | ################ 4 | 5 | .. py:module:: django_downloadview.views.path 6 | 7 | :class:`PathDownloadView` **serves file given a path on local filesystem**. 8 | 9 | Use this view whenever you just have a path, outside storage or model. 10 | 11 | .. warning:: 12 | 13 | Take care of path validation, especially if you compute paths from user 14 | input: an attacker may be able to download files from arbitrary locations. 15 | In most cases, you should consider managing files in storages, because they 16 | implement default security mechanisms. 17 | 18 | 19 | ************** 20 | Simple example 21 | ************** 22 | 23 | Setup a view to stream files given path: 24 | 25 | .. literalinclude:: /../demo/demoproject/path/views.py 26 | :language: python 27 | :lines: 1-13 28 | :emphasize-lines: 13 29 | 30 | 31 | ************ 32 | Base options 33 | ************ 34 | 35 | :class:`PathDownloadView` inherits from 36 | :class:`~django_downloadview.views.base.DownloadMixin`, which has various 37 | options such as :attr:`~django_downloadview.views.base.DownloadMixin.basename` 38 | or :attr:`~django_downloadview.views.base.DownloadMixin.attachment`. 39 | 40 | 41 | ************************** 42 | Computing path dynamically 43 | ************************** 44 | 45 | Override the :meth:`PathDownloadView.get_path` method to adapt path 46 | resolution to your needs: 47 | 48 | .. literalinclude:: /../demo/demoproject/path/views.py 49 | :language: python 50 | :lines: 1-9, 15- 51 | 52 | The view accepts a ``path`` argument you can setup either in ``as_view`` or 53 | via URLconfs: 54 | 55 | .. literalinclude:: /../demo/demoproject/path/urls.py 56 | :language: python 57 | :lines: 1-13 58 | 59 | 60 | ************* 61 | API reference 62 | ************* 63 | 64 | .. autoclass:: PathDownloadView 65 | :members: 66 | :undoc-members: 67 | :show-inheritance: 68 | :member-order: bysource 69 | -------------------------------------------------------------------------------- /docs/views/storage.txt: -------------------------------------------------------------------------------- 1 | ################### 2 | StorageDownloadView 3 | ################### 4 | 5 | .. py:module:: django_downloadview.views.storage 6 | 7 | :class:`StorageDownloadView` **serves files given a storage and a path**. 8 | 9 | Use this view when you manage files in a storage (which is a good practice), 10 | unrelated to a model. 11 | 12 | 13 | ************** 14 | Simple example 15 | ************** 16 | 17 | Given a storage: 18 | 19 | .. literalinclude:: /../demo/demoproject/storage/views.py 20 | :language: python 21 | :lines: 1, 4-5 22 | 23 | Setup a view to stream files in storage: 24 | 25 | .. literalinclude:: /../demo/demoproject/storage/views.py 26 | :language: python 27 | :lines: 3-6, 8-9 28 | 29 | The view accepts a ``path`` argument you can setup either in ``as_view`` or 30 | via URLconfs: 31 | 32 | .. literalinclude:: /../demo/demoproject/storage/urls.py 33 | :language: python 34 | :lines: 1-6, 7-11, 17 35 | 36 | 37 | ************ 38 | Base options 39 | ************ 40 | 41 | :class:`StorageDownloadView` inherits from 42 | :class:`~django_downloadview.views.base.DownloadMixin`, which has various 43 | options such as :attr:`~django_downloadview.views.base.DownloadMixin.basename` 44 | or :attr:`~django_downloadview.views.base.DownloadMixin.attachment`. 45 | 46 | 47 | ************************** 48 | Computing path dynamically 49 | ************************** 50 | 51 | Override the :meth:`StorageDownloadView.get_path` method to adapt path 52 | resolution to your needs. 53 | 54 | As an example, here is the same view as above, but the path is converted to 55 | uppercase: 56 | 57 | .. literalinclude:: /../demo/demoproject/storage/views.py 58 | :language: python 59 | :lines: 3-5, 11-20 60 | 61 | 62 | ************* 63 | API reference 64 | ************* 65 | 66 | .. autoclass:: StorageDownloadView 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | :member-order: bysource 71 | -------------------------------------------------------------------------------- /docs/views/virtual.txt: -------------------------------------------------------------------------------- 1 | ################### 2 | VirtualDownloadView 3 | ################### 4 | 5 | .. py:module:: django_downloadview.views.virtual 6 | 7 | :class:`VirtualDownloadView` **serves files that do not live on disk**. 8 | Use it when you want to stream a file which content is dynamically generated 9 | or which lives in memory. 10 | 11 | It is all about overriding :meth:`VirtualDownloadView.get_file` method so that 12 | it returns a suitable file wrapper... 13 | 14 | .. note:: 15 | 16 | Current implementation does not support reverse-proxy optimizations, 17 | because content is actually generated within Django, not stored in some 18 | third-party place. 19 | 20 | 21 | ************ 22 | Base options 23 | ************ 24 | 25 | :class:`VirtualDownloadView` inherits from 26 | :class:`~django_downloadview.views.base.DownloadMixin`, which has various 27 | options such as :attr:`~django_downloadview.views.base.DownloadMixin.basename` 28 | or :attr:`~django_downloadview.views.base.DownloadMixin.attachment`. 29 | 30 | 31 | *************************************** 32 | Serve text (string or unicode) or bytes 33 | *************************************** 34 | 35 | Let's consider you build text dynamically, as a bytes or string or unicode 36 | object. Serve it with Django's builtin 37 | :class:`~django.core.files.base.ContentFile` wrapper: 38 | 39 | .. literalinclude:: /../demo/demoproject/virtual/views.py 40 | :language: python 41 | :lines: 1, 3, 7-11 42 | 43 | 44 | ************** 45 | Serve StringIO 46 | ************** 47 | 48 | :class:`~StringIO.StringIO` object lives in memory. Let's wrap it in some 49 | download view via :class:`~django_downloadview.files.VirtualFile`: 50 | 51 | .. literalinclude:: /../demo/demoproject/virtual/views.py 52 | :language: python 53 | :lines: 1-4, 5-6, 13-17 54 | 55 | 56 | ************************ 57 | Stream generated content 58 | ************************ 59 | 60 | Let's consider you have a generator function (``yield``) or an iterator object 61 | (``__iter__()``): 62 | 63 | 64 | .. literalinclude:: /../demo/demoproject/virtual/views.py 65 | :language: python 66 | :lines: 20-23 67 | 68 | 69 | Stream generated content using :class:`VirtualDownloadView`, 70 | :class:`~django_downloadview.files.VirtualFile` and 71 | :class:`~django_downloadview.io.BytesIteratorIO`: 72 | 73 | .. literalinclude:: /../demo/demoproject/virtual/views.py 74 | :language: python 75 | :lines: 3, 26-30 76 | 77 | 78 | ************* 79 | API reference 80 | ************* 81 | 82 | .. autoclass:: VirtualDownloadView 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | :member-order: bysource 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | #: Absolute path to directory containing setup.py file. 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | setup( 8 | name="django-downloadview", 9 | use_scm_version={"version_scheme": "post-release"}, 10 | setup_requires=["setuptools_scm"], 11 | description="Serve files with Django and reverse-proxies.", 12 | long_description=open(os.path.join(here, "README.rst")).read(), 13 | long_description_content_type="text/x-rst", 14 | classifiers=[ 15 | "Development Status :: 5 - Production/Stable", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Framework :: Django", 24 | "Framework :: Django :: 4.2", 25 | "Framework :: Django :: 5.0", 26 | ], 27 | keywords=" ".join( 28 | [ 29 | "file", 30 | "stream", 31 | "download", 32 | "FileField", 33 | "ImageField", 34 | "x-accel", 35 | "x-accel-redirect", 36 | "x-sendfile", 37 | "sendfile", 38 | "mod_xsendfile", 39 | "offload", 40 | ] 41 | ), 42 | author="Benoît Bryon", 43 | author_email="benoit@marmelune.net", 44 | url="https://django-downloadview.readthedocs.io/", 45 | license="BSD", 46 | packages=[ 47 | "django_downloadview", 48 | "django_downloadview.apache", 49 | "django_downloadview.lighttpd", 50 | "django_downloadview.nginx", 51 | "django_downloadview.views", 52 | ], 53 | include_package_data=True, 54 | zip_safe=False, 55 | python_requires=">=3.8", 56 | install_requires=[ 57 | # BEGIN requirements 58 | "Django>=4.2", 59 | "requests", 60 | # END requirements 61 | ], 62 | extras_require={ 63 | "test": ["tox"], 64 | }, 65 | ) 66 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-downloadview/31e64b6a768435b9938c6a1ce7ba5d6ec862acac/tests/__init__.py -------------------------------------------------------------------------------- /tests/api.py: -------------------------------------------------------------------------------- 1 | """Test suite around :mod:`django_downloadview.api` and deprecation plan.""" 2 | 3 | from importlib import import_module, reload 4 | import unittest 5 | import warnings 6 | 7 | from django.core.exceptions import ImproperlyConfigured 8 | import django.test 9 | from django.test.utils import override_settings 10 | 11 | 12 | class APITestCase(unittest.TestCase): 13 | """Make sure django_downloadview exposes API.""" 14 | 15 | def assert_module_attributes(self, module_path, attribute_names): 16 | """Assert imported ``module_path`` has ``attribute_names``.""" 17 | module = import_module(module_path) 18 | missing_attributes = [] 19 | for attribute_name in attribute_names: 20 | if not hasattr(module, attribute_name): 21 | missing_attributes.append(attribute_name) 22 | if missing_attributes: 23 | self.fail( 24 | 'Missing attributes in "{module_path}": {", ".join(missing_attributes)}' 25 | ) 26 | 27 | def test_root_attributes(self): 28 | """API is exposed in django_downloadview root package. 29 | 30 | The goal of this test is to make sure that main items of project's API 31 | are easy to import... and prevent refactoring from breaking main API. 32 | 33 | If this test is broken by refactoring, a :class:`DeprecationWarning` or 34 | simimar should be raised. 35 | 36 | """ 37 | api = [ 38 | # Views: 39 | "ObjectDownloadView", 40 | "StorageDownloadView", 41 | "PathDownloadView", 42 | "HTTPDownloadView", 43 | "VirtualDownloadView", 44 | "BaseDownloadView", 45 | "DownloadMixin", 46 | # File wrappers: 47 | "StorageFile", 48 | "HTTPFile", 49 | "VirtualFile", 50 | # Responses: 51 | "DownloadResponse", 52 | "ProxiedDownloadResponse", 53 | # Middlewares: 54 | "BaseDownloadMiddleware", 55 | "DownloadDispatcherMiddleware", 56 | "SmartDownloadMiddleware", 57 | # Testing: 58 | "assert_download_response", 59 | "setup_view", 60 | "temporary_media_root", 61 | # Utilities: 62 | "StringIteratorIO", 63 | "sendfile", 64 | ] 65 | self.assert_module_attributes("django_downloadview", api) 66 | 67 | def test_nginx_attributes(self): 68 | """Nginx-related API is exposed in django_downloadview.nginx.""" 69 | api = [ 70 | "XAccelRedirectResponse", 71 | "XAccelRedirectMiddleware", 72 | "x_accel_redirect", 73 | "assert_x_accel_redirect", 74 | ] 75 | self.assert_module_attributes("django_downloadview.nginx", api) 76 | 77 | def test_apache_attributes(self): 78 | """Apache-related API is exposed in django_downloadview.apache.""" 79 | api = [ 80 | "XSendfileResponse", 81 | "XSendfileMiddleware", 82 | "x_sendfile", 83 | "assert_x_sendfile", 84 | ] 85 | self.assert_module_attributes("django_downloadview.apache", api) 86 | 87 | def test_lighttpd_attributes(self): 88 | """Lighttpd-related API is exposed in django_downloadview.lighttpd.""" 89 | api = [ 90 | "XSendfileResponse", 91 | "XSendfileMiddleware", 92 | "x_sendfile", 93 | "assert_x_sendfile", 94 | ] 95 | self.assert_module_attributes("django_downloadview.lighttpd", api) 96 | 97 | 98 | class DeprecatedAPITestCase(django.test.SimpleTestCase): 99 | """Make sure using deprecated items raise DeprecationWarning.""" 100 | 101 | def test_nginx_x_accel_redirect_middleware(self): 102 | "XAccelRedirectMiddleware in settings triggers ImproperlyConfigured." 103 | with override_settings( 104 | MIDDLEWARE=["django_downloadview.nginx.XAccelRedirectMiddleware"], 105 | ): 106 | with self.assertRaises(ImproperlyConfigured): 107 | import django_downloadview.nginx.settings 108 | 109 | reload(django_downloadview.nginx.settings) 110 | 111 | def test_nginx_x_accel_redirect_global_settings(self): 112 | """Global settings for Nginx middleware are deprecated.""" 113 | settings_overrides = { 114 | "NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING": True, 115 | "NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE": 32, 116 | "NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES": 3600, 117 | "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT": "/", 118 | "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR": "/", 119 | "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL": "/", 120 | "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL": "/", 121 | "NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL": "/", 122 | } 123 | import django_downloadview.nginx.settings 124 | 125 | missed_warnings = [] 126 | for setting_name, setting_value in settings_overrides.items(): 127 | warnings.resetwarnings() 128 | warnings.simplefilter("always") 129 | with warnings.catch_warnings(record=True) as warning_list: 130 | with override_settings(**{setting_name: setting_value}): 131 | reload(django_downloadview.nginx.settings) 132 | caught = False 133 | for warning_item in warning_list: 134 | if warning_item.category is DeprecationWarning: 135 | if "deprecated" in str(warning_item.message): 136 | if setting_name in str(warning_item.message): 137 | caught = True 138 | break 139 | if not caught: 140 | missed_warnings.append(setting_name) 141 | if missed_warnings: 142 | self.fail( 143 | f"No DeprecationWarning raised about following settings: " 144 | f'{", ".join(missed_warnings)}.' 145 | ) 146 | -------------------------------------------------------------------------------- /tests/io.py: -------------------------------------------------------------------------------- 1 | """Tests around :mod:`django_downloadview.io`.""" 2 | 3 | import unittest 4 | 5 | from django_downloadview import BytesIteratorIO, TextIteratorIO 6 | 7 | HELLO_TEXT = "Hello world!\né\n" 8 | HELLO_BYTES = b"Hello world!\n\xc3\xa9\n" 9 | 10 | 11 | def generate_hello_text(): 12 | """Generate u'Hello world!\n'.""" 13 | yield "Hello " 14 | yield "world!" 15 | yield "\n" 16 | yield "é" 17 | yield "\n" 18 | 19 | 20 | def generate_hello_bytes(): 21 | """Generate b'Hello world!\n'.""" 22 | yield b"Hello " 23 | yield b"world!" 24 | yield b"\n" 25 | yield b"\xc3\xa9" 26 | yield b"\n" 27 | 28 | 29 | class TextIteratorIOTestCase(unittest.TestCase): 30 | """Tests around :class:`~django_downloadview.io.TextIteratorIO`.""" 31 | 32 | def test_read_text(self): 33 | """TextIteratorIO obviously accepts text generator.""" 34 | file_obj = TextIteratorIO(generate_hello_text()) 35 | self.assertEqual(file_obj.read(), HELLO_TEXT) 36 | 37 | def test_read_bytes(self): 38 | """TextIteratorIO converts bytes as text.""" 39 | file_obj = TextIteratorIO(generate_hello_bytes()) 40 | self.assertEqual(file_obj.read(), HELLO_TEXT) 41 | 42 | 43 | class BytesIteratorIOTestCase(unittest.TestCase): 44 | """Tests around :class:`~django_downloadview.io.BytesIteratorIO`.""" 45 | 46 | def test_read_bytes(self): 47 | """BytesIteratorIO obviously accepts bytes generator.""" 48 | file_obj = BytesIteratorIO(generate_hello_bytes()) 49 | self.assertEqual(file_obj.read(), HELLO_BYTES) 50 | 51 | def test_read_text(self): 52 | """BytesIteratorIO converts text as bytes.""" 53 | file_obj = BytesIteratorIO(generate_hello_text()) 54 | self.assertEqual(file_obj.read(), HELLO_BYTES) 55 | -------------------------------------------------------------------------------- /tests/packaging.py: -------------------------------------------------------------------------------- 1 | """Tests around project's distribution and packaging.""" 2 | 3 | import importlib.metadata 4 | import os 5 | import unittest 6 | 7 | tests_dir = os.path.dirname(os.path.abspath(__file__)) 8 | project_dir = os.path.dirname(tests_dir) 9 | build_dir = os.path.join(project_dir, "var", "docs", "html") 10 | 11 | 12 | class VersionTestCase(unittest.TestCase): 13 | """Various checks around project's version info.""" 14 | 15 | def get_version(self): 16 | """Return django_downloadview.__version__.""" 17 | from django_downloadview import __version__ 18 | 19 | return __version__ 20 | 21 | def test_version_present(self): 22 | """:PEP:`396` - django_downloadview has __version__ attribute.""" 23 | try: 24 | self.get_version() 25 | except ImportError: 26 | self.fail("django_downloadview package has no __version__.") 27 | 28 | def test_version_match(self): 29 | """django_downloadview.__version__ matches importlib metadata.""" 30 | distribution = importlib.metadata.distribution("django-downloadview") 31 | installed_version = distribution.version 32 | self.assertEqual( 33 | installed_version, 34 | self.get_version(), 35 | "Version mismatch: django_downloadview.__version__ " 36 | 'is "%s" whereas importlib.metadata tells "%s". ' 37 | "You may need to run ``make develop`` to update the " 38 | "installed version in development environment." 39 | % (self.get_version(), installed_version), 40 | ) 41 | -------------------------------------------------------------------------------- /tests/response.py: -------------------------------------------------------------------------------- 1 | """Unit tests around responses.""" 2 | 3 | import unittest 4 | 5 | from django_downloadview.response import DownloadResponse 6 | 7 | 8 | class DownloadResponseTestCase(unittest.TestCase): 9 | """Tests around :class:`django_downloadviews.response.DownloadResponse`.""" 10 | 11 | def test_content_disposition_encoding(self): 12 | """Content-Disposition header is encoded.""" 13 | response = DownloadResponse( 14 | "fake file", 15 | attachment=True, 16 | basename="espacé .txt", 17 | ) 18 | headers = response.default_headers 19 | self.assertIn('filename="espace_.txt"', headers["Content-Disposition"]) 20 | self.assertIn( 21 | "filename*=UTF-8''espac%C3%A9%20.txt", headers["Content-Disposition"] 22 | ) 23 | 24 | def test_content_disposition_escaping(self): 25 | """Content-Disposition headers escape special characters.""" 26 | response = DownloadResponse( 27 | "fake file", attachment=True, basename=r'"malicious\file.exe' 28 | ) 29 | headers = response.default_headers 30 | self.assertIn( 31 | r'filename="\"malicious\\file.exe"', headers["Content-Disposition"] 32 | ) 33 | -------------------------------------------------------------------------------- /tests/sendfile.py: -------------------------------------------------------------------------------- 1 | """Tests around :py:mod:`django_downloadview.sendfile`.""" 2 | 3 | from django.http import Http404 4 | import django.test 5 | 6 | from django_downloadview.response import DownloadResponse 7 | from django_downloadview.shortcuts import sendfile 8 | 9 | 10 | class SendfileTestCase(django.test.TestCase): 11 | """Tests around :func:`django_downloadview.sendfile.sendfile`.""" 12 | 13 | def test_defaults(self): 14 | """sendfile() takes at least request and filename.""" 15 | request = django.test.RequestFactory().get("/fake-url") 16 | filename = __file__ 17 | response = sendfile(request, filename) 18 | self.assertTrue(isinstance(response, DownloadResponse)) 19 | self.assertFalse(response.attachment) 20 | 21 | def test_custom(self): 22 | """sendfile() accepts various arguments for response tuning.""" 23 | request = django.test.RequestFactory().get("/fake-url") 24 | filename = __file__ 25 | response = sendfile( 26 | request, 27 | filename, 28 | attachment=True, 29 | attachment_filename="toto.txt", 30 | mimetype="test/octet-stream", 31 | encoding="gzip", 32 | ) 33 | self.assertTrue(isinstance(response, DownloadResponse)) 34 | self.assertTrue(response.attachment) 35 | self.assertEqual(response.basename, "toto.txt") 36 | self.assertEqual(response["Content-Type"], "test/octet-stream; charset=utf-8") 37 | self.assertEqual(response.get_encoding(), "gzip") 38 | 39 | def test_404(self): 40 | """sendfile() raises Http404 if file does not exists.""" 41 | request = django.test.RequestFactory().get("/fake-url") 42 | filename = "i-do-no-exist" 43 | with self.assertRaises(Http404): 44 | sendfile(request, filename) 45 | -------------------------------------------------------------------------------- /tests/signature.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test signature generation and validation. 3 | """ 4 | 5 | import unittest 6 | 7 | from django.core.exceptions import PermissionDenied 8 | from django.core.signing import TimestampSigner 9 | 10 | from django_downloadview.decorators import _signature_is_valid 11 | from django_downloadview.storage import SignedURLMixin 12 | 13 | 14 | class TestStorage: 15 | def url(self, name): 16 | return "https://example.com/{name}".format(name=name) 17 | 18 | 19 | class SignedTestStorage(SignedURLMixin, TestStorage): 20 | pass 21 | 22 | 23 | class SignatureGeneratorTestCase(unittest.TestCase): 24 | def test_signed_storage(self): 25 | """ 26 | django_downloadview.storage.SignedURLMixin adds X-Signature to URLs. 27 | """ 28 | 29 | storage = SignedTestStorage() 30 | url = storage.url("test") 31 | self.assertIn("https://example.com/test?X-Signature=", url) 32 | 33 | 34 | class SignatureValidatorTestCase(unittest.TestCase): 35 | def test_verify_signature(self): 36 | """ 37 | django_downloadview.decorators._signature_is_valid returns True on 38 | valid signatures. 39 | """ 40 | 41 | signer = TimestampSigner() 42 | request = unittest.mock.MagicMock() 43 | 44 | request.path = "test" 45 | request.GET = {"X-Signature": signer.sign("test")} 46 | 47 | self.assertIsNone(_signature_is_valid(request)) 48 | 49 | def test_verify_signature_invalid(self): 50 | """ 51 | django_downloadview.decorators._signature_is_valid raises PermissionDenied 52 | on invalid signatures. 53 | """ 54 | 55 | request = unittest.mock.MagicMock() 56 | 57 | request.path = "test" 58 | request.GET = {"X-Signature": "not-valid"} 59 | 60 | with self.assertRaises(PermissionDenied): 61 | _signature_is_valid(request) 62 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311,312}-dj42 4 | py{310,311,312}-dj{50,main} 5 | lint 6 | sphinx 7 | readme 8 | 9 | [gh-actions] 10 | python = 11 | 3.8: py38, lint, sphinx, readme 12 | 3.9: py39 13 | 3.10: py310 14 | 3.11: py311 15 | 3.12: py312 16 | 17 | [gh-actions:env] 18 | DJANGO = 19 | 4.2: dj42 20 | 5.0: dj50 21 | main: djmain 22 | 23 | [testenv] 24 | deps = 25 | coverage 26 | dj42: Django>=4.2,<5.0 27 | dj50: Django>=5.0,<5.1 28 | djmain: https://github.com/django/django/archive/main.tar.gz 29 | pytest 30 | pytest-cov 31 | commands = 32 | pip install -e . 33 | pip install -e demo 34 | # doctests and unit tests 35 | pytest --cov=django_downloadview --cov=demoproject {posargs} 36 | # demo project integration tests 37 | coverage run --append {envbindir}/demo test {posargs: demoproject} 38 | coverage xml 39 | pip freeze 40 | ignore_outcome = 41 | djmain: True 42 | 43 | [testenv:lint] 44 | deps = 45 | flake8 46 | black 47 | isort 48 | commands = 49 | flake8 demo django_downloadview tests 50 | black --check demo django_downloadview tests 51 | isort --check-only --recursive demo django_downloadview tests 52 | 53 | [testenv:sphinx] 54 | deps = 55 | Sphinx 56 | commands = 57 | pip install -e . 58 | make --directory=docs SPHINXOPTS='-W' clean {posargs:html doctest linkcheck} 59 | whitelist_externals = 60 | make 61 | 62 | [testenv:readme] 63 | description = Ensure README renders on PyPI 64 | deps = twine 65 | commands = 66 | {envpython} setup.py -q sdist bdist_wheel 67 | twine check dist/* 68 | 69 | [flake8] 70 | max-line-length = 88 71 | ignore = E203, W503 72 | 73 | [coverage:run] 74 | source = django_downloadview,demo 75 | 76 | [pytest] 77 | DJANGO_SETTINGS_MODULE = demoproject.settings 78 | addopts = --doctest-modules --ignore=docs/ 79 | python_files = tests/*.py 80 | --------------------------------------------------------------------------------