From 311dbac14e04bd3fb9d1c2bc8c8fcf7d154f93cc Mon Sep 17 00:00:00 2001 From: Tuomas Virtanen Date: Tue, 10 Mar 2026 18:38:23 +0200 Subject: [PATCH 1/3] backend, admin: Add configurable image file size limit to compos --- admin/src/api/sdk.gen.ts | 5 +- admin/src/api/types.gen.ts | 33 ++++++----- admin/src/locales/en.json | 1 + admin/src/locales/fi.json | 1 + admin/src/views/kompomaatti/CompoEditView.vue | 17 +++++- .../admin/kompomaatti/compo_serializer.py | 3 + .../0026_add_imagefile_sizelimit.py | 18 ++++++ backend/Instanssi/kompomaatti/models.py | 3 +- backend/openapi.yaml | 46 +++++++++------ .../compo_entries/test_file_validation.py | 58 +++++++++++++++++++ .../v2/admin/kompomaatti/compos/test_staff.py | 6 ++ .../test_file_validation.py | 56 ++++++++++++++++++ 12 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 backend/Instanssi/kompomaatti/migrations/0026_add_imagefile_sizelimit.py diff --git a/admin/src/api/sdk.gen.ts b/admin/src/api/sdk.gen.ts index 06a40071a..10f3fc57d 100644 --- a/admin/src/api/sdk.gen.ts +++ b/admin/src/api/sdk.gen.ts @@ -2194,7 +2194,10 @@ export const adminEventKompomaattiLiveVotingRevealAllCreate = < }; /** - * Staff viewset for managing live voting state during a compo presentation. + * Reveal a single entry. Only the next unrevealed entry (by order_index) can be revealed. + * + * Note: Entry.objects.filter().update() is used instead of entry.save() throughout + * this viewset to avoid triggering Entry.save()'s generate_alternates() side effect. */ export const adminEventKompomaattiLiveVotingRevealEntryCreate = < ThrowOnError extends boolean = false, diff --git a/admin/src/api/types.gen.ts b/admin/src/api/types.gen.ts index 80058abe3..5ae375587 100644 --- a/admin/src/api/types.gen.ts +++ b/admin/src/api/types.gen.ts @@ -245,6 +245,10 @@ export type Compo = { * Source size limit */ source_sizelimit?: number; + /** + * Image file size limit + */ + imagefile_sizelimit?: number; /** * Allowed file extensions */ @@ -259,6 +263,7 @@ export type Compo = { image_formats?: string; readonly max_source_size: number; readonly max_entry_size: number; + readonly max_image_size: number; readonly source_format_list: Array; readonly entry_format_list: Array; readonly image_format_list: Array; @@ -316,10 +321,6 @@ export type CompoEntry = { * Revealed in live voting */ live_voting_revealed?: boolean; - /** - * Live voting reveal order - */ - live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -372,10 +373,6 @@ export type CompoEntryRequest = { * Revealed in live voting */ live_voting_revealed?: boolean; - /** - * Live voting reveal order - */ - live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -421,6 +418,10 @@ export type CompoRequest = { * Source size limit */ source_sizelimit?: number; + /** + * Image file size limit + */ + imagefile_sizelimit?: number; /** * Allowed file extensions */ @@ -1573,10 +1574,6 @@ export type PatchedCompoEntryRequest = { * Revealed in live voting */ live_voting_revealed?: boolean; - /** - * Live voting reveal order - */ - live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason @@ -1622,6 +1619,10 @@ export type PatchedCompoRequest = { * Source size limit */ source_sizelimit?: number; + /** + * Image file size limit + */ + imagefile_sizelimit?: number; /** * Allowed file extensions */ @@ -3368,6 +3369,10 @@ export type CompoWritable = { * Source size limit */ source_sizelimit?: number; + /** + * Image file size limit + */ + imagefile_sizelimit?: number; /** * Allowed file extensions */ @@ -3428,10 +3433,6 @@ export type CompoEntryWritable = { * Revealed in live voting */ live_voting_revealed?: boolean; - /** - * Live voting reveal order - */ - live_voting_order?: number; disqualified?: boolean; /** * Disqualification reason diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index 3e6b65149..ed72bed1d 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -266,6 +266,7 @@ "votingEnd": "Voting end", "entrySizelimit": "Max entry size (bytes)", "sourceSizelimit": "Max source size (bytes)", + "imageSizelimit": "Max image size (bytes)", "formats": "Entry formats (e.g. png|jpg)", "sourceFormats": "Source formats", "imageFormats": "Image formats", diff --git a/admin/src/locales/fi.json b/admin/src/locales/fi.json index f07647986..bbb7b1d8f 100644 --- a/admin/src/locales/fi.json +++ b/admin/src/locales/fi.json @@ -266,6 +266,7 @@ "votingEnd": "Äänestyksen päättymisaika", "entrySizelimit": "Tuotoksen enimmäiskoko (tavua)", "sourceSizelimit": "Lähdekoodin enimmäiskoko (tavua)", + "imageSizelimit": "Kuvatiedoston enimmäiskoko (tavua)", "formats": "Tuotosmuodot (esim. png|jpg)", "sourceFormats": "Lähdekoodimuodot", "imageFormats": "Kuvamuodot", diff --git a/admin/src/views/kompomaatti/CompoEditView.vue b/admin/src/views/kompomaatti/CompoEditView.vue index 463604a6d..873eadc1f 100644 --- a/admin/src/views/kompomaatti/CompoEditView.vue +++ b/admin/src/views/kompomaatti/CompoEditView.vue @@ -69,20 +69,27 @@ {{ t("CompoEditView.sections.fileSettings") }} - + - + + + + @@ -255,6 +262,7 @@ const API_FIELD_MAPPING: FieldMapping = { voting_end: "votingEnd", entry_sizelimit: "entrySizelimit", source_sizelimit: "sourceSizelimit", + imagefile_sizelimit: "imageSizelimit", formats: "formats", source_formats: "sourceFormats", image_formats: "imageFormats", @@ -358,6 +366,7 @@ const validationSchema = yupObject({ votingEnd: yupString().required(), entrySizelimit: yupNumber().nullable(), sourceSizelimit: yupNumber().nullable(), + imageSizelimit: yupNumber().nullable(), formats: yupString(), sourceFormats: yupString(), imageFormats: yupString(), @@ -380,6 +389,7 @@ const { handleSubmit, setValues, setErrors, meta } = useForm({ votingEnd: "", entrySizelimit: null as number | null, sourceSizelimit: null as number | null, + imageSizelimit: null as number | null, formats: "", sourceFormats: "", imageFormats: "", @@ -400,6 +410,7 @@ const compoStart = useField("compoStart"); const votingEnd = useField("votingEnd"); const entrySizelimit = useField("entrySizelimit"); const sourceSizelimit = useField("sourceSizelimit"); +const imageSizelimit = useField("imageSizelimit"); const formats = useField("formats"); const sourceFormats = useField("sourceFormats"); const imageFormats = useField("imageFormats"); @@ -448,6 +459,7 @@ function buildBody(values: GenericObject) { voting_end: toISODatetime(values.votingEnd)!, entry_sizelimit: values.entrySizelimit, source_sizelimit: values.sourceSizelimit, + imagefile_sizelimit: values.imageSizelimit, formats: values.formats || "", source_formats: values.sourceFormats || "", image_formats: values.imageFormats || "", @@ -510,6 +522,7 @@ onMounted(async () => { votingEnd: toLocalDatetime(item.voting_end), entrySizelimit: item.entry_sizelimit ?? null, sourceSizelimit: item.source_sizelimit ?? null, + imageSizelimit: item.imagefile_sizelimit ?? null, formats: item.formats ?? "", sourceFormats: item.source_formats ?? "", imageFormats: item.image_formats ?? "", diff --git a/backend/Instanssi/api/v2/serializers/admin/kompomaatti/compo_serializer.py b/backend/Instanssi/api/v2/serializers/admin/kompomaatti/compo_serializer.py index 04a66315d..bfed3a9e2 100644 --- a/backend/Instanssi/api/v2/serializers/admin/kompomaatti/compo_serializer.py +++ b/backend/Instanssi/api/v2/serializers/admin/kompomaatti/compo_serializer.py @@ -9,6 +9,7 @@ class CompoSerializer(ModelSerializer[Compo]): max_entry_size = IntegerField(read_only=True) max_source_size = IntegerField(read_only=True) + max_image_size = IntegerField(read_only=True) source_format_list = ListField(child=CharField(), read_only=True) entry_format_list = ListField(child=CharField(), read_only=True) image_format_list = ListField(child=CharField(), read_only=True) @@ -26,11 +27,13 @@ class Meta: "voting_end", "entry_sizelimit", "source_sizelimit", + "imagefile_sizelimit", "formats", "source_formats", "image_formats", "max_source_size", "max_entry_size", + "max_image_size", "source_format_list", "entry_format_list", "image_format_list", diff --git a/backend/Instanssi/kompomaatti/migrations/0026_add_imagefile_sizelimit.py b/backend/Instanssi/kompomaatti/migrations/0026_add_imagefile_sizelimit.py new file mode 100644 index 000000000..4b6b3e45e --- /dev/null +++ b/backend/Instanssi/kompomaatti/migrations/0026_add_imagefile_sizelimit.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-03-10 11:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kompomaatti", "0025_live_voting"), + ] + + operations = [ + migrations.AddField( + model_name="compo", + name="imagefile_sizelimit", + field=models.IntegerField(default=6291456, verbose_name="Image file size limit"), + ), + ] diff --git a/backend/Instanssi/kompomaatti/models.py b/backend/Instanssi/kompomaatti/models.py index 52174aa46..edf24418b 100644 --- a/backend/Instanssi/kompomaatti/models.py +++ b/backend/Instanssi/kompomaatti/models.py @@ -139,6 +139,7 @@ class Compo(models.Model): voting_end = models.DateTimeField(_("Voting end time")) entry_sizelimit = models.IntegerField(_("Entry size limit"), default=134217728) # Default to 128M source_sizelimit = models.IntegerField(_("Source size limit"), default=134217728) # Default to 128M + imagefile_sizelimit = models.IntegerField(_("Image file size limit"), default=6291456) # Default to 6M formats = models.CharField( _("Allowed file extensions"), max_length=128, @@ -222,7 +223,7 @@ def max_entry_size(self) -> int: @property def max_image_size(self) -> int: - return self.MAX_IMAGE_SIZE + return self.imagefile_sizelimit @property def readable_max_source_size(self) -> str: diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 0a5e049e8..7ea71ff6f 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -2059,7 +2059,11 @@ paths: /api/v2/admin/event/{event_pk}/kompomaatti/live_voting/{id}/reveal_entry/: post: operationId: admin_event_kompomaatti_live_voting_reveal_entry_create - description: Staff viewset for managing live voting state during a compo presentation. + description: |- + Reveal a single entry. Only the next unrevealed entry (by order_index) can be revealed. + + Note: Entry.objects.filter().update() is used instead of entry.save() throughout + this viewset to avoid triggering Entry.save()'s generate_alternates() side effect. parameters: - in: path name: event_pk @@ -7018,6 +7022,12 @@ components: minimum: -9223372036854775808 format: int64 title: Source size limit + imagefile_sizelimit: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + title: Image file size limit formats: type: string title: Allowed file extensions @@ -7036,6 +7046,9 @@ components: max_entry_size: type: integer readOnly: true + max_image_size: + type: integer + readOnly: true source_format_list: type: array items: @@ -7083,6 +7096,7 @@ components: - id - image_format_list - max_entry_size + - max_image_size - max_source_size - name - source_format_list @@ -7155,12 +7169,6 @@ components: live_voting_revealed: type: boolean title: Revealed in live voting - live_voting_order: - type: integer - maximum: 9223372036854775807 - minimum: -9223372036854775808 - format: int64 - title: Live voting reveal order disqualified: type: boolean disqualified_reason: @@ -7258,12 +7266,6 @@ components: live_voting_revealed: type: boolean title: Revealed in live voting - live_voting_order: - type: integer - maximum: 9223372036854775807 - minimum: -9223372036854775808 - format: int64 - title: Live voting reveal order disqualified: type: boolean disqualified_reason: @@ -7327,6 +7329,12 @@ components: minimum: -9223372036854775808 format: int64 title: Source size limit + imagefile_sizelimit: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + title: Image file size limit formats: type: string minLength: 1 @@ -9357,12 +9365,6 @@ components: live_voting_revealed: type: boolean title: Revealed in live voting - live_voting_order: - type: integer - maximum: 9223372036854775807 - minimum: -9223372036854775808 - format: int64 - title: Live voting reveal order disqualified: type: boolean disqualified_reason: @@ -9419,6 +9421,12 @@ components: minimum: -9223372036854775808 format: int64 title: Source size limit + imagefile_sizelimit: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + title: Image file size limit formats: type: string minLength: 1 diff --git a/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py b/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py index ad997d31b..83df58c68 100644 --- a/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py +++ b/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py @@ -117,6 +117,64 @@ def test_staff_entry_file_too_large_rejected(staff_api_client, open_compo, base_ assert "maximum allowed file size" in str(req.data["entryfile"]).lower() +@pytest.mark.django_db +def test_staff_source_file_too_large_rejected(staff_api_client, open_compo, base_user, entry_zip, image_png): + """Test that source files exceeding size limit are rejected""" + open_compo.source_sizelimit = 10 # 10 bytes + open_compo.save() + + large_file = SimpleUploadedFile("source.zip", b"x" * 100, content_type="application/zip") + base_url = get_base_url(open_compo.event_id) + req = staff_api_client.post( + base_url, + format="multipart", + data={ + "user": base_user.id, + "compo": open_compo.id, + "name": "Test Entry", + "description": "Should fail validation", + "creator": "Test Creator", + "platform": "Linux", + "entryfile": entry_zip, + "imagefile_original": image_png, + "sourcefile": large_file, + }, + ) + assert req.status_code == 400 + assert "sourcefile" in req.data + assert "maximum allowed file size" in str(req.data["sourcefile"]).lower() + + +@pytest.mark.django_db +def test_staff_image_file_too_large_rejected( + staff_api_client, open_compo, base_user, entry_zip, source_zip, test_image +): + """Test that image files exceeding size limit are rejected""" + open_compo.imagefile_sizelimit = 10 # 10 bytes + open_compo.save() + + large_image = SimpleUploadedFile("image.png", test_image, content_type="image/png") + base_url = get_base_url(open_compo.event_id) + req = staff_api_client.post( + base_url, + format="multipart", + data={ + "user": base_user.id, + "compo": open_compo.id, + "name": "Test Entry", + "description": "Should fail validation", + "creator": "Test Creator", + "platform": "Linux", + "entryfile": entry_zip, + "imagefile_original": large_image, + "sourcefile": source_zip, + }, + ) + assert req.status_code == 400 + assert "imagefile_original" in req.data + assert "maximum allowed file size" in str(req.data["imagefile_original"]).lower() + + @pytest.mark.django_db def test_staff_image_required_but_missing_rejected( staff_api_client, open_compo, base_user, entry_zip, source_zip diff --git a/backend/tests/api/v2/admin/kompomaatti/compos/test_staff.py b/backend/tests/api/v2/admin/kompomaatti/compos/test_staff.py index afa647487..d42ae9d43 100644 --- a/backend/tests/api/v2/admin/kompomaatti/compos/test_staff.py +++ b/backend/tests/api/v2/admin/kompomaatti/compos/test_staff.py @@ -35,11 +35,13 @@ def test_staff_can_get_compo_detail(staff_api_client, open_compo): "voting_end": open_compo.voting_end.astimezone(settings.ZONE_INFO).isoformat(), "entry_sizelimit": open_compo.entry_sizelimit, "source_sizelimit": open_compo.source_sizelimit, + "imagefile_sizelimit": open_compo.imagefile_sizelimit, "formats": open_compo.formats, "source_formats": open_compo.source_formats, "image_formats": open_compo.image_formats, "max_source_size": open_compo.max_source_size, "max_entry_size": open_compo.max_entry_size, + "max_image_size": open_compo.max_image_size, "source_format_list": open_compo.source_format_list, "entry_format_list": open_compo.entry_format_list, "image_format_list": open_compo.image_format_list, @@ -84,11 +86,13 @@ def test_staff_can_create_compo(staff_api_client, event): "voting_end": voting_end.astimezone(settings.ZONE_INFO).isoformat(), "entry_sizelimit": 134217728, "source_sizelimit": 134217728, + "imagefile_sizelimit": 6291456, "formats": "zip|7z|gz|bz2", "source_formats": "zip|7z|gz|bz2", "image_formats": "png|jpg", "max_source_size": 134217728, "max_entry_size": 134217728, + "max_image_size": 6291456, "source_format_list": ["zip", "7z", "gz", "bz2"], "entry_format_list": ["zip", "7z", "gz", "bz2"], "image_format_list": ["png", "jpg"], @@ -118,11 +122,13 @@ def test_staff_can_update_compo(staff_api_client, open_compo): "voting_end": open_compo.voting_end.astimezone(settings.ZONE_INFO).isoformat(), "entry_sizelimit": open_compo.entry_sizelimit, "source_sizelimit": open_compo.source_sizelimit, + "imagefile_sizelimit": open_compo.imagefile_sizelimit, "formats": open_compo.formats, "source_formats": open_compo.source_formats, "image_formats": open_compo.image_formats, "max_source_size": open_compo.max_source_size, "max_entry_size": open_compo.max_entry_size, + "max_image_size": open_compo.max_image_size, "source_format_list": open_compo.source_format_list, "entry_format_list": open_compo.entry_format_list, "image_format_list": open_compo.image_format_list, diff --git a/backend/tests/api/v2/user/kompomaatti/user_compo_entries/test_file_validation.py b/backend/tests/api/v2/user/kompomaatti/user_compo_entries/test_file_validation.py index f2ad6453c..83d640100 100644 --- a/backend/tests/api/v2/user/kompomaatti/user_compo_entries/test_file_validation.py +++ b/backend/tests/api/v2/user/kompomaatti/user_compo_entries/test_file_validation.py @@ -210,6 +210,62 @@ def test_user_entry_file_too_large_rejected(auth_client, open_compo, source_zip, assert "maximum allowed file size" in str(req.data["entryfile"]).lower() +@pytest.mark.django_db +@freeze_time(FROZEN_TIME) +def test_user_source_file_too_large_rejected(auth_client, open_compo, entry_zip, image_png): + """Test that source files exceeding size limit are rejected""" + open_compo.source_sizelimit = 10 # 10 bytes + open_compo.save() + + large_file = SimpleUploadedFile("source.zip", b"x" * 100, content_type="application/zip") + base_url = get_base_url(open_compo.event_id) + req = auth_client.post( + base_url, + format="multipart", + data={ + "compo": open_compo.id, + "name": "Test Entry", + "description": "Should fail validation", + "creator": "Test Creator", + "platform": "Linux", + "entryfile": entry_zip, + "imagefile_original": image_png, + "sourcefile": large_file, + }, + ) + assert req.status_code == 400 + assert "sourcefile" in req.data + assert "maximum allowed file size" in str(req.data["sourcefile"]).lower() + + +@pytest.mark.django_db +@freeze_time(FROZEN_TIME) +def test_user_image_file_too_large_rejected(auth_client, open_compo, entry_zip, source_zip, test_image): + """Test that image files exceeding size limit are rejected""" + open_compo.imagefile_sizelimit = 10 # 10 bytes + open_compo.save() + + large_image = SimpleUploadedFile("image.png", test_image, content_type="image/png") + base_url = get_base_url(open_compo.event_id) + req = auth_client.post( + base_url, + format="multipart", + data={ + "compo": open_compo.id, + "name": "Test Entry", + "description": "Should fail validation", + "creator": "Test Creator", + "platform": "Linux", + "entryfile": entry_zip, + "imagefile_original": large_image, + "sourcefile": source_zip, + }, + ) + assert req.status_code == 400 + assert "imagefile_original" in req.data + assert "maximum allowed file size" in str(req.data["imagefile_original"]).lower() + + @pytest.mark.django_db @freeze_time(FROZEN_TIME) def test_user_image_required_but_missing_rejected(auth_client, open_compo, entry_zip, source_zip): From 2172156932abcbab976a1e10169bd050dcaf13d0 Mon Sep 17 00:00:00 2001 From: Tuomas Virtanen Date: Tue, 10 Mar 2026 20:46:29 +0200 Subject: [PATCH 2/3] backend, admin: Fix multipart/form-data content type for admin entry uploads --- admin/src/api/sdk.gen.ts | 12 ++++++++---- .../v2/viewsets/admin/kompomaatti/compo_entries.py | 4 ++-- backend/openapi.yaml | 12 ------------ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/admin/src/api/sdk.gen.ts b/admin/src/api/sdk.gen.ts index 10f3fc57d..abb06a06d 100644 --- a/admin/src/api/sdk.gen.ts +++ b/admin/src/api/sdk.gen.ts @@ -1763,6 +1763,7 @@ export const adminEventKompomaattiEntriesCreate = ({ + ...formDataBodySerializer, responseType: "json", security: [ { @@ -1778,7 +1779,7 @@ export const adminEventKompomaattiEntriesCreate = ({ + ...formDataBodySerializer, responseType: "json", security: [ { @@ -1874,7 +1876,7 @@ export const adminEventKompomaattiEntriesPartialUpdate = ({ + ...formDataBodySerializer, responseType: "json", security: [ { @@ -1909,7 +1912,7 @@ export const adminEventKompomaattiEntriesUpdate = ({ + ...formDataBodySerializer, responseType: "json", security: [ { @@ -1975,7 +1979,7 @@ export const adminEventKompomaattiEntriesReorderCreate = None: # type: ig "All entries in the compo must be included exactly once." ), ) - @action(detail=False, methods=["post"], url_path="reorder") + @action(detail=False, methods=["post"], url_path="reorder", parser_classes=[JSONParser]) def reorder(self, request: Request, event_pk: int = 0) -> Response: """Bulk reorder entries within a compo.""" compo_id = request.data.get("compo") diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 7ea71ff6f..266b35a9a 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1589,9 +1589,6 @@ paths: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/CompoEntryRequest' - application/json: - schema: - $ref: '#/components/schemas/CompoEntryRequest' required: true security: - knoxApiToken: [] @@ -1664,9 +1661,6 @@ paths: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/CompoEntryRequest' - application/json: - schema: - $ref: '#/components/schemas/CompoEntryRequest' required: true security: - knoxApiToken: [] @@ -1707,9 +1701,6 @@ paths: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/PatchedCompoEntryRequest' - application/json: - schema: - $ref: '#/components/schemas/PatchedCompoEntryRequest' security: - knoxApiToken: [] - cookieAuth: [] @@ -1808,9 +1799,6 @@ paths: application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/ReorderEntriesRequestRequest' - application/json: - schema: - $ref: '#/components/schemas/ReorderEntriesRequestRequest' required: true security: - knoxApiToken: [] From 34d80531f7fd558756d309e4ba0ac87919697840 Mon Sep 17 00:00:00 2001 From: Tuomas Virtanen Date: Tue, 10 Mar 2026 20:46:47 +0200 Subject: [PATCH 3/3] backend, admin: Add option to override file size limits in admin entry form --- admin/src/locales/en.json | 3 +- admin/src/locales/fi.json | 3 +- admin/src/views/kompomaatti/EntryEditView.vue | 14 ++++++-- .../api/v2/utils/entry_file_validation.py | 14 ++++++-- .../admin/kompomaatti/compo_entries.py | 12 +++++-- .../compo_entries/test_file_validation.py | 33 +++++++++++++++++++ 6 files changed, 70 insertions(+), 9 deletions(-) diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index ed72bed1d..5c3395336 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -426,7 +426,8 @@ "youtubeUrl": "YouTube URL", "disqualifiedOn": "Entry is disqualified", "disqualifiedOff": "Entry is not disqualified", - "disqualifiedReason": "Reason" + "disqualifiedReason": "Reason", + "skipSizeCheck": "Override file size limits" }, "noAlternateFiles": "No alternate files available." }, diff --git a/admin/src/locales/fi.json b/admin/src/locales/fi.json index bbb7b1d8f..ef7bdffe5 100644 --- a/admin/src/locales/fi.json +++ b/admin/src/locales/fi.json @@ -426,7 +426,8 @@ "youtubeUrl": "YouTube-URL", "disqualifiedOn": "Tuotos on hylätty", "disqualifiedOff": "Tuotosta ei ole hylätty", - "disqualifiedReason": "Syy" + "disqualifiedReason": "Syy", + "skipSizeCheck": "Ohita tiedostojen kokorajat" }, "noAlternateFiles": "Vaihtoehtoisia tiedostoja ei ole saatavilla." }, diff --git a/admin/src/views/kompomaatti/EntryEditView.vue b/admin/src/views/kompomaatti/EntryEditView.vue index 29485e6dd..3e8373cb2 100644 --- a/admin/src/views/kompomaatti/EntryEditView.vue +++ b/admin/src/views/kompomaatti/EntryEditView.vue @@ -68,6 +68,13 @@ {{ t("EntryEditView.sections.files") }} + (""); const eventId = computed(() => parseInt(props.eventId, 10)); const isEditMode = computed(() => props.id !== undefined); @@ -438,7 +446,8 @@ async function createItem(values: GenericObject) { // Type assertion needed: our bodySerializer handles null for file clearing body: body as api.CompoEntryRequest, bodySerializer: () => toFormData(body), - }); + ...(skipSizeCheck.value && { query: { skip_size_check: "true" } }), + } as Parameters[0]); toast.success(t("EntryEditView.createSuccess")); return true; } catch (e) { @@ -455,7 +464,8 @@ async function editItem(itemId: number, values: GenericObject) { // Type assertion needed: our bodySerializer handles null for file clearing body: body as api.PatchedCompoEntryRequest, bodySerializer: () => toFormData(body), - }); + ...(skipSizeCheck.value && { query: { skip_size_check: "true" } }), + } as Parameters[0]); toast.success(t("EntryEditView.editSuccess")); return true; } catch (e) { diff --git a/backend/Instanssi/api/v2/utils/entry_file_validation.py b/backend/Instanssi/api/v2/utils/entry_file_validation.py index a52e95b03..deff5fc3a 100644 --- a/backend/Instanssi/api/v2/utils/entry_file_validation.py +++ b/backend/Instanssi/api/v2/utils/entry_file_validation.py @@ -9,7 +9,13 @@ from Instanssi.kompomaatti.models import Compo, Entry -def validate_entry_files(data: dict[str, Any], compo: Compo, instance: Entry | None = None) -> None: +def validate_entry_files( + data: dict[str, Any], + compo: Compo, + instance: Entry | None = None, + *, + skip_size_check: bool = False, +) -> None: """Validate entry files against compo settings. Checks file format (extension), file size, and image file requirements. @@ -51,7 +57,7 @@ def validate_entry_files(data: dict[str, Any], compo: Compo, instance: Entry | N errors = {} for key, args in check_files_on.items(): if file := data.get(key): - field_errors = _validate_file(file, *args) + field_errors = _validate_file(file, *args, skip_size_check=skip_size_check) if field_errors: errors[key] = field_errors @@ -72,12 +78,14 @@ def _validate_file( accept_formats_readable: str, max_size: int, max_readable_size: str, + *, + skip_size_check: bool = False, ) -> list[str]: """Validate file size and format, returning list of error messages.""" errors: list[str] = [] # Check file size - if file.size is not None and file.size > max_size: + if not skip_size_check and file.size is not None and file.size > max_size: errors.append(_("Maximum allowed file size is %(size)s") % {"size": max_readable_size}) # Check file extension — use all suffixes so that e.g. ".tar.gz" is diff --git a/backend/Instanssi/api/v2/viewsets/admin/kompomaatti/compo_entries.py b/backend/Instanssi/api/v2/viewsets/admin/kompomaatti/compo_entries.py index 78958719d..549967560 100644 --- a/backend/Instanssi/api/v2/viewsets/admin/kompomaatti/compo_entries.py +++ b/backend/Instanssi/api/v2/viewsets/admin/kompomaatti/compo_entries.py @@ -70,10 +70,13 @@ def _refresh_with_annotations(self, serializer: BaseSerializer[Entry]) -> None: assert serializer.instance is not None serializer.instance = self.get_queryset().get(pk=serializer.instance.pk) + def _skip_size_check(self) -> bool: + return self.request.query_params.get("skip_size_check") == "true" + def perform_create(self, serializer: BaseSerializer[Entry]) -> None: # type: ignore[override] if compo := serializer.validated_data.get("compo"): self.validate_compo_belongs_to_event(compo) - validate_entry_files(serializer.validated_data, compo) + validate_entry_files(serializer.validated_data, compo, skip_size_check=self._skip_size_check()) instance = serializer.save() maybe_copy_entry_to_image(instance) self._refresh_with_annotations(serializer) @@ -83,7 +86,12 @@ def perform_update(self, serializer: BaseSerializer[Entry]) -> None: # type: ig if new_compo := serializer.validated_data.get("compo"): if new_compo.id != serializer.instance.compo_id: raise serializers.ValidationError({"compo": [_("Cannot change compo after creation")]}) - validate_entry_files(serializer.validated_data, serializer.instance.compo, serializer.instance) + validate_entry_files( + serializer.validated_data, + serializer.instance.compo, + serializer.instance, + skip_size_check=self._skip_size_check(), + ) instance = serializer.save() maybe_copy_entry_to_image(instance) self._refresh_with_annotations(serializer) diff --git a/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py b/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py index 83df58c68..267fc77ea 100644 --- a/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py +++ b/backend/tests/api/v2/admin/kompomaatti/compo_entries/test_file_validation.py @@ -323,3 +323,36 @@ def test_staff_can_upload_to_closed_compo( }, ) assert req.status_code == 201 + + +@pytest.mark.django_db +def test_staff_skip_size_check_allows_oversized_files( + staff_api_client, open_compo, base_user, source_zip, test_image +): + """Test that staff can bypass size limits with skip_size_check query parameter""" + open_compo.entry_sizelimit = 10 + open_compo.source_sizelimit = 10 + open_compo.imagefile_sizelimit = 10 + open_compo.save() + + large_entry = SimpleUploadedFile("entry.zip", b"x" * 100, content_type="application/zip") + large_source = SimpleUploadedFile("source.zip", b"x" * 100, content_type="application/zip") + large_image = SimpleUploadedFile("image.png", test_image, content_type="image/png") + + base_url = get_base_url(open_compo.event_id) + req = staff_api_client.post( + f"{base_url}?skip_size_check=true", + format="multipart", + data={ + "user": base_user.id, + "compo": open_compo.id, + "name": "Oversized Entry", + "description": "Should pass with size check skipped", + "creator": "Test Creator", + "platform": "Linux", + "entryfile": large_entry, + "imagefile_original": large_image, + "sourcefile": large_source, + }, + ) + assert req.status_code == 201