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.open-media-library")}
-
- {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