From a4d268dac8fed78c5123481822f09b6a76a2025b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 14:59:01 +0200 Subject: [PATCH 01/11] 7259: Made upload sizes configurable --- README.md | 26 +++++++ .../slide/content/file-dropzone.jsx | 16 +++-- .../slide/content/file-selector.jsx | 23 ++++-- .../components/util/admin-config-loader.js | 1 + .../tests/admin/admin-config-loader.test.js | 59 +++++++++++++++ assets/tests/admin/data-fixtures.js | 1 + assets/tests/admin/file-dropzone.test.jsx | 71 +++++++++++++++++++ config/services.yaml | 1 + infrastructure/nginx/Dockerfile | 2 +- .../Admin/AdminConfigController.php | 2 + src/Entity/Tenant/Media.php | 15 +++- tests/Api/MediaTest.php | 42 +++++++++++ .../Admin/AdminConfigControllerTest.php | 44 ++++++++++++ 13 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 assets/tests/admin/admin-config-loader.test.js create mode 100644 assets/tests/admin/file-dropzone.test.jsx create mode 100644 tests/Controller/Admin/AdminConfigControllerTest.php diff --git a/README.md b/README.md index 6255876e8..9d8e3dfae 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,7 @@ KEY_VAULT_SOURCE=ENVIRONMENT KEY_VAULT_JSON="{}" TRACK_SCREEN_INFO=false TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 +MEDIA_MAX_UPLOAD_SIZE_MB=200 ###< App ### ``` @@ -467,6 +468,16 @@ TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 - EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expire be for cache entries in EventDatabaseApiV2FeedType? - TRACK_SCREEN_INFO: Should screen info be tracked (true|false)? - TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS: How often (seconds) should the screen info be tracked from API requests? +- MEDIA_MAX_UPLOAD_SIZE_MB: Maximum allowed size (in megabytes) for media uploads. Drives both the Symfony backend + validator on the `Media` entity and the file upload UI in the Admin (the dropzone size check and the displayed + "Max-size" label). Must be aligned with the nginx body-size limit and the PHP-FPM upload/post limits — see + [Configuring media upload size limits](#configuring-media-upload-size-limits) below. + + **Default**: `200`. + + **Note**: The Symfony validator metadata is cached. After changing this value, run + `docker compose exec phpfpm bin/console cache:clear` (or redeploy) so the new limit takes effect on the backend. + The admin UI reads the value at runtime from `/config/admin` and picks it up on the next page load. ### Admin configuration @@ -588,6 +599,21 @@ CLIENT_DEBUG=false **Default**: Disabled. +### Configuring media upload size limits + +The maximum size of an uploaded media file is enforced at three independent layers. They must be kept aligned — +the strictest one wins, and when nginx or PHP-FPM rejects a request the user sees a generic 413 / network error +rather than the friendly Symfony validator message. Keep them ordered as: **PHP-FPM ≥ nginx ≥ app**. + +| Layer | Knob | Where it lives | +|---|---|---| +| App (Symfony validator + Admin UI) | `MEDIA_MAX_UPLOAD_SIZE_MB` (megabytes, integer) | `.env` (committed default `200`) — override in `.env.local` for development or in the deployment environment for production | +| Nginx request body | `NGINX_MAX_BODY_SIZE` (nginx size string, e.g. `200m`) | `docker-compose.yml` and `docker-compose.server.yml`; image default is `200m` (set in `infrastructure/nginx/Dockerfile`) | +| PHP-FPM upload + post body | `PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE` (PHP size strings, e.g. `200M`) | Operator-managed env vars on the php-fpm container (supported by the `itkdev/php8.4-fpm` base image). Not set in this repo by default — base image defaults apply unless overridden | + +After changing `MEDIA_MAX_UPLOAD_SIZE_MB`, run `docker compose exec phpfpm bin/console cache:clear` (or redeploy) +so the validator metadata cache picks up the new value. + ### Other configuration options - See `docs/configuration/openid-connect.md` for configuration of OpenID Connect. diff --git a/assets/admin/components/slide/content/file-dropzone.jsx b/assets/admin/components/slide/content/file-dropzone.jsx index 277c3ca3f..c8aedcc53 100644 --- a/assets/admin/components/slide/content/file-dropzone.jsx +++ b/assets/admin/components/slide/content/file-dropzone.jsx @@ -5,14 +5,20 @@ import { useTranslation } from "react-i18next"; * @param {object} props - The props. * @param {Function} props.onFilesAdded - Callback when files are added. * @param {Array | null} props.acceptedMimetypes - Mimetypes to accept. + * @param {number} props.maxSizeMb - Maximum allowed file size in megabytes. * @returns {object} Dropzone component. */ -function FileDropzone({ onFilesAdded, acceptedMimetypes = null }) { +function FileDropzone({ + onFilesAdded, + acceptedMimetypes = null, + maxSizeMb = 200, +}) { const { t } = useTranslation("common"); - // TODO: Make this configurable. It should always align with sizes in - // https://github.com/os2display/display-api-service/blob/develop/src/Entity/Tenant/Media.php - const allowedSize = 200000000; + // Use binary MiB so this threshold aligns with Symfony's `Assert\File` + // `maxSize: 'NM'` semantics — otherwise a file rejected backend-side could + // slip past the dropzone. + const allowedSize = maxSizeMb * 1024 * 1024; const fileValidator = (file) => { if (file.size > allowedSize) { @@ -21,7 +27,7 @@ function FileDropzone({ onFilesAdded, acceptedMimetypes = null }) { code: "file-too-large", message: `${file.name} (${Math.floor( file.size / 1000000, - )} MB) ${largerThanText} (${allowedSize / 1000000} MB)`, + )} MB) ${largerThanText} (${maxSizeMb} MB)`, }; } diff --git a/assets/admin/components/slide/content/file-selector.jsx b/assets/admin/components/slide/content/file-selector.jsx index fa2042ac7..13d5fcc77 100644 --- a/assets/admin/components/slide/content/file-selector.jsx +++ b/assets/admin/components/slide/content/file-selector.jsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import MediaSelectorModal from "./media-selector-modal"; import FileFormElement from "./file-form-element"; import FileDropzone from "./file-dropzone"; +import AdminConfigLoader from "../../util/admin-config-loader"; import "../../util/image-uploader/image-uploader.scss"; /** @@ -30,6 +31,19 @@ function FileSelector({ }) { const { t } = useTranslation("common"); const [showMediaModal, setShowMediaModal] = useState(false); + const [maxSizeMb, setMaxSizeMb] = useState(200); + + useEffect(() => { + let cancelled = false; + AdminConfigLoader.loadConfig().then((config) => { + if (!cancelled && config?.mediaMaxUploadSizeMb) { + setMaxSizeMb(config.mediaMaxUploadSizeMb); + } + }); + return () => { + cancelled = true; + }; + }, []); const closeModal = () => { setShowMediaModal(false); @@ -104,6 +118,7 @@ function FileSelector({ {enableMediaLibrary && ( <> @@ -114,12 +129,8 @@ function FileSelector({ > {t("file-selector.open-media-library")} - {/* - TODO: Make this configurable. It should always align with sizes in - https://github.com/os2display/display-api-service/blob/develop/src/Entity/Tenant/Media.php - */}
- {t("file-selector.max-size")}: 200 MB + {t("file-selector.max-size")}: {maxSizeMb} MB
{ + let fetchMock; + + beforeEach(() => { + vi.resetModules(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fetches config from /config/admin on first call", async () => { + fetchMock.mockResolvedValue({ + json: () => Promise.resolve({ mediaMaxUploadSizeMb: 50 }), + }); + + const { default: AdminConfigLoader } = await import( + "../../admin/components/util/admin-config-loader.js" + ); + + const config = await AdminConfigLoader.loadConfig(); + + expect(fetchMock).toHaveBeenCalledWith("/config/admin"); + expect(config.mediaMaxUploadSizeMb).toBe(50); + }); + + it("caches the response across repeated calls", async () => { + fetchMock.mockResolvedValue({ + json: () => Promise.resolve({ mediaMaxUploadSizeMb: 75 }), + }); + + const { default: AdminConfigLoader } = await import( + "../../admin/components/util/admin-config-loader.js" + ); + + await AdminConfigLoader.loadConfig(); + await AdminConfigLoader.loadConfig(); + await AdminConfigLoader.loadConfig(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to defaults when fetch rejects", async () => { + fetchMock.mockRejectedValue(new Error("network down")); + + const { default: AdminConfigLoader } = await import( + "../../admin/components/util/admin-config-loader.js" + ); + + const config = await AdminConfigLoader.loadConfig(); + + expect(config.mediaMaxUploadSizeMb).toBe(200); + expect(config.loginMethods).toBeDefined(); + }); +}); diff --git a/assets/tests/admin/data-fixtures.js b/assets/tests/admin/data-fixtures.js index d1978eaf2..cd9fcb53a 100644 --- a/assets/tests/admin/data-fixtures.js +++ b/assets/tests/admin/data-fixtures.js @@ -398,6 +398,7 @@ const adminConfigJson = { }, ], enhancedPreview: true, + mediaMaxUploadSizeMb: 200, }; const clientConfigJson = { diff --git a/assets/tests/admin/file-dropzone.test.jsx b/assets/tests/admin/file-dropzone.test.jsx new file mode 100644 index 000000000..621422d16 --- /dev/null +++ b/assets/tests/admin/file-dropzone.test.jsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import FileDropzone from "../../admin/components/slide/content/file-dropzone"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key) => key }), +})); + +const makeFileWithSize = (name, mime, sizeBytes) => { + const file = new File(["x"], name, { type: mime }); + Object.defineProperty(file, "size", { value: sizeBytes }); + return file; +}; + +describe("FileDropzone", () => { + it("rejects files larger than the configured limit and shows the limit in the message", async () => { + const onFilesAdded = vi.fn(); + const { container, findByText } = render( + , + ); + + const oversizeFile = makeFileWithSize( + "huge.png", + "image/png", + 11 * 1024 * 1024, + ); + + const input = container.querySelector('input[type="file"]'); + fireEvent.change(input, { target: { files: [oversizeFile] } }); + + const errorRow = await findByText(/10 MB/); + expect(errorRow).toBeInTheDocument(); + expect(onFilesAdded).not.toHaveBeenCalled(); + }); + + it("accepts files within the configured limit", async () => { + const onFilesAdded = vi.fn(); + const { container } = render( + , + ); + + const okFile = makeFileWithSize("ok.png", "image/png", 5 * 1024 * 1024); + + const input = container.querySelector('input[type="file"]'); + fireEvent.change(input, { target: { files: [okFile] } }); + + await waitFor(() => { + expect(onFilesAdded).toHaveBeenCalledTimes(1); + }); + expect(onFilesAdded.mock.calls[0][0][0].name).toBe("ok.png"); + }); + + it("uses the default 200 MB limit when no maxSizeMb prop is passed", async () => { + const onFilesAdded = vi.fn(); + const { container, findByText } = render( + , + ); + + const oversizeFile = makeFileWithSize( + "huge.png", + "image/png", + 201 * 1024 * 1024, + ); + + const input = container.querySelector('input[type="file"]'); + fireEvent.change(input, { target: { files: [oversizeFile] } }); + + const errorRow = await findByText(/200 MB/); + expect(errorRow).toBeInTheDocument(); + }); +}); diff --git a/config/services.yaml b/config/services.yaml index 0878fa9a7..eb1209abb 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -72,6 +72,7 @@ services: $loginMethods: '%env(json:ADMIN_LOGIN_METHODS)%' $enhancedPreview: '%env(bool:ADMIN_ENHANCED_PREVIEW)%' $loginScreenText: '%env(string:ADMIN_LOGIN_SCREEN_TEXT)%' + $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' App\Controller\Client\ClientConfigController: arguments: diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile index 5fae1ef19..c6b17cb53 100644 --- a/infrastructure/nginx/Dockerfile +++ b/infrastructure/nginx/Dockerfile @@ -28,7 +28,7 @@ ARG UID=101 ENV NGINX_PORT=8080 \ NGINX_FPM_SERVICE=os2display \ NGINX_FPM_PORT=9000 \ - NGINX_MAX_BODY_SIZE=140m \ + NGINX_MAX_BODY_SIZE=200m \ NGINX_SET_REAL_IP_FROM=172.16.0.0/12 \ NGINX_WEB_ROOT=/app/public diff --git a/src/Controller/Admin/AdminConfigController.php b/src/Controller/Admin/AdminConfigController.php index c1e153939..b3a5e2f0b 100644 --- a/src/Controller/Admin/AdminConfigController.php +++ b/src/Controller/Admin/AdminConfigController.php @@ -19,6 +19,7 @@ public function __construct( private readonly array $loginMethods, private readonly bool $enhancedPreview, private readonly string $loginScreenText, + private readonly int $mediaMaxUploadSizeMb, ) {} public function __invoke(): Response @@ -30,6 +31,7 @@ public function __invoke(): Response 'loginMethods' => $this->loginMethods, 'enhancedPreview' => $this->enhancedPreview, 'loginScreenText' => $this->loginScreenText, + 'mediaMaxUploadSizeMb' => $this->mediaMaxUploadSizeMb, ]); } } diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 855764e3a..637c97d43 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Mapping\ClassMetadata; use Vich\UploaderBundle\Mapping\Attribute as Vich; #[Vich\Uploadable] @@ -25,7 +26,6 @@ class Media extends AbstractTenantScopedEntity implements RelationsChecksumInter use RelationsChecksumTrait; #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath', size: 'size')] - #[Assert\File(maxSize: '200000k', mimeTypes: ['image/jpeg', 'image/png', 'image/svg+xml', 'video/webm', 'video/mp4', 'image/gif'], mimeTypesMessage: 'Please upload a valid image format: jpeg, svg, gif or png, or video format: webm or mp4')] public ?File $file = null; #[ORM\Column(nullable: true)] @@ -60,6 +60,19 @@ public function __construct() $this->slides = new ArrayCollection(); } + public static function loadValidatorMetadata(ClassMetadata $metadata): void + { + // Read the env var directly: validator metadata is cached at warmup, so + // operators must `bin/console cache:clear` after changing the value. + $maxSizeMb = (int) ($_ENV['MEDIA_MAX_UPLOAD_SIZE_MB'] ?? 200); + + $metadata->addPropertyConstraint('file', new Assert\File( + maxSize: $maxSizeMb.'M', + mimeTypes: ['image/jpeg', 'image/png', 'image/svg+xml', 'video/webm', 'video/mp4', 'image/gif'], + mimeTypesMessage: 'Please upload a valid image format: jpeg, svg, gif or png, or video format: webm or mp4', + )); + } + public function getWidth(): int { return $this->width; diff --git a/tests/Api/MediaTest.php b/tests/Api/MediaTest.php index a18189a0f..e19154eb4 100644 --- a/tests/Api/MediaTest.php +++ b/tests/Api/MediaTest.php @@ -160,4 +160,46 @@ public function testMediaUpload(): void // @TODO: hydra:member[0].assets: Object value found, but an array is required // $this->assertMatchesResourceItemJsonSchema(Media::class); } + + public function testMediaUploadRejectedWhenTooLarge(): void + { + // Driven by the MEDIA_MAX_UPLOAD_SIZE_MB env var on `Media::loadValidatorMetadata`. + // .env.test should set this to a small value (e.g. 1) so we can build the over-limit + // file cheaply. If the configured limit is too large to test cheaply, skip. + $maxSizeMb = (int) ($_ENV['MEDIA_MAX_UPLOAD_SIZE_MB'] ?? 200); + if ($maxSizeMb > 10) { + $this->markTestSkipped(sprintf( + 'MEDIA_MAX_UPLOAD_SIZE_MB=%d is too large to test cheaply; set it to 1 in .env.test to enable this test.', + $maxSizeMb, + )); + } + + $tmpFile = stream_get_meta_data(tmpfile())['uri']; + // Start with a real JPG so the mime check would pass; pad until the file exceeds the limit. + file_put_contents($tmpFile, file_get_contents('fixtures/files/test.jpg')); + $targetSize = ($maxSizeMb * 1024 * 1024) + 1024; + $fh = fopen($tmpFile, 'a'); + fwrite($fh, str_repeat("\0", $targetSize - filesize($tmpFile))); + fclose($fh); + + $file = new UploadedFile($tmpFile, 'too-large.jpg'); + + $this->getAuthenticatedClient()->request('POST', '/v2/media', [ + 'extra' => [ + 'parameters' => [ + 'title' => 'Too large', + 'description' => 'Exceeds configured max upload size', + 'license' => 'Free CC', + ], + 'files' => [ + 'file' => $file, + ], + ], + 'headers' => [ + 'Content-Type' => 'multipart/form-data', + ], + ]); + + $this->assertResponseStatusCodeSame(422); + } } diff --git a/tests/Controller/Admin/AdminConfigControllerTest.php b/tests/Controller/Admin/AdminConfigControllerTest.php new file mode 100644 index 000000000..48a641191 --- /dev/null +++ b/tests/Controller/Admin/AdminConfigControllerTest.php @@ -0,0 +1,44 @@ +request(Request::METHOD_GET, '/config/admin'); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/json'); + + $payload = $response->toArray(); + + $this->assertArrayHasKey('rejseplanenApiKey', $payload); + $this->assertArrayHasKey('touchButtonRegions', $payload); + $this->assertArrayHasKey('showScreenStatus', $payload); + $this->assertArrayHasKey('loginMethods', $payload); + $this->assertArrayHasKey('enhancedPreview', $payload); + $this->assertArrayHasKey('loginScreenText', $payload); + $this->assertArrayHasKey('mediaMaxUploadSizeMb', $payload); + } + + public function testMediaMaxUploadSizeMbMatchesConfiguredValue(): void + { + $client = static::createClient(); + $response = $client->request(Request::METHOD_GET, '/config/admin'); + + $this->assertResponseIsSuccessful(); + + $payload = $response->toArray(); + $expected = (int) ($_ENV['MEDIA_MAX_UPLOAD_SIZE_MB'] ?? 200); + + $this->assertIsInt($payload['mediaMaxUploadSizeMb']); + $this->assertSame($expected, $payload['mediaMaxUploadSizeMb']); + } +} From 7e51842df6397b68b41afd9d45920a98efe6e7ab Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 16:36:55 +0200 Subject: [PATCH 02/11] 7259: Added rector ignore rule --- rector.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rector.php b/rector.php index 016c83c6a..a7f2a00fe 100644 --- a/rector.php +++ b/rector.php @@ -7,6 +7,7 @@ use Rector\Doctrine\Set\DoctrineSetList; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\Set\ValueObject\LevelSetList; +use Rector\Symfony\CodeQuality\Rector\Class_\LoadValidatorMetadataToAnnotationRector; use Rector\Symfony\Set\SymfonySetList; return RectorConfig::configure() @@ -38,4 +39,10 @@ NullToStrictStringFuncCallArgRector::class => [ __DIR__.'/src/Feed/CalendarApiFeedType.php', ], + // Media uses loadValidatorMetadata to read MEDIA_MAX_UPLOAD_SIZE_MB at + // warmup; Rector cannot fold the runtime expression into an attribute + // and produces invalid `#[Assert\File('M')]`. + LoadValidatorMetadataToAnnotationRector::class => [ + __DIR__.'/src/Entity/Tenant/Media.php', + ], ]); From 53afec39b31064369d7cafb330696ac2caf0832d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 18:48:03 +0200 Subject: [PATCH 03/11] 7259: Changed strategy for validating max size --- .env | 2 ++ .env.test | 3 +++ README.md | 17 ++++++++--------- Taskfile.yml | 2 +- config/services.yaml | 6 +++++- phpunit.xml.dist | 2 +- rector.php | 7 ------- src/Controller/Api/MediaController.php | 24 ++++++++++++++++++++++++ src/Entity/Tenant/Media.php | 15 --------------- 9 files changed, 44 insertions(+), 34 deletions(-) diff --git a/.env b/.env index cd9a02ab1..61841b17b 100644 --- a/.env +++ b/.env @@ -108,6 +108,8 @@ KEY_VAULT_JSON="{}" TRACK_SCREEN_INFO=false # Interval in seconds between screen info updates. TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 +# Maximum media upload size in megabytes; run cache:clear after changing. +MEDIA_MAX_UPLOAD_SIZE_MB=200 # Toggle the relations checksum optimisation for content updates. RELATIONS_CHECKSUM_ENABLED=true ###< App ### diff --git a/.env.test b/.env.test index 2652e6b45..b6bb163b4 100644 --- a/.env.test +++ b/.env.test @@ -19,3 +19,6 @@ JWT_SCREEN_TOKEN_TTL=43200 JWT_REFRESH_TOKEN_TTL=3600 JWT_SCREEN_REFRESH_TOKEN_TTL=86400 ###< gesdinet/jwt-refresh-token-bundle ### + +# Lowered so testMediaUploadRejectedWhenTooLarge can build a small tmp file. +MEDIA_MAX_UPLOAD_SIZE_MB=1 diff --git a/README.md b/README.md index 9d8e3dfae..4e9de902a 100644 --- a/README.md +++ b/README.md @@ -468,16 +468,15 @@ MEDIA_MAX_UPLOAD_SIZE_MB=200 - EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expire be for cache entries in EventDatabaseApiV2FeedType? - TRACK_SCREEN_INFO: Should screen info be tracked (true|false)? - TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS: How often (seconds) should the screen info be tracked from API requests? -- MEDIA_MAX_UPLOAD_SIZE_MB: Maximum allowed size (in megabytes) for media uploads. Drives both the Symfony backend - validator on the `Media` entity and the file upload UI in the Admin (the dropzone size check and the displayed - "Max-size" label). Must be aligned with the nginx body-size limit and the PHP-FPM upload/post limits — see - [Configuring media upload size limits](#configuring-media-upload-size-limits) below. +- MEDIA_MAX_UPLOAD_SIZE_MB: Maximum allowed size (in megabytes, binary MiB) for media uploads. Enforced inside + `App\Controller\Api\MediaController` and exposed to the Admin via `/config/admin` so the dropzone size check and + the displayed "Max-size" label stay aligned. Must also be aligned with the nginx body-size limit and the PHP-FPM + upload/post limits — see [Configuring media upload size limits](#configuring-media-upload-size-limits) below. **Default**: `200`. - **Note**: The Symfony validator metadata is cached. After changing this value, run - `docker compose exec phpfpm bin/console cache:clear` (or redeploy) so the new limit takes effect on the backend. - The admin UI reads the value at runtime from `/config/admin` and picks it up on the next page load. + Changes are picked up on the next request once PHP-FPM workers see the new env value (in production, restart the + php-fpm container or reload the workers). The admin UI re-fetches `/config/admin` on the next page load. ### Admin configuration @@ -611,8 +610,8 @@ rather than the friendly Symfony validator message. Keep them ordered as: **PHP- | Nginx request body | `NGINX_MAX_BODY_SIZE` (nginx size string, e.g. `200m`) | `docker-compose.yml` and `docker-compose.server.yml`; image default is `200m` (set in `infrastructure/nginx/Dockerfile`) | | PHP-FPM upload + post body | `PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE` (PHP size strings, e.g. `200M`) | Operator-managed env vars on the php-fpm container (supported by the `itkdev/php8.4-fpm` base image). Not set in this repo by default — base image defaults apply unless overridden | -After changing `MEDIA_MAX_UPLOAD_SIZE_MB`, run `docker compose exec phpfpm bin/console cache:clear` (or redeploy) -so the validator metadata cache picks up the new value. +The app reads `MEDIA_MAX_UPLOAD_SIZE_MB` per-request, so a deploy / php-fpm worker reload is enough to pick up +changes; no validator cache clear is needed. ### Other configuration options diff --git a/Taskfile.yml b/Taskfile.yml index 7097b346f..fc0808dfc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -217,7 +217,7 @@ tasks: desc: "Runs API tests (PHPUnit)." cmds: - task composer -- test-setup - - task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}} + - task compose -- exec phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}} test:frontend-built: desc: "Runs frontend tests (Playwright) on the built files. This temporarily stops the node container." diff --git a/config/services.yaml b/config/services.yaml index eb1209abb..c92d0a6ff 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,10 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + # Fallback for MEDIA_MAX_UPLOAD_SIZE_MB so the container resolves even if + # the operator has not added the var to .env / their environment. The + # committed default in .env is also 200; this is belt-and-suspenders. + env(MEDIA_MAX_UPLOAD_SIZE_MB): '200' services: # default configuration for services in *this* file @@ -19,6 +23,7 @@ services: $removeProcessor: '@api_platform.doctrine.orm.state.remove_processor' $trackScreenInfo: '%env(bool:TRACK_SCREEN_INFO)%' $trackScreenInfoUpdateIntervalSeconds: '%env(TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS)%' + $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' _instanceof: App\Feed\FeedTypeInterface: @@ -72,7 +77,6 @@ services: $loginMethods: '%env(json:ADMIN_LOGIN_METHODS)%' $enhancedPreview: '%env(bool:ADMIN_ENHANCED_PREVIEW)%' $loginScreenText: '%env(string:ADMIN_LOGIN_SCREEN_TEXT)%' - $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' App\Controller\Client\ClientConfigController: arguments: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 43ab85e78..af9ebe38b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,7 +15,7 @@ - + diff --git a/rector.php b/rector.php index a7f2a00fe..016c83c6a 100644 --- a/rector.php +++ b/rector.php @@ -7,7 +7,6 @@ use Rector\Doctrine\Set\DoctrineSetList; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\Set\ValueObject\LevelSetList; -use Rector\Symfony\CodeQuality\Rector\Class_\LoadValidatorMetadataToAnnotationRector; use Rector\Symfony\Set\SymfonySetList; return RectorConfig::configure() @@ -39,10 +38,4 @@ NullToStrictStringFuncCallArgRector::class => [ __DIR__.'/src/Feed/CalendarApiFeedType.php', ], - // Media uses loadValidatorMetadata to read MEDIA_MAX_UPLOAD_SIZE_MB at - // warmup; Rector cannot fold the runtime expression into an attribute - // and produces invalid `#[Assert\File('M')]`. - LoadValidatorMetadataToAnnotationRector::class => [ - __DIR__.'/src/Entity/Tenant/Media.php', - ], ]); diff --git a/src/Controller/Api/MediaController.php b/src/Controller/Api/MediaController.php index a95fdca35..758ec2c25 100644 --- a/src/Controller/Api/MediaController.php +++ b/src/Controller/Api/MediaController.php @@ -4,16 +4,24 @@ namespace App\Controller\Api; +use ApiPlatform\Validator\Exception\ValidationException; use App\Entity\Tenant\Media; use App\Exceptions\MediaException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Validator\ValidatorInterface; #[AsController] class MediaController extends AbstractController { + public function __construct( + private readonly ValidatorInterface $validator, + private readonly int $mediaMaxUploadSizeMb, + ) {} + /** * @throws MediaException */ @@ -24,6 +32,22 @@ public function __invoke(Request $request): Media throw new BadRequestHttpException('"file" is required'); } + // API Platform skips its ValidateListener when `deserialize: false` is set on the + // operation, so we run the Assert\File constraint here. `Mi` (binary MiB) matches + // the admin dropzone's `mb * 1024 * 1024` threshold. + $violations = $this->validator->validate( + $uploadedFile, + new Assert\File( + maxSize: $this->mediaMaxUploadSizeMb.'Mi', + mimeTypes: ['image/jpeg', 'image/png', 'image/svg+xml', 'video/webm', 'video/mp4', 'image/gif'], + mimeTypesMessage: 'Please upload a valid image format: jpeg, svg, gif or png, or video format: webm or mp4', + ), + ); + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + $title = $this->getRequestParameter($request, 'title'); $description = $this->getRequestParameter($request, 'description'); $license = $this->getRequestParameter($request, 'license'); diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 637c97d43..45bad25a1 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -12,8 +12,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; -use Symfony\Component\Validator\Constraints as Assert; -use Symfony\Component\Validator\Mapping\ClassMetadata; use Vich\UploaderBundle\Mapping\Attribute as Vich; #[Vich\Uploadable] @@ -60,19 +58,6 @@ public function __construct() $this->slides = new ArrayCollection(); } - public static function loadValidatorMetadata(ClassMetadata $metadata): void - { - // Read the env var directly: validator metadata is cached at warmup, so - // operators must `bin/console cache:clear` after changing the value. - $maxSizeMb = (int) ($_ENV['MEDIA_MAX_UPLOAD_SIZE_MB'] ?? 200); - - $metadata->addPropertyConstraint('file', new Assert\File( - maxSize: $maxSizeMb.'M', - mimeTypes: ['image/jpeg', 'image/png', 'image/svg+xml', 'video/webm', 'video/mp4', 'image/gif'], - mimeTypesMessage: 'Please upload a valid image format: jpeg, svg, gif or png, or video format: webm or mp4', - )); - } - public function getWidth(): int { return $this->width; From 9776076390a85841982a0e3c823b09250932fa16 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 19:01:20 +0200 Subject: [PATCH 04/11] 7259: Applied coding standards --- assets/tests/admin/admin-config-loader.test.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/assets/tests/admin/admin-config-loader.test.js b/assets/tests/admin/admin-config-loader.test.js index 557ce014d..db21260b6 100644 --- a/assets/tests/admin/admin-config-loader.test.js +++ b/assets/tests/admin/admin-config-loader.test.js @@ -18,9 +18,8 @@ describe("AdminConfigLoader", () => { json: () => Promise.resolve({ mediaMaxUploadSizeMb: 50 }), }); - const { default: AdminConfigLoader } = await import( - "../../admin/components/util/admin-config-loader.js" - ); + const { default: AdminConfigLoader } = + await import("../../admin/components/util/admin-config-loader.js"); const config = await AdminConfigLoader.loadConfig(); @@ -33,9 +32,8 @@ describe("AdminConfigLoader", () => { json: () => Promise.resolve({ mediaMaxUploadSizeMb: 75 }), }); - const { default: AdminConfigLoader } = await import( - "../../admin/components/util/admin-config-loader.js" - ); + const { default: AdminConfigLoader } = + await import("../../admin/components/util/admin-config-loader.js"); await AdminConfigLoader.loadConfig(); await AdminConfigLoader.loadConfig(); @@ -47,9 +45,8 @@ describe("AdminConfigLoader", () => { it("falls back to defaults when fetch rejects", async () => { fetchMock.mockRejectedValue(new Error("network down")); - const { default: AdminConfigLoader } = await import( - "../../admin/components/util/admin-config-loader.js" - ); + const { default: AdminConfigLoader } = + await import("../../admin/components/util/admin-config-loader.js"); const config = await AdminConfigLoader.loadConfig(); From 3da441682db7c08abde13fe429117533a571335b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 19:04:06 +0200 Subject: [PATCH 05/11] 7259: Added line to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7340f20..246b54a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var. + ## [3.0.0-rc3] - 2026-05-11 - Made the Admin login sidebar text configurable via the new `ADMIN_LOGIN_SCREEN_TEXT` From be40d969c596a2cdd078b4a9f40cd88df5b2c59b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 May 2026 19:14:50 +0200 Subject: [PATCH 06/11] 7259: Fixed test --- assets/tests/admin/file-dropzone.test.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/tests/admin/file-dropzone.test.jsx b/assets/tests/admin/file-dropzone.test.jsx index 621422d16..1f45a3bea 100644 --- a/assets/tests/admin/file-dropzone.test.jsx +++ b/assets/tests/admin/file-dropzone.test.jsx @@ -30,7 +30,10 @@ describe("FileDropzone", () => { const errorRow = await findByText(/10 MB/); expect(errorRow).toBeInTheDocument(); - expect(onFilesAdded).not.toHaveBeenCalled(); + // react-dropzone still fires onDrop with acceptedFiles=[] when all files + // are rejected; the contract is "no real files were passed through". + const passedFiles = onFilesAdded.mock.calls.flatMap(([files]) => files); + expect(passedFiles).toEqual([]); }); it("accepts files within the configured limit", async () => { From a851544569af729f06650ee872cbacfc82c36263 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 26 May 2026 12:30:59 +0200 Subject: [PATCH 07/11] 7259: Moved file validation back onto Media entity via MediaFile constraint Custom constraint reads MEDIA_MAX_UPLOAD_SIZE_MB from DI so the limit stays configurable while the invariants live on the entity, not in the controller. --- config/services.yaml | 10 +++--- src/Controller/Api/MediaController.php | 26 +++++--------- src/Entity/Tenant/Media.php | 2 ++ src/Validator/MediaFile.php | 31 +++++++++++++++++ src/Validator/MediaFileValidator.php | 47 ++++++++++++++++++++++++++ tests/Api/MediaTest.php | 7 ++-- 6 files changed, 97 insertions(+), 26 deletions(-) create mode 100644 src/Validator/MediaFile.php create mode 100644 src/Validator/MediaFileValidator.php diff --git a/config/services.yaml b/config/services.yaml index c92d0a6ff..2092f896d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,10 +4,6 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: - # Fallback for MEDIA_MAX_UPLOAD_SIZE_MB so the container resolves even if - # the operator has not added the var to .env / their environment. The - # committed default in .env is also 200; this is belt-and-suspenders. - env(MEDIA_MAX_UPLOAD_SIZE_MB): '200' services: # default configuration for services in *this* file @@ -23,7 +19,6 @@ services: $removeProcessor: '@api_platform.doctrine.orm.state.remove_processor' $trackScreenInfo: '%env(bool:TRACK_SCREEN_INFO)%' $trackScreenInfoUpdateIntervalSeconds: '%env(TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS)%' - $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' _instanceof: App\Feed\FeedTypeInterface: @@ -77,6 +72,11 @@ services: $loginMethods: '%env(json:ADMIN_LOGIN_METHODS)%' $enhancedPreview: '%env(bool:ADMIN_ENHANCED_PREVIEW)%' $loginScreenText: '%env(string:ADMIN_LOGIN_SCREEN_TEXT)%' + $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' + + App\Validator\MediaFileValidator: + arguments: + $mediaMaxUploadSizeMb: '%env(int:MEDIA_MAX_UPLOAD_SIZE_MB)%' App\Controller\Client\ClientConfigController: arguments: diff --git a/src/Controller/Api/MediaController.php b/src/Controller/Api/MediaController.php index 758ec2c25..dd31a728b 100644 --- a/src/Controller/Api/MediaController.php +++ b/src/Controller/Api/MediaController.php @@ -11,7 +11,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Validator\ValidatorInterface; #[AsController] @@ -19,7 +18,6 @@ class MediaController extends AbstractController { public function __construct( private readonly ValidatorInterface $validator, - private readonly int $mediaMaxUploadSizeMb, ) {} /** @@ -32,22 +30,6 @@ public function __invoke(Request $request): Media throw new BadRequestHttpException('"file" is required'); } - // API Platform skips its ValidateListener when `deserialize: false` is set on the - // operation, so we run the Assert\File constraint here. `Mi` (binary MiB) matches - // the admin dropzone's `mb * 1024 * 1024` threshold. - $violations = $this->validator->validate( - $uploadedFile, - new Assert\File( - maxSize: $this->mediaMaxUploadSizeMb.'Mi', - mimeTypes: ['image/jpeg', 'image/png', 'image/svg+xml', 'video/webm', 'video/mp4', 'image/gif'], - mimeTypesMessage: 'Please upload a valid image format: jpeg, svg, gif or png, or video format: webm or mp4', - ), - ); - - if (count($violations) > 0) { - throw new ValidationException($violations); - } - $title = $this->getRequestParameter($request, 'title'); $description = $this->getRequestParameter($request, 'description'); $license = $this->getRequestParameter($request, 'license'); @@ -60,6 +42,14 @@ public function __invoke(Request $request): Media ->setLicense($license) ; + // API Platform skips its built-in validation pipeline when `deserialize: false` + // is set on the operation, so we re-trigger entity-level constraints here. + // The size limit lives on the `#[MediaMaxUploadSize]` attribute on Media::$file. + $violations = $this->validator->validate($media); + if (count($violations) > 0) { + throw new ValidationException($violations); + } + // Note that the extra information about the uploaded file is added in the MediaDoctrineEventListener because // the file does not exist on disk before this point. return $media; diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 45bad25a1..bb63672ab 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -8,6 +8,7 @@ use App\Entity\Traits\EntityTitleDescriptionTrait; use App\Entity\Traits\RelationsChecksumTrait; use App\Repository\MediaRepository; +use App\Validator\MediaFile; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -24,6 +25,7 @@ class Media extends AbstractTenantScopedEntity implements RelationsChecksumInter use RelationsChecksumTrait; #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath', size: 'size')] + #[MediaFile] public ?File $file = null; #[ORM\Column(nullable: true)] diff --git a/src/Validator/MediaFile.php b/src/Validator/MediaFile.php new file mode 100644 index 000000000..49edc5f0d --- /dev/null +++ b/src/Validator/MediaFile.php @@ -0,0 +1,31 @@ +context->getValidator() + ->inContext($this->context) + ->validate($value, new Assert\File( + maxSize: $this->mediaMaxUploadSizeMb.'Mi', + mimeTypes: $constraint->mimeTypes, + maxSizeMessage: $constraint->maxSizeMessage, + mimeTypesMessage: $constraint->mimeTypesMessage, + )); + } +} diff --git a/tests/Api/MediaTest.php b/tests/Api/MediaTest.php index e19154eb4..c363c221f 100644 --- a/tests/Api/MediaTest.php +++ b/tests/Api/MediaTest.php @@ -163,9 +163,10 @@ public function testMediaUpload(): void public function testMediaUploadRejectedWhenTooLarge(): void { - // Driven by the MEDIA_MAX_UPLOAD_SIZE_MB env var on `Media::loadValidatorMetadata`. - // .env.test should set this to a small value (e.g. 1) so we can build the over-limit - // file cheaply. If the configured limit is too large to test cheaply, skip. + // Driven by the MEDIA_MAX_UPLOAD_SIZE_MB env var via the `#[MediaFile]` + // attribute on `Media::$file`. .env.test should set this to a small value + // (e.g. 1) so we can build the over-limit file cheaply. If the configured + // limit is too large to test cheaply, skip. $maxSizeMb = (int) ($_ENV['MEDIA_MAX_UPLOAD_SIZE_MB'] ?? 200); if ($maxSizeMb > 10) { $this->markTestSkipped(sprintf( From b5f594c9d27461d595fce25ce1195fb28664bd45 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 26 May 2026 12:31:12 +0200 Subject: [PATCH 08/11] 7259: Cached default config in AdminConfigLoader on fetch failure Without this every consumer mount re-fired a failing /config/admin request. --- .../components/util/admin-config-loader.js | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/assets/admin/components/util/admin-config-loader.js b/assets/admin/components/util/admin-config-loader.js index 898a94a97..b8a67c632 100644 --- a/assets/admin/components/util/admin-config-loader.js +++ b/assets/admin/components/util/admin-config-loader.js @@ -1,3 +1,21 @@ +const DEFAULT_CONFIG = { + rejseplanenApiKey: null, + touchButtonRegions: false, + showScreenStatus: false, + enhancedPreview: false, + loginScreenText: "", + mediaMaxUploadSizeMb: 200, + loginMethods: [ + { + type: "username-password", + enabled: true, + provider: "username-password", + label: null, + icon: null, + }, + ], +}; + let configData = null; let activePromise = null; @@ -6,49 +24,25 @@ const AdminConfigLoader = { if (activePromise) { return activePromise; } + if (configData !== null) { + return configData; + } - activePromise = new Promise((resolve) => { - if (configData !== null) { - resolve(configData); - } else { - fetch("/config/admin") - .then((response) => response.json()) - .then((data) => { - configData = data; - resolve(configData); - }) - .catch(() => { - if (configData !== null) { - resolve(configData); - } else { - // eslint-disable-next-line no-console - console.error("Could not load config. Will use default config."); - - // Default config. - resolve({ - rejseplanenApiKey: null, - touchButtonRegions: false, - showScreenStatus: false, - enhancedPreview: false, - loginScreenText: "", - mediaMaxUploadSizeMb: 200, - loginMethods: [ - { - type: "username-password", - enabled: true, - provider: "username-password", - label: null, - icon: null, - }, - ], - }); - } - }) - .finally(() => { - activePromise = null; - }); - } - }); + activePromise = fetch("/config/admin") + .then((response) => response.json()) + .then((data) => { + configData = data; + return configData; + }) + .catch(() => { + // eslint-disable-next-line no-console + console.error("Could not load config. Will use default config."); + configData = DEFAULT_CONFIG; + return configData; + }) + .finally(() => { + activePromise = null; + }); return activePromise; }, From bd25feacd0473dee2f064d9d7849d407de119b7c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 26 May 2026 12:31:31 +0200 Subject: [PATCH 09/11] 7259: Guarded FileSelector against unloaded or invalid mediaMaxUploadSizeMb Initial state is null and the dropzone is hidden behind a loading placeholder until config resolves; a non-positive integer falls back to 200 instead of being silently absorbed. --- .../slide/content/file-selector.jsx | 32 ++++++----- assets/admin/translations/da/common.json | 3 +- assets/tests/admin/file-selector.test.jsx | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 assets/tests/admin/file-selector.test.jsx diff --git a/assets/admin/components/slide/content/file-selector.jsx b/assets/admin/components/slide/content/file-selector.jsx index 13d5fcc77..281d4824e 100644 --- a/assets/admin/components/slide/content/file-selector.jsx +++ b/assets/admin/components/slide/content/file-selector.jsx @@ -31,14 +31,14 @@ function FileSelector({ }) { const { t } = useTranslation("common"); const [showMediaModal, setShowMediaModal] = useState(false); - const [maxSizeMb, setMaxSizeMb] = useState(200); + const [maxSizeMb, setMaxSizeMb] = useState(null); useEffect(() => { let cancelled = false; AdminConfigLoader.loadConfig().then((config) => { - if (!cancelled && config?.mediaMaxUploadSizeMb) { - setMaxSizeMb(config.mediaMaxUploadSizeMb); - } + if (cancelled) return; + const value = config?.mediaMaxUploadSizeMb; + setMaxSizeMb(Number.isInteger(value) && value > 0 ? value : 200); }); return () => { cancelled = true; @@ -115,11 +115,17 @@ function FileSelector({ return ( <> - + {maxSizeMb === null ? ( +
+ {t("file-selector.loading")} +
+ ) : ( + + )} {enableMediaLibrary && ( <> -
- {t("file-selector.max-size")}: {maxSizeMb} MB -
+ {maxSizeMb !== null && ( +
+ {t("file-selector.max-size")}: {maxSizeMb} MB +
+ )} ({ + useTranslation: () => ({ t: (key) => key }), +})); + +vi.mock("../../admin/components/slide/content/media-selector-modal", () => ({ + default: () => null, +})); + +vi.mock("../../admin/components/slide/content/file-form-element", () => ({ + default: () => null, +})); + +vi.mock("../../admin/components/util/admin-config-loader", () => ({ + default: { + loadConfig: vi.fn(), + }, +})); + +import AdminConfigLoader from "../../admin/components/util/admin-config-loader"; + +describe("FileSelector", () => { + it("renders the loading placeholder and hides the dropzone until config resolves", async () => { + let resolveConfig; + AdminConfigLoader.loadConfig.mockReturnValue( + new Promise((resolve) => { + resolveConfig = resolve; + }), + ); + + const { container, queryByText, findByText } = render( + {}} name="media" />, + ); + + // Loading placeholder visible, dropzone (file input) not yet rendered. + expect(queryByText("file-selector.loading")).toBeInTheDocument(); + expect(container.querySelector('input[type="file"]')).toBeNull(); + // The "Max-size" label is also gated until config arrives. + expect(queryByText(/file-selector.max-size/)).toBeNull(); + + resolveConfig({ mediaMaxUploadSizeMb: 50 }); + + // After config resolves the dropzone replaces the placeholder. + await waitFor(() => { + expect(container.querySelector('input[type="file"]')).not.toBeNull(); + }); + expect(queryByText("file-selector.loading")).toBeNull(); + expect(await findByText(/50 MB/)).toBeInTheDocument(); + }); +}); From 6d0326c34675ca3719e39ea1c03db326016544d3 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 26 May 2026 12:44:22 +0200 Subject: [PATCH 10/11] 7259: Failed fast on non-positive MEDIA_MAX_UPLOAD_SIZE_MB Constructor guard turns a 0 (or empty/negative env) misconfig into a clear 500 naming the env var, instead of a 422 "file too large (0 bytes)" that blames the user's upload. --- src/Validator/MediaFileValidator.php | 6 ++++- tests/Validator/MediaFileValidatorTest.php | 31 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/Validator/MediaFileValidatorTest.php diff --git a/src/Validator/MediaFileValidator.php b/src/Validator/MediaFileValidator.php index 6baf73947..72c85ffd1 100644 --- a/src/Validator/MediaFileValidator.php +++ b/src/Validator/MediaFileValidator.php @@ -15,7 +15,11 @@ class MediaFileValidator extends ConstraintValidator { public function __construct( private readonly int $mediaMaxUploadSizeMb, - ) {} + ) { + if ($mediaMaxUploadSizeMb <= 0) { + throw new \InvalidArgumentException(sprintf('MEDIA_MAX_UPLOAD_SIZE_MB must be a positive integer; got %d.', $mediaMaxUploadSizeMb)); + } + } public function validate(mixed $value, Constraint $constraint): void { diff --git a/tests/Validator/MediaFileValidatorTest.php b/tests/Validator/MediaFileValidatorTest.php new file mode 100644 index 000000000..887ef4b0b --- /dev/null +++ b/tests/Validator/MediaFileValidatorTest.php @@ -0,0 +1,31 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/MEDIA_MAX_UPLOAD_SIZE_MB/'); + + new MediaFileValidator($limit); + } + + /** + * @return iterable + */ + public static function nonPositiveLimits(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + } +} From a016403e66220f757746ae62140f528e9a27734c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 26 May 2026 12:50:45 +0200 Subject: [PATCH 11/11] 7259: Corrected stale cache:clear note in .env The value is read per-request via %env(int:...)%; no validator cache to clear. Also documented the MiB unit and the alignment requirement with nginx/php-fpm. --- .env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 61841b17b..caf53dd02 100644 --- a/.env +++ b/.env @@ -108,7 +108,8 @@ KEY_VAULT_JSON="{}" TRACK_SCREEN_INFO=false # Interval in seconds between screen info updates. TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 -# Maximum media upload size in megabytes; run cache:clear after changing. +# Maximum media upload size in mebibytes (MiB). Read per-request, so no cache:clear needed. +# Must stay aligned with NGINX_MAX_BODY_SIZE and php-fpm upload_max_filesize / post_max_size. MEDIA_MAX_UPLOAD_SIZE_MB=200 # Toggle the relations checksum optimisation for content updates. RELATIONS_CHECKSUM_ENABLED=true