From 20490809c8fbae2961f8cde0ce4cf06f0653a31a Mon Sep 17 00:00:00 2001 From: Bart Jeukendrup Date: Sat, 6 Apr 2024 12:46:50 +0200 Subject: [PATCH] feat: add ability to protect media --- Dockerfile | 2 ++ app/signals/apps/media/README.md | 23 +++++++++++++++ app/signals/apps/media/__init__.py | 0 app/signals/apps/media/apps.py | 6 ++++ app/signals/apps/media/storages.py | 24 ++++++++++++++++ app/signals/apps/media/tests.py | 3 ++ app/signals/apps/media/urls.py | 8 ++++++ app/signals/apps/media/views.py | 45 ++++++++++++++++++++++++++++++ app/signals/settings.py | 4 +++ app/signals/urls.py | 10 +++---- 10 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 app/signals/apps/media/README.md create mode 100644 app/signals/apps/media/__init__.py create mode 100644 app/signals/apps/media/apps.py create mode 100644 app/signals/apps/media/storages.py create mode 100644 app/signals/apps/media/tests.py create mode 100644 app/signals/apps/media/urls.py create mode 100644 app/signals/apps/media/views.py diff --git a/Dockerfile b/Dockerfile index 2d1afe018..e4baa1aa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,8 @@ RUN set -eux; \ libmagic1 \ libcairo2 \ libpango1.0-0 \ + libpcre3 \ + libpcre3-dev \ libpq-dev \ gcc \ graphviz \ diff --git a/app/signals/apps/media/README.md b/app/signals/apps/media/README.md new file mode 100644 index 000000000..8afa08660 --- /dev/null +++ b/app/signals/apps/media/README.md @@ -0,0 +1,23 @@ +# Protected media + +This app provides the possibility to protect the media folder. To use this functionality in production, specific uWSGI settings are required to use the X-Sendfile header. + +You can run uWSGI as follows: + +```bash +uwsgi \ + --master \ + --http=0.0.0.0:8000 \ + --module=signals.wsgi:application \ + --static-map=/signals/static=./app/static \ + --static-safe=./app/media \ + --plugins=router_static \ + --offload-threads=2 \ + --collect-header="X-Sendfile X_SENDFILE" \ + --response-route-if-not="empty:${X_SENDFILE} static:${X_SENDFILE}" \ + --buffer-size=32768 \ + --py-auto-reload=1 \ + --die-on-term +``` + +The relevant settings are `plugins`, `offload-threads`, `collect-header` and `response-route-if-not`. For more information see the [X-Sendfile emulation snippet of the uWSGI documentation](https://uwsgi-docs.readthedocs.io/en/latest/Snippets.html#x-sendfile-emulation). \ No newline at end of file diff --git a/app/signals/apps/media/__init__.py b/app/signals/apps/media/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/signals/apps/media/apps.py b/app/signals/apps/media/apps.py new file mode 100644 index 000000000..56a6a4b18 --- /dev/null +++ b/app/signals/apps/media/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MediaConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "media" diff --git a/app/signals/apps/media/storages.py b/app/signals/apps/media/storages.py new file mode 100644 index 000000000..35e51467a --- /dev/null +++ b/app/signals/apps/media/storages.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +from urllib.parse import urljoin + +from django.core import signing +from django.core.files.storage import FileSystemStorage +from django.utils.encoding import filepath_to_uri + +signer = signing.TimestampSigner() + + +class ProtectedFileSystemStorage(FileSystemStorage): + def url(self, name): + if self.base_url is None: + raise ValueError("This file is not accessible via a URL.") + + url = filepath_to_uri(name) + if url is not None: + url = url.lstrip("/") + + signature = signer.sign(url).split(':') + + full_path = urljoin(self.base_url, url) + return full_path + f'?t={signature[1]}&s={signature[2]}' diff --git a/app/signals/apps/media/tests.py b/app/signals/apps/media/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/app/signals/apps/media/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/signals/apps/media/urls.py b/app/signals/apps/media/urls.py new file mode 100644 index 000000000..eaea3de10 --- /dev/null +++ b/app/signals/apps/media/urls.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +from django.urls import re_path +from . import views + +urlpatterns = [ + re_path(r"^(?P.*)$", views.download_file, name='download_file'), +] diff --git a/app/signals/apps/media/views.py b/app/signals/apps/media/views.py new file mode 100644 index 000000000..adbbad7e9 --- /dev/null +++ b/app/signals/apps/media/views.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +from datetime import timedelta +import mimetypes +import os + +from django.conf import settings +from django.core import signing +from django.contrib.staticfiles.views import serve +from django.http import HttpResponse +from django.views.static import serve + +signer = signing.TimestampSigner() + +def download_file(request, path): + t = request.GET.get('t') + s = request.GET.get('s') + + if not t or not s: + return HttpResponse('No signature provided', status=401) + + try: + signer.unsign(f'{path}:{t}:{s}', max_age=timedelta(hours=1)) + except signing.SignatureExpired: + return HttpResponse('Signature expired', status=401) + except signing.BadSignature: + return HttpResponse('Bad signature', status=401) + + if settings.DEBUG: + response = serve(request, path, document_root=settings.MEDIA_ROOT, show_indexes=False) + else: + mimetype, encoding = mimetypes.guess_type(path) + + response = HttpResponse() + + if mimetype: + response["Content-Type"] = mimetype + if encoding: + response["Content-Encoding"] = encoding + + response["X-Sendfile"] = os.path.join( + settings.MEDIA_ROOT, path + ).encode("utf8") + + return response diff --git a/app/signals/settings.py b/app/signals/settings.py index 613a16740..8ac7bd0de 100644 --- a/app/signals/settings.py +++ b/app/signals/settings.py @@ -245,6 +245,10 @@ def is_super_user(user) -> bool: MEDIA_URL: str = '/signals/media/' MEDIA_ROOT: str = os.path.join(os.path.dirname(BASE_DIR), 'media') +PROTECTED_FILE_SYSTEM_STORAGE: bool = os.getenv('PROTECTED_FILE_SYSTEM_STORAGE', False) in TRUE_VALUES +if PROTECTED_FILE_SYSTEM_STORAGE: + DEFAULT_FILE_STORAGE: str = 'signals.apps.media.storages.ProtectedFileSystemStorage' + AZURE_STORAGE_ENABLED: bool = os.getenv('AZURE_STORAGE_ENABLED', False) in TRUE_VALUES if AZURE_STORAGE_ENABLED: # Azure Settings diff --git a/app/signals/urls.py b/app/signals/urls.py index 5c42048b6..61b9ae88c 100644 --- a/app/signals/urls.py +++ b/app/signals/urls.py @@ -19,6 +19,10 @@ path('signals/', BaseSignalsAPIRootView.as_view()), path('signals/', include('signals.apps.api.urls')), + # The media folder is routed with X-Sendfile when DEBUG=False and + # with the Django static helper when DEBUG=True + path('signals/media/', include('signals.apps.media.urls')), + # The Django admin path('signals/admin/', admin.site.urls), re_path(r'^signals/markdownx/', include('markdownx.urls')), @@ -27,12 +31,6 @@ path('signals/sigmax/', include('signals.apps.sigmax.urls')), ] -if settings.DEBUG: - from django.conf.urls.static import static - - media_root = static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - urlpatterns += media_root - if settings.OIDC_RP_CLIENT_ID: urlpatterns += [ path('signals/oidc/login_failure/', TemplateView.as_view(template_name='admin/oidc/login_failure.html')),