Skip to content

Commit

Permalink
feat: implement django-presigned-url
Browse files Browse the repository at this point in the history
  • Loading branch information
Yelinz committed Jul 19, 2024
1 parent 881420a commit eab9dcb
Show file tree
Hide file tree
Showing 13 changed files with 710 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: friday
time: "00:00"
open-pull-requests-limit: 10
Empty file added .github/workflows/pypi.yml
Empty file.
Empty file added .github/workflows/tests.yml
Empty file.
75 changes: 75 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.pytest_cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/

# Translations
*.mo
*.pot

# Django stuff:
*.log

# Sphinx documentation
docs/_build/

# PyBuilder
target/

#Ipython Notebook
.ipynb_checkpoints

# Pyenv
.python-version

# Editor swap files
*.swp

# Cookiecutter test project
ci_project

# ruff
.ruff_cache/
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Contributing

Contributions to django-presigned-url are very welcome! Best have a look at the open [issues](https://github.com/adfinis/django-presigned-url)
and open a [GitHub pull request](https://github.com/adfinis/django-presigned-url/compare). See instructions below how to setup development
environment. Before writing any code, best discuss your proposed change in a GitHub issue to see if the proposed change makes sense for the project.

## Setup development environment

### Clone

To work on django-presigned-url you first need to clone

```bash
git clone https://github.com/adfinis/django-presigned-url.git
cd django-presigned-url
```
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.DEFAULT_GOAL := help

.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -k 1,1 | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

.PHONY: lint
lint: ## lint the code
@poetry run ruff check .
@poetry run ruff format --check .
@poetry run mypy .

.PHONY: format
format: ## format the code
@poetry run ruff check . --fix
@poetry run ruff format .
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# django-presigned-url

Generate presigned urls for django.

Empty file.
84 changes: 84 additions & 0 deletions django_presigned_url/presign_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import hashlib

from django.conf import settings
from django.utils import timezone
from django.utils.http import urlsafe_base64_encode
from django.http import HttpRequest
from django.core.exceptions import ValidationError
from django.utils.translation import gettext


def make_signature_components(
path: str,
hostname: str,
expires: int | None = None,
scheme: str = "http",
) -> tuple[str, int, str]:
"""Make the components used to sign and verify the url.
If `expires` is provided the components are called for the verification step.
Otherwise expiry is calculated and returned
"""
if not expires:
expires = int(
(
timezone.now()
+ timezone.timedelta(seconds=settings.PRESIGNED_URL_LIFETIME)
).timestamp()
)
host = f"{scheme}://{hostname}"
url = f"{host.strip('/')}{path}"
token = f"{url}{expires}{settings.SECRET_KEY}"
hash = hashlib.shake_256(token.encode())
# Django's base64 encoder strips padding and ascii-decodes the output
signature = urlsafe_base64_encode(hash.digest(32))
return url, expires, signature


def verify_signed_components(
path: str, hostname: str, expires: int, scheme: str, token_sig: str
) -> bool:
"""Verify a presigned download URL.
It tests against the expiry: raises a TimeoutError
It tests against signature integrity: raises an AssertionError
returns True otherwise.
"""
now = timezone.now()
host, expires, signature = make_signature_components(
path, hostname, expires, scheme
)

if int(now.timestamp()) > expires:
raise ValidationError(gettext("Presigned URL expired."))
if not token_sig == signature:
raise ValidationError(gettext("Invalid signature."))

return True


def make_presigned_url(path: str, request: HttpRequest) -> str:
"""Make a presigned URL."""
url, expires, signature = make_signature_components(
path,
request.get_host(),
scheme=request.META.get("wsgi.url_scheme", "http"),
)

return f"{url}?expires={expires}&signature={signature}"


def verify_presigned_request(path: str, request: HttpRequest) -> bool:
"""Verify a presigned URL."""
if token_sig := request.GET.get("signature"):
return verify_signed_components(
path,
request.get_host(),
expires=int(request.GET.get("expires")),
scheme=request.META.get("wsgi.url_scheme", "http"),
token_sig=token_sig,
)

return False
Empty file.
76 changes: 76 additions & 0 deletions django_presigned_url/tests/test_presigned_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import pytest
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from presign_urls import make_presigned_url, verify_presigned_request
from freezegun import freeze_time


@pytest.fixture(autouse=True, scope="session")
def django():
import django
from django.conf import settings

settings.configure(
SECRET_KEY="foobar",
PRESIGNED_URL_LIFETIME=3600,
ALLOWED_HOSTS=["example.com"],
)
django.setup()


@pytest.fixture(scope="function", autouse=True)
def _dj_autoclear_mailbox() -> None:
# Override the `_dj_autoclear_mailbox` test fixture in `pytest_django`.
pass


@pytest.fixture
def req():
req = HttpRequest()
req.META["HTTP_HOST"] = "example.com"
return req


@freeze_time("2024-02-02")
def test_make_presigned_url(req):
presigned = make_presigned_url("/download/1", req)
assert (
presigned
== "http://example.com/download/1?expires=1706835600&signature=yI7rCgdttNzH6hhgzawcc5Y-VWiFM4RZ-amlZyV-jKI"
)


@freeze_time("2024-02-02")
def test_verify_presigned_req(req):
req.GET = {
"expires": "1706835600",
"signature": "yI7rCgdttNzH6hhgzawcc5Y-VWiFM4RZ-amlZyV-jKI",
}
assert verify_presigned_request("/download/1", req)


@freeze_time("2024-02-02")
@pytest.mark.parametrize(
"query_params,error",
[
(
{
"expires": "1706749200",
"signature": "KumiLhKy-Qw_9Tm89hnPJCdRnFF78Su1cxYB_TVVqIM",
},
"Presigned URL expired.",
),
(
{
"expires": "1706835600",
"signature": "gimme",
},
"Invalid signature.",
),
],
)
def test_verify_presigned_req_error(req, query_params, error):
req.GET = query_params
with pytest.raises(ValidationError) as exception:
verify_presigned_request("/download/1", req)
assert error in str(exception.value)
Loading

0 comments on commit eab9dcb

Please sign in to comment.