(settings) disable file upload by default & max count

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.
This commit is contained in:
Florent Chehab
2026-03-03 17:43:58 +01:00
parent 8887e811d3
commit 3b719ab9ba
12 changed files with 166 additions and 30 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to
- ✨(helm) support ingress for custom background image #1124 - ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129 - ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133 - ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
### Changed ### Changed
+1
View File
@@ -28,6 +28,7 @@ AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083 MEDIA_BASE_URL=http://localhost:8083
FILE_UPLOAD_ENABLED=True
# OIDC # OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+15
View File
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS, "expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION, "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": { "telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED, "enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
+1
View File
@@ -13,6 +13,7 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE", "recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE", "storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED", "subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
} }
@classmethod @classmethod
+8
View File
@@ -1,5 +1,6 @@
"""Permission handlers for the Meet core app.""" """Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404 from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
@@ -116,6 +117,13 @@ class FilePermission(IsAuthenticated):
Handling soft deletions specificities 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): def has_object_permission(self, request, view, obj):
""" """
Return a 404 on deleted files or if the user is not the owner Return a 404 on deleted files or if the user is not the owner
+23 -1
View File
@@ -12,6 +12,7 @@ from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as django_filters from django_filters import rest_framework as django_filters
from rest_framework import ( from rest_framework import (
@@ -77,7 +78,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__) logger = getLogger(__name__)
FILE_FOLDER = settings.FILE_UPLOAD_PATH FILE_FOLDER = settings.FILE_UPLOAD_PATH
UUID_REGEX = ( 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}" 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): def perform_create(self, serializer):
"""Set the current user as creator of the newly created file.""" """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) serializer.save(creator=self.request.user)
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -980,6 +1000,7 @@ class FileViewSet(
instance.soft_delete() instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended") @decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs): def upload_ended(self, request, *args, **kwargs):
""" """
Check the actual uploaded file and mark it as ready. Check the actual uploaded file and mark it as ready.
@@ -1162,6 +1183,7 @@ class FileViewSet(
return url_params, request.user.id, file return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth") @decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs): def media_auth(self, request, *args, **kwargs):
""" """
This view is used by an Nginx subrequest to control access to an file's This view is used by an Nginx subrequest to control access to an file's
@@ -174,6 +174,26 @@ def test_api_files_create_file_authenticated_extension_case_insensitive():
assert file.title == "file" 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): 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. 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."]} 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(): def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item.""" """It should be possible to force the item ID when creating a item."""
user = factories.UserFactory() user = factories.UserFactory()
+11 -7
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\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." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:341 #: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Keine Scopes" msgstr "Keine Scopes"
#: core/admin.py:343 #: core/admin.py:344
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -98,6 +98,10 @@ msgstr ""
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt." 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 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
@@ -600,18 +604,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. " "an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:215 #: meet/settings.py:223
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:216 #: meet/settings.py:224
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:217 #: meet/settings.py:225
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:218 #: meet/settings.py:226
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
+11 -7
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\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." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:341 #: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "No scopes" msgstr "No scopes"
#: core/admin.py:343 #: core/admin.py:344
msgid "Scopes" msgid "Scopes"
msgstr "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." msgid "This file extension is not allowed."
msgstr "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 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Member" msgstr "Member"
@@ -596,18 +600,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
#: meet/settings.py:215 #: meet/settings.py:223
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:216 #: meet/settings.py:224
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:217 #: meet/settings.py:225
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:218 #: meet/settings.py:226
msgid "German" msgid "German"
msgstr "German" msgstr "German"
+13 -8
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,7 +68,8 @@ msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »
#: core/admin.py:218 #: core/admin.py:218
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »." msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226 #: core/admin.py:226
#, fuzzy, python-format #, 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." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)." msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:341 #: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Aucun scopes" msgstr "Aucun scopes"
#: core/admin.py:343 #: core/admin.py:344
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -98,6 +99,10 @@ msgstr ""
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée" 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 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Membre" msgstr "Membre"
@@ -601,18 +606,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. " "équipe d'assistance à %(support_email)s. "
#: meet/settings.py:215 #: meet/settings.py:223
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:216 #: meet/settings.py:224
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:217 #: meet/settings.py:225
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:218 #: meet/settings.py:226
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
+11 -7
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\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." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen." msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:341 #: core/admin.py:342
msgid "No scopes" msgid "No scopes"
msgstr "Geen scopes" msgstr "Geen scopes"
#: core/admin.py:343 #: core/admin.py:344
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -97,6 +97,10 @@ msgstr ""
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan." 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 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Lid" msgstr "Lid"
@@ -595,18 +599,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. " "team via %(support_email)s. "
#: meet/settings.py:215 #: meet/settings.py:223
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:216 #: meet/settings.py:224
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:217 #: meet/settings.py:225
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:218 #: meet/settings.py:226
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
+9
View File
@@ -176,6 +176,13 @@ class Base(Configuration):
environ_prefix=None, 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( FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None "files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
) )
@@ -188,6 +195,7 @@ class Base(Configuration):
{ {
"background_image": { "background_image": {
"max_size": 2 * MB, "max_size": 2 * MB,
"max_count_by_user": 10,
"allowed_extensions": [".jpeg", ".jpg", ".png"], "allowed_extensions": [".jpeg", ".jpg", ".png"],
"allowed_mimetypes": ["image/jpeg", "image/png"], "allowed_mimetypes": ["image/jpeg", "image/png"],
}, },
@@ -973,6 +981,7 @@ class Test(Base):
APPLICATION_JWT_AUDIENCE = "Test inc." APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
FILE_UPLOAD_ENABLED = True
def __init__(self): def __init__(self):
# pylint: disable=invalid-name # pylint: disable=invalid-name