From 3b719ab9baea5b239d67747f16f7a49d4177de96 Mon Sep 17 00:00:00 2001 From: Florent Chehab Date: Tue, 3 Mar 2026 17:43:58 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(settings)=20disable=20file=20upload?= =?UTF-8?q?=20by=20default=20&=20max=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FILE_UPLOAD_ENABLED setting (default to False, to avoid a breaking change). Also adds a max_count_by_user sub setting to restrict the number of uploaded files per user. --- CHANGELOG.md | 1 + env.d/development/common.dist | 1 + src/backend/core/api/__init__.py | 15 +++++ src/backend/core/api/feature_flag.py | 1 + src/backend/core/api/permissions.py | 8 +++ src/backend/core/api/viewsets.py | 24 ++++++- .../core/tests/files/test_api_files_create.py | 62 +++++++++++++++++++ .../locale/de_DE/LC_MESSAGES/django.po | 18 +++--- .../locale/en_US/LC_MESSAGES/django.po | 18 +++--- .../locale/fr_FR/LC_MESSAGES/django.po | 21 ++++--- .../locale/nl_NL/LC_MESSAGES/django.po | 18 +++--- src/backend/meet/settings.py | 9 +++ 12 files changed, 166 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a40e13..c4c0bb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(helm) support ingress for custom background image #1124 - ✨(backend) add authenticated user rate throttling on request-entry #1129 - ✨(backend) expose `is_active` field for Application in Django admin #1133 +- ✨(file-upload) disable by default & limit count by user #1141 ### Changed diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 1eca95d9..f3fe3e00 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -28,6 +28,7 @@ AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ACCESS_KEY_ID=meet AWS_S3_SECRET_ACCESS_KEY=password MEDIA_BASE_URL=http://localhost:8083 +FILE_UPLOAD_ENABLED=True # OIDC OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index cee68ac7..b212c5ad 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -43,6 +43,21 @@ def get_frontend_configuration(request): "expiration_days": settings.RECORDING_EXPIRATION_DAYS, "max_duration": settings.RECORDING_MAX_DURATION, }, + "background_image": { + "upload_is_enabled": settings.FILE_UPLOAD_ENABLED, + "max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][ + "max_count_by_user" + ], + "max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][ + "max_size" + ], + "allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][ + "allowed_extensions" + ], + "allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][ + "allowed_mimetypes" + ], + }, "telephony": { "enabled": settings.ROOM_TELEPHONY_ENABLED, "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER diff --git a/src/backend/core/api/feature_flag.py b/src/backend/core/api/feature_flag.py index b137a7ea..f38865d2 100644 --- a/src/backend/core/api/feature_flag.py +++ b/src/backend/core/api/feature_flag.py @@ -13,6 +13,7 @@ class FeatureFlag: "recording": "RECORDING_ENABLE", "storage_event": "RECORDING_STORAGE_EVENT_ENABLE", "subtitle": "ROOM_SUBTITLE_ENABLED", + "file_upload": "FILE_UPLOAD_ENABLED", } @classmethod diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 63f695c2..c607d80f 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -1,5 +1,6 @@ """Permission handlers for the Meet core app.""" +from django.conf import settings from django.http import Http404 from rest_framework import permissions @@ -116,6 +117,13 @@ class FilePermission(IsAuthenticated): Handling soft deletions specificities """ + def has_permission(self, request, view): + """Allow access only to authenticated users.""" + if not settings.FILE_UPLOAD_ENABLED: + raise Http404 + + return super().has_permission(request, view) + def has_object_permission(self, request, view, obj): """ Return a 404 on deleted files or if the user is not the owner diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 09a45860..90d177b2 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -12,6 +12,7 @@ from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as django_filters from rest_framework import ( @@ -77,7 +78,6 @@ from .feature_flag import FeatureFlag logger = getLogger(__name__) - FILE_FOLDER = settings.FILE_UPLOAD_PATH UUID_REGEX = ( r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" @@ -973,6 +973,26 @@ class FileViewSet( def perform_create(self, serializer): """Set the current user as creator of the newly created file.""" + + if settings.FILE_UPLOAD_APPLY_RESTRICTIONS: + file_type = serializer.validated_data["type"] + config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type] + + count = models.File.objects.filter( + creator=self.request.user, + deleted_at__isnull=True, + type=file_type, + ).count() + + if count >= config_for_file_type["max_count_by_user"]: + logger.info( + "create_item: user reached max files per user for type %s", + file_type, + ) + raise serializers.PermissionDenied( + _("You have reached the maximum number of files for this type.") + ) + serializer.save(creator=self.request.user) def perform_destroy(self, instance): @@ -980,6 +1000,7 @@ class FileViewSet( instance.soft_delete() @decorators.action(detail=True, methods=["post"], url_path="upload-ended") + @FeatureFlag.require("file_upload") def upload_ended(self, request, *args, **kwargs): """ Check the actual uploaded file and mark it as ready. @@ -1162,6 +1183,7 @@ class FileViewSet( return url_params, request.user.id, file @decorators.action(detail=False, methods=["get"], url_path="media-auth") + @FeatureFlag.require("file_upload") def media_auth(self, request, *args, **kwargs): """ This view is used by an Nginx subrequest to control access to an file's diff --git a/src/backend/core/tests/files/test_api_files_create.py b/src/backend/core/tests/files/test_api_files_create.py index 7b9ae184..5cb055cb 100644 --- a/src/backend/core/tests/files/test_api_files_create.py +++ b/src/backend/core/tests/files/test_api_files_create.py @@ -174,6 +174,26 @@ def test_api_files_create_file_authenticated_extension_case_insensitive(): assert file.title == "file" +def test_api_files_create_file_disabled(settings): + """ + Creating a file is denied if file upload is disabled + """ + settings.FILE_UPLOAD_ENABLED = False + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "file.JPG", + }, + format="json", + ) + assert response.status_code == 404 + assert not File.objects.exists() + + def test_api_files_create_file_authenticated_not_checking_extension(settings): """ Creating a file with an extension not allowed should not fail when restrictions are disabled. @@ -239,6 +259,48 @@ def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_ assert response.json() == {"filename": ["This file extension is not allowed."]} +def test_api_files_create_file_too_many( + settings, +): + """ + Creating a file is forbidden if above user limit. + """ + settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True + settings.FILE_UPLOAD_RESTRICTIONS = { + "background_image": { + **settings.FILE_UPLOAD_RESTRICTIONS["background_image"], + "max_count_by_user": 1, + }, + } + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "1.png", + }, + ) + + assert response.status_code == 201 + + response = client.post( + "/api/v1.0/files/", + { + "type": FileTypeChoices.BACKGROUND_IMAGE, + "filename": "2.png", + }, + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You have reached the maximum number of files for this type." + } + assert File.objects.count() == 1 + + def test_api_files_create_force_id_success(): """It should be possible to force the item ID when creating a item.""" user = factories.UserFactory() diff --git a/src/backend/locale/de_DE/LC_MESSAGES/django.po b/src/backend/locale/de_DE/LC_MESSAGES/django.po index 7f4141d8..fdf3a623 100644 --- a/src/backend/locale/de_DE/LC_MESSAGES/django.po +++ b/src/backend/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-11 17:31+0000\n" +"POT-Creation-Date: 2026-03-12 13:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -76,11 +76,11 @@ msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert msgid "Skipped %(count)s recording(s) with an ineligible status." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." -#: core/admin.py:341 +#: core/admin.py:342 msgid "No scopes" msgstr "Keine Scopes" -#: core/admin.py:343 +#: core/admin.py:344 msgid "Scopes" msgstr "Scopes" @@ -98,6 +98,10 @@ msgstr "" msgid "This file extension is not allowed." msgstr "Diese Dateiendung ist nicht erlaubt." +#: core/api/serializers.py:533 +msgid "You have reached the maximum number of files for this type." +msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht." + #: core/models.py:37 msgid "Member" msgstr "Mitglied" @@ -600,18 +604,18 @@ msgstr "" " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " "an unser Support-Team unter %(support_email)s. " -#: meet/settings.py:215 +#: meet/settings.py:223 msgid "English" msgstr "Englisch" -#: meet/settings.py:216 +#: meet/settings.py:224 msgid "French" msgstr "Französisch" -#: meet/settings.py:217 +#: meet/settings.py:225 msgid "Dutch" msgstr "Niederländisch" -#: meet/settings.py:218 +#: meet/settings.py:226 msgid "German" msgstr "Deutsch" diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po index ae0e4d5b..a9f6d96f 100644 --- a/src/backend/locale/en_US/LC_MESSAGES/django.po +++ b/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-11 17:31+0000\n" +"POT-Creation-Date: 2026-03-12 13:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -76,11 +76,11 @@ msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "Skipped %(count)s recording(s) with an ineligible status." msgstr "Skipped %(count)s expired recording(s)." -#: core/admin.py:341 +#: core/admin.py:342 msgid "No scopes" msgstr "No scopes" -#: core/admin.py:343 +#: core/admin.py:344 msgid "Scopes" msgstr "Scopes" @@ -96,6 +96,10 @@ msgstr "You must be administrator or owner of a room to add accesses to it." msgid "This file extension is not allowed." msgstr "This file extension is not allowed." +#: core/api/serializers.py:533 +msgid "You have reached the maximum number of files for this type." +msgstr "You have reached the maximum number of files for this type." + #: core/models.py:37 msgid "Member" msgstr "Member" @@ -596,18 +600,18 @@ msgstr "" " If you have any questions or need assistance, please contact our support " "team at %(support_email)s. " -#: meet/settings.py:215 +#: meet/settings.py:223 msgid "English" msgstr "English" -#: meet/settings.py:216 +#: meet/settings.py:224 msgid "French" msgstr "French" -#: meet/settings.py:217 +#: meet/settings.py:225 msgid "Dutch" msgstr "Dutch" -#: meet/settings.py:218 +#: meet/settings.py:226 msgid "German" msgstr "German" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po index b4ae5035..dfb9da92 100644 --- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-11 17:31+0000\n" +"POT-Creation-Date: 2026-03-12 13:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Language-Team: LANGUAGE \n" @@ -68,7 +68,8 @@ msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt » #: core/admin.py:218 #, python-format msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." -msgstr "%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »." +msgstr "" +"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »." #: core/admin.py:226 #, fuzzy, python-format @@ -76,11 +77,11 @@ msgstr "%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’ msgid "Skipped %(count)s recording(s) with an ineligible status." msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)." -#: core/admin.py:341 +#: core/admin.py:342 msgid "No scopes" msgstr "Aucun scopes" -#: core/admin.py:343 +#: core/admin.py:344 msgid "Scopes" msgstr "Scopes" @@ -98,6 +99,10 @@ msgstr "" msgid "This file extension is not allowed." msgstr "Cette extension n'est pas autorisée" +#: core/api/serializers.py:533 +msgid "You have reached the maximum number of files for this type." +msgstr "Vous avez atteint le nombre maximum de fichiers de ce type" + #: core/models.py:37 msgid "Member" msgstr "Membre" @@ -601,18 +606,18 @@ msgstr "" " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " "équipe d'assistance à %(support_email)s. " -#: meet/settings.py:215 +#: meet/settings.py:223 msgid "English" msgstr "Anglais" -#: meet/settings.py:216 +#: meet/settings.py:224 msgid "French" msgstr "Français" -#: meet/settings.py:217 +#: meet/settings.py:225 msgid "Dutch" msgstr "Néerlandais" -#: meet/settings.py:218 +#: meet/settings.py:226 msgid "German" msgstr "Allemand" diff --git a/src/backend/locale/nl_NL/LC_MESSAGES/django.po b/src/backend/locale/nl_NL/LC_MESSAGES/django.po index d2d5b564..b7cc8136 100644 --- a/src/backend/locale/nl_NL/LC_MESSAGES/django.po +++ b/src/backend/locale/nl_NL/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-11 17:31+0000\n" +"POT-Creation-Date: 2026-03-12 13:46+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -76,11 +76,11 @@ msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'." msgid "Skipped %(count)s recording(s) with an ineligible status." msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen." -#: core/admin.py:341 +#: core/admin.py:342 msgid "No scopes" msgstr "Geen scopes" -#: core/admin.py:343 +#: core/admin.py:344 msgid "Scopes" msgstr "Scopes" @@ -97,6 +97,10 @@ msgstr "" msgid "This file extension is not allowed." msgstr "Deze bestandsextensie is niet toegestaan." +#: core/api/serializers.py:533 +msgid "You have reached the maximum number of files for this type." +msgstr "Het maximale aantal bestanden voor dit type is bereikt." + #: core/models.py:37 msgid "Member" msgstr "Lid" @@ -595,18 +599,18 @@ msgstr "" " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " "team via %(support_email)s. " -#: meet/settings.py:215 +#: meet/settings.py:223 msgid "English" msgstr "Engels" -#: meet/settings.py:216 +#: meet/settings.py:224 msgid "French" msgstr "Frans" -#: meet/settings.py:217 +#: meet/settings.py:225 msgid "Dutch" msgstr "Nederlands" -#: meet/settings.py:218 +#: meet/settings.py:226 msgid "German" msgstr "Duits" diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index fdfdfdaa..f6768ea0 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -176,6 +176,13 @@ class Base(Configuration): environ_prefix=None, ) + FILE_UPLOAD_ENABLED = values.BooleanValue( + # False to avoid a breaking change for now + default=False, + environ_name="FILE_UPLOAD_ENABLED", + environ_prefix=None, + ) + FILE_UPLOAD_PATH = values.Value( "files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None ) @@ -188,6 +195,7 @@ class Base(Configuration): { "background_image": { "max_size": 2 * MB, + "max_count_by_user": 10, "allowed_extensions": [".jpeg", ".jpg", ".png"], "allowed_mimetypes": ["image/jpeg", "image/png"], }, @@ -973,6 +981,7 @@ class Test(Base): APPLICATION_JWT_AUDIENCE = "Test inc." CELERY_TASK_ALWAYS_EAGER = True + FILE_UPLOAD_ENABLED = True def __init__(self): # pylint: disable=invalid-name