Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
```

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
16 changes: 11 additions & 5 deletions assets/admin/components/slide/content/file-dropzone.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)`,
};
}

Expand Down
43 changes: 31 additions & 12 deletions assets/admin/components/slide/content/file-selector.jsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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);
Expand Down Expand Up @@ -101,10 +115,17 @@ function FileSelector({

return (
<>
<FileDropzone
onFilesAdded={filesAdded}
acceptedMimetypes={acceptedMimetypes}
/>
{maxSizeMb === null ? (
<div className="small mt-3 text-muted">
{t("file-selector.loading")}
</div>
) : (
<FileDropzone
onFilesAdded={filesAdded}
acceptedMimetypes={acceptedMimetypes}
maxSizeMb={maxSizeMb}
/>
)}
{enableMediaLibrary && (
<>
<Button
Expand All @@ -114,13 +135,11 @@ function FileSelector({
>
{t("file-selector.open-media-library")}
</Button>
{/*
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
*/}
<div className="small mt-3">
{t("file-selector.max-size")}: 200 MB
</div>
{maxSizeMb !== null && (
<div className="small mt-3">
{t("file-selector.max-size")}: {maxSizeMb} MB
</div>
)}
<MediaSelectorModal
selectedMedia={files}
multiple={multiple}
Expand Down
77 changes: 36 additions & 41 deletions assets/admin/components/util/admin-config-loader.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -6,48 +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: "",
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;
},
Expand Down
3 changes: 2 additions & 1 deletion assets/admin/translations/da/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions assets/tests/admin/admin-config-loader.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions assets/tests/admin/data-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ const adminConfigJson = {
},
],
enhancedPreview: true,
mediaMaxUploadSizeMb: 200,
};

const clientConfigJson = {
Expand Down
Loading