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..90ca28e58 --- /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, make sure to configure the PROTECTED_FILE_SYSTEM_STORAGE setting. + +Then specific the following uWSGI settings to protect the media folder: + +```bash +uwsgi \ + --master \ + --http=0.0.0.0:8000 \ + --module=signals.wsgi:application \ + --static-map=/signals/static=./app/static \ + --static-safe=./app/media \ + --offload-threads=2 \ + --collect-header="X-Sendfile X_SENDFILE" \ + --response-route-if-not="empty:${X_SENDFILE} static:${X_SENDFILE}" \ + --buffer-size=32768 \ + --die-on-term \ + --processes=4 \ + --threads=2 +``` + +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). diff --git a/app/signals/apps/media/__init__.py b/app/signals/apps/media/__init__.py new file mode 100644 index 000000000..140d9197d --- /dev/null +++ b/app/signals/apps/media/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. diff --git a/app/signals/apps/media/apps.py b/app/signals/apps/media/apps.py new file mode 100644 index 000000000..eba6e3cd2 --- /dev/null +++ b/app/signals/apps/media/apps.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +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..3a284e96a --- /dev/null +++ b/app/signals/apps/media/storages.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +from typing import Optional +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(salt='protected_file_system_storage') + + +class ProtectedFileSystemStorage(FileSystemStorage): + def url(self, name: Optional[str]) -> str: + if self.base_url is None: + raise ValueError('This file is not accessible via a URL.') + + if not name: + raise ValueError('Name is not defined.') + + 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..1d9b9df28 --- /dev/null +++ b/app/signals/apps/media/tests.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +from unittest.mock import patch + +from django.core import signing +from django.http import HttpResponse +from django.test import TestCase, override_settings + +from signals.apps.media.storages import ProtectedFileSystemStorage + + +@override_settings(PROTECTED_FILE_SYSTEM_STORAGE=True) +class DownloadFileTestCase(TestCase): + def setUp(self) -> None: + self.storage = ProtectedFileSystemStorage(base_url='http://localhost:8000/signals/media/') + + def test_missing_signature(self) -> None: + # Test with missing 't' or 's' parameter + response = self.client.get('/signals/media/test.txt') + self.assertEqual(response.status_code, 401) + self.assertEqual(response.content, b'No signature provided') + + def test_bad_signature(self) -> None: + # Test with an invalid signature + response = self.client.get('/signals/media/test.txt?t=some_time&s=some_signature') + self.assertEqual(response.status_code, 401) + self.assertEqual(response.content, b'Bad signature') + + def test_expired_signature(self) -> None: + # Test with an expired signature + with patch('django.core.signing.TimestampSigner.unsign') as mock_unsign: + mock_unsign.side_effect = signing.SignatureExpired + response = self.client.get('/signals/media/test.txt?t=some_time&s=some_signature') + self.assertEqual(response.status_code, 401) + self.assertEqual(response.content, b'Signature expired') + + @override_settings(DEBUG=True) + def test_debug_mode_file_serving(self) -> None: + # Test serving the file in DEBUG mode + with patch('signals.apps.media.views.serve') as mock_serve: + mock_serve.return_value = HttpResponse('File content') + response = self.client.get(self.storage.url('test.txt')) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'File content') + mock_serve.assert_called_once() + + @override_settings(DEBUG=False) + def test_production_mode_file_serving(self) -> None: + # Test serving the file in production mode + with patch('signals.apps.media.views.mimetypes.guess_type') as mock_mimetype: + mock_mimetype.return_value = 'text/plain', None + response = self.client.get(self.storage.url('test.txt')) + self.assertEqual(response.status_code, 200) + self.assertIn('test.txt', response['X-Sendfile']) + self.assertEqual(response['Content-Type'], 'text/plain') diff --git a/app/signals/apps/media/urls.py b/app/signals/apps/media/urls.py new file mode 100644 index 000000000..56870983f --- /dev/null +++ b/app/signals/apps/media/urls.py @@ -0,0 +1,9 @@ +# 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..b89cfdcbb --- /dev/null +++ b/app/signals/apps/media/views.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (C) 2024 Delta10 B.V. +import mimetypes +import os +from datetime import timedelta + +from django.conf import settings +from django.core import signing +from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase +from django.views.static import serve + +signer = signing.TimestampSigner(salt='protected_file_system_storage') + + +def download_file(request: HttpRequest, path: str) -> HttpResponseBase: + t = request.GET.get('t') + s = request.GET.get('s') + + if settings.DEBUG: + return serve(request, path, document_root=settings.MEDIA_ROOT, show_indexes=False) + + 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) + + 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 958623577..33e6888d6 100644 --- a/app/signals/settings.py +++ b/app/signals/settings.py @@ -80,7 +80,8 @@ 'signals.apps.search', 'signals.apps.dataset', 'signals.apps.questionnaires', - 'signals.apps.my_signals' + 'signals.apps.my_signals', + 'signals.apps.media', ] INSTALLED_APPS: list[str] = [ @@ -242,10 +243,16 @@ def is_super_user(user) -> bool: MEDIA_URL: str = '/signals/media/' MEDIA_ROOT: str = os.path.join(os.path.dirname(BASE_DIR), 'media') +DEFAULT_FILE_STORAGE: str = 'django.core.files.storage.FileSystemStorage' + +PROTECTED_FILE_SYSTEM_STORAGE: bool = os.getenv('PROTECTED_FILE_SYSTEM_STORAGE', False) in TRUE_VALUES +if PROTECTED_FILE_SYSTEM_STORAGE: + DEFAULT_FILE_STORAGE = 'signals.apps.media.storages.ProtectedFileSystemStorage' + AZURE_STORAGE_ENABLED: bool = os.getenv('AZURE_STORAGE_ENABLED', False) in TRUE_VALUES if AZURE_STORAGE_ENABLED: # Azure Settings - DEFAULT_FILE_STORAGE: str = 'storages.backends.azure_storage.AzureStorage' + DEFAULT_FILE_STORAGE = 'storages.backends.azure_storage.AzureStorage' AZURE_ACCOUNT_NAME: str | None = os.getenv('AZURE_STORAGE_ACCOUNT_NAME') AZURE_ACCOUNT_KEY: str | None = os.getenv('AZURE_STORAGE_ACCOUNT_KEY') 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')),