diff --git a/.env b/.env
index cd9a02ab1..caf53dd02 100644
--- a/.env
+++ b/.env
@@ -108,6 +108,9 @@ 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 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
###< 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/CHANGELOG.md b/CHANGELOG.md
index a985fb5ea..9cbea2f22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ 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.
- Fixed playlist share-target dropdown silently truncating to 30 tenants; it now loads every page.
- Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types.
- Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect
diff --git a/README.md b/README.md
index 6255876e8..4e9de902a 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,15 @@ 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, 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`.
+
+ 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
@@ -588,6 +598,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 |
+
+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
- See `docs/configuration/openid-connect.md` for configuration of OpenID Connect.
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/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..281d4824e 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(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ AdminConfigLoader.loadConfig().then((config) => {
+ if (cancelled) return;
+ const value = config?.mediaMaxUploadSizeMb;
+ setMaxSizeMb(Number.isInteger(value) && value > 0 ? value : 200);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
const closeModal = () => {
setShowMediaModal(false);
@@ -101,10 +115,17 @@ function FileSelector({
return (
<>
-
+ {maxSizeMb === null ? (
+
+ {t("file-selector.loading")}
+
+ ) : (
+
+ )}
{enableMediaLibrary && (
<>
- {/*
- 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
-
+ {maxSizeMb !== null && (
+
+ {t("file-selector.max-size")}: {maxSizeMb} MB
+
+ )}
{
- 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: "",
- 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;
},
diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json
index ab3002c27..de2822ea3 100644
--- a/assets/admin/translations/da/common.json
+++ b/assets/admin/translations/da/common.json
@@ -1019,7 +1019,8 @@
},
"file-selector": {
"open-media-library": "Vælg fra mediearkiv",
- "max-size": "Maksimal størrelse på filer"
+ "max-size": "Maksimal størrelse på filer",
+ "loading": "Henter konfiguration..."
},
"poster-selector": {
"preview-of-events": "Forhåndsvisning af begivenheder udfra valg",
diff --git a/assets/tests/admin/admin-config-loader.test.js b/assets/tests/admin/admin-config-loader.test.js
new file mode 100644
index 000000000..db21260b6
--- /dev/null
+++ b/assets/tests/admin/admin-config-loader.test.js
@@ -0,0 +1,56 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+describe("AdminConfigLoader", () => {
+ 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..1f45a3bea
--- /dev/null
+++ b/assets/tests/admin/file-dropzone.test.jsx
@@ -0,0 +1,74 @@
+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();
+ // 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 () => {
+ 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/assets/tests/admin/file-selector.test.jsx b/assets/tests/admin/file-selector.test.jsx
new file mode 100644
index 000000000..dbf68ae2e
--- /dev/null
+++ b/assets/tests/admin/file-selector.test.jsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, waitFor } from "@testing-library/react";
+import FileSelector from "../../admin/components/slide/content/file-selector";
+
+vi.mock("react-i18next", () => ({
+ 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();
+ });
+});
diff --git a/config/services.yaml b/config/services.yaml
index 0878fa9a7..2092f896d 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -72,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/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/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/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/Controller/Api/MediaController.php b/src/Controller/Api/MediaController.php
index a95fdca35..dd31a728b 100644
--- a/src/Controller/Api/MediaController.php
+++ b/src/Controller/Api/MediaController.php
@@ -4,16 +4,22 @@
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\Validator\ValidatorInterface;
#[AsController]
class MediaController extends AbstractController
{
+ public function __construct(
+ private readonly ValidatorInterface $validator,
+ ) {}
+
/**
* @throws MediaException
*/
@@ -36,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 855764e3a..bb63672ab 100644
--- a/src/Entity/Tenant/Media.php
+++ b/src/Entity/Tenant/Media.php
@@ -8,11 +8,11 @@
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;
use Symfony\Component\HttpFoundation\File\File;
-use Symfony\Component\Validator\Constraints as Assert;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[Vich\Uploadable]
@@ -25,7 +25,7 @@ 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')]
+ #[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 a18189a0f..c363c221f 100644
--- a/tests/Api/MediaTest.php
+++ b/tests/Api/MediaTest.php
@@ -160,4 +160,47 @@ 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 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(
+ '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']);
+ }
+}
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];
+ }
+}