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]; + } +}