Skip to content

Commit 75b05b4

Browse files
authored
Merge branch 'release/3.0.0' into feature/brnd-2.7-fix1
2 parents 90395be + a0b3c89 commit 75b05b4

31 files changed

Lines changed: 1090 additions & 94 deletions

.env

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ KEY_VAULT_JSON="{}"
108108
TRACK_SCREEN_INFO=false
109109
# Interval in seconds between screen info updates.
110110
TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300
111+
# Maximum media upload size in mebibytes (MiB). Read per-request, so no cache:clear needed.
112+
# Must stay aligned with NGINX_MAX_BODY_SIZE and php-fpm upload_max_filesize / post_max_size.
113+
MEDIA_MAX_UPLOAD_SIZE_MB=200
111114
# Toggle the relations checksum optimisation for content updates.
112115
RELATIONS_CHECKSUM_ENABLED=true
113116
###< App ###
@@ -177,6 +180,11 @@ CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS=300
177180
EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
178181
###< Event Database Api V2 Feed Type ###
179182

183+
###> Instant Book ###
184+
# Source of busy intervals data for instant booking interactive slides (graph or feed).
185+
INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
186+
###< Instant Book ###
187+
180188
###> Admin configuration ###
181189
# API key for Rejseplanen (Danish journey planner) integration.
182190
ADMIN_REJSEPLANEN_APIKEY=

.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ JWT_SCREEN_TOKEN_TTL=43200
1919
JWT_REFRESH_TOKEN_TTL=3600
2020
JWT_SCREEN_REFRESH_TOKEN_TTL=86400
2121
###< gesdinet/jwt-refresh-token-bundle ###
22+
23+
# Lowered so testMediaUploadRejectedWhenTooLarge can build a small tmp file.
24+
MEDIA_MAX_UPLOAD_SIZE_MB=1

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
## [Unreleased]
66

77
- Merged fixes from 2.7.0 into release/3.0.0.
8+
- Added `INSTANT_BOOK_BUSY_INTERVALS_SOURCE` to select between Graph and the slide's calendar feed as the source of busy
9+
intervals for InstantBook.
10+
- Changed polling interval for instant booking template.
11+
- Fixed admin toast leaking a raw `SyntaxError: Unexpected token '<'` when an upload was rejected
12+
upstream (e.g. nginx 413); the toast now shows `HTTP <status>` instead.
13+
- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var.
14+
- Fixed playlist share-target dropdown silently truncating to 30 tenants; it now loads every page.
815
- Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types.
916
- Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect
1017
throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ KEY_VAULT_SOURCE=ENVIRONMENT
452452
KEY_VAULT_JSON="{}"
453453
TRACK_SCREEN_INFO=false
454454
TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300
455+
MEDIA_MAX_UPLOAD_SIZE_MB=200
455456
###< App ###
456457
```
457458

@@ -467,6 +468,15 @@ TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300
467468
- EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expire be for cache entries in EventDatabaseApiV2FeedType?
468469
- TRACK_SCREEN_INFO: Should screen info be tracked (true|false)?
469470
- TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS: How often (seconds) should the screen info be tracked from API requests?
471+
- MEDIA_MAX_UPLOAD_SIZE_MB: Maximum allowed size (in megabytes, binary MiB) for media uploads. Enforced inside
472+
`App\Controller\Api\MediaController` and exposed to the Admin via `/config/admin` so the dropzone size check and
473+
the displayed "Max-size" label stay aligned. Must also be aligned with the nginx body-size limit and the PHP-FPM
474+
upload/post limits — see [Configuring media upload size limits](#configuring-media-upload-size-limits) below.
475+
476+
**Default**: `200`.
477+
478+
Changes are picked up on the next request once PHP-FPM workers see the new env value (in production, restart the
479+
php-fpm container or reload the workers). The admin UI re-fetches `/config/admin` on the next page load.
470480

471481
### Admin configuration
472482

@@ -588,6 +598,21 @@ CLIENT_DEBUG=false
588598

589599
**Default**: Disabled.
590600

601+
### Configuring media upload size limits
602+
603+
The maximum size of an uploaded media file is enforced at three independent layers. They must be kept aligned —
604+
the strictest one wins, and when nginx or PHP-FPM rejects a request the user sees a generic 413 / network error
605+
rather than the friendly Symfony validator message. Keep them ordered as: **PHP-FPM ≥ nginx ≥ app**.
606+
607+
| Layer | Knob | Where it lives |
608+
|---|---|---|
609+
| 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 |
610+
| 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`) |
611+
| 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 |
612+
613+
The app reads `MEDIA_MAX_UPLOAD_SIZE_MB` per-request, so a deploy / php-fpm worker reload is enough to pick up
614+
changes; no validator cache clear is needed.
615+
591616
### Other configuration options
592617

593618
- See `docs/configuration/openid-connect.md` for configuration of OpenID Connect.
@@ -604,6 +629,21 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS=300
604629
- EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expiration be for cache entries in
605630
EventDatabaseApiV2FeedType?
606631

632+
#### InstantBook
633+
634+
```dotenv
635+
###> InstantBook ###
636+
INSTANT_BOOK_BUSY_INTERVALS_SOURCE=graph
637+
###< InstantBook ###
638+
```
639+
640+
- INSTANT_BOOK_BUSY_INTERVALS_SOURCE: Where the InstantBook interactive slide fetches resource
641+
busy-intervals from.
642+
- `graph`: Fetch busy intervals from Microsoft Graph (results cached for 15 minutes).
643+
- `feed`: Fetch busy intervals from the slide's configured calendar-output feed.
644+
645+
**Default**: `graph`.
646+
607647
## Rest API & Relationships
608648

609649
To avoid embedding all relations in REST representations but still allow the clients to minimize the amount of API calls

Taskfile.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ tasks:
217217
desc: "Runs API tests (PHPUnit)."
218218
cmds:
219219
- task composer -- test-setup
220-
- task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}}
220+
- task compose -- exec phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}}
221221

222222
test:frontend-built:
223223
desc: "Runs frontend tests (Playwright) on the built files. This temporarily stops the node container."

assets/admin/components/playlist/playlist-form.jsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { useContext } from "react";
1+
import { useContext, useEffect, useState } from "react";
22
import { useTranslation } from "react-i18next";
3+
import { useDispatch } from "react-redux";
34
import { Alert } from "react-bootstrap";
45
import UserContext from "../../context/user-context";
56
import Schedule from "../util/schedule/schedule";
6-
import { useGetV2TenantsQuery } from "../../../shared/redux/enhanced-api.ts";
7+
import { enhancedApi } from "../../../shared/redux/enhanced-api.ts";
8+
import getAllPages from "../util/helpers/get-all-pages.js";
79
import ContentBody from "../util/content-body/content-body";
810
import TenantsDropdown from "../util/forms/multiselect-dropdown/tenants/tenants-dropdown";
911

@@ -24,10 +26,20 @@ function PlaylistForm({
2426
}) {
2527
const { t } = useTranslation("common", { keyPrefix: "playlist-form" });
2628
const context = useContext(UserContext);
29+
const dispatch = useDispatch();
30+
const [tenants, setTenants] = useState(null);
2731

28-
const { data: tenants } = useGetV2TenantsQuery({
29-
itemsPerPage: 30,
30-
});
32+
useEffect(() => {
33+
let cancelled = false;
34+
getAllPages(dispatch, enhancedApi.endpoints.getV2Tenants, {
35+
itemsPerPage: 30,
36+
}).then((all) => {
37+
if (!cancelled) setTenants(all);
38+
});
39+
return () => {
40+
cancelled = true;
41+
};
42+
}, [dispatch]);
3143

3244
return (
3345
<>
@@ -51,7 +63,7 @@ function PlaylistForm({
5163
name="tenants"
5264
handleTenantSelection={handleInput}
5365
selected={playlist.tenants}
54-
data={tenants["hydra:member"].filter(({ tenantKey }) => {
66+
data={tenants.filter(({ tenantKey }) => {
5567
return context.selectedTenant.get.tenantKey !== tenantKey;
5668
})}
5769
/>

assets/admin/components/slide/content/file-dropzone.jsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ import { useTranslation } from "react-i18next";
55
* @param {object} props - The props.
66
* @param {Function} props.onFilesAdded - Callback when files are added.
77
* @param {Array | null} props.acceptedMimetypes - Mimetypes to accept.
8+
* @param {number} props.maxSizeMb - Maximum allowed file size in megabytes.
89
* @returns {object} Dropzone component.
910
*/
10-
function FileDropzone({ onFilesAdded, acceptedMimetypes = null }) {
11+
function FileDropzone({
12+
onFilesAdded,
13+
acceptedMimetypes = null,
14+
maxSizeMb = 200,
15+
}) {
1116
const { t } = useTranslation("common");
1217

13-
// TODO: Make this configurable. It should always align with sizes in
14-
// https://github.com/os2display/display-api-service/blob/develop/src/Entity/Tenant/Media.php
15-
const allowedSize = 200000000;
18+
// Use binary MiB so this threshold aligns with Symfony's `Assert\File`
19+
// `maxSize: 'NM'` semantics — otherwise a file rejected backend-side could
20+
// slip past the dropzone.
21+
const allowedSize = maxSizeMb * 1024 * 1024;
1622

1723
const fileValidator = (file) => {
1824
if (file.size > allowedSize) {
@@ -21,7 +27,7 @@ function FileDropzone({ onFilesAdded, acceptedMimetypes = null }) {
2127
code: "file-too-large",
2228
message: `${file.name} (${Math.floor(
2329
file.size / 1000000,
24-
)} MB) ${largerThanText} (${allowedSize / 1000000} MB)`,
30+
)} MB) ${largerThanText} (${maxSizeMb} MB)`,
2531
};
2632
}
2733

assets/admin/components/slide/content/file-selector.jsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import { Button } from "react-bootstrap";
33
import { useTranslation } from "react-i18next";
44
import MediaSelectorModal from "./media-selector-modal";
55
import FileFormElement from "./file-form-element";
66
import FileDropzone from "./file-dropzone";
7+
import AdminConfigLoader from "../../util/admin-config-loader";
78
import "../../util/image-uploader/image-uploader.scss";
89

910
/**
@@ -30,6 +31,19 @@ function FileSelector({
3031
}) {
3132
const { t } = useTranslation("common");
3233
const [showMediaModal, setShowMediaModal] = useState(false);
34+
const [maxSizeMb, setMaxSizeMb] = useState(null);
35+
36+
useEffect(() => {
37+
let cancelled = false;
38+
AdminConfigLoader.loadConfig().then((config) => {
39+
if (cancelled) return;
40+
const value = config?.mediaMaxUploadSizeMb;
41+
setMaxSizeMb(Number.isInteger(value) && value > 0 ? value : 200);
42+
});
43+
return () => {
44+
cancelled = true;
45+
};
46+
}, []);
3347

3448
const closeModal = () => {
3549
setShowMediaModal(false);
@@ -101,10 +115,17 @@ function FileSelector({
101115

102116
return (
103117
<>
104-
<FileDropzone
105-
onFilesAdded={filesAdded}
106-
acceptedMimetypes={acceptedMimetypes}
107-
/>
118+
{maxSizeMb === null ? (
119+
<div className="small mt-3 text-muted">
120+
{t("file-selector.loading")}
121+
</div>
122+
) : (
123+
<FileDropzone
124+
onFilesAdded={filesAdded}
125+
acceptedMimetypes={acceptedMimetypes}
126+
maxSizeMb={maxSizeMb}
127+
/>
128+
)}
108129
{enableMediaLibrary && (
109130
<>
110131
<Button
@@ -114,13 +135,11 @@ function FileSelector({
114135
>
115136
{t("file-selector.open-media-library")}
116137
</Button>
117-
{/*
118-
TODO: Make this configurable. It should always align with sizes in
119-
https://github.com/os2display/display-api-service/blob/develop/src/Entity/Tenant/Media.php
120-
*/}
121-
<div className="small mt-3">
122-
{t("file-selector.max-size")}: 200 MB
123-
</div>
138+
{maxSizeMb !== null && (
139+
<div className="small mt-3">
140+
{t("file-selector.max-size")}: {maxSizeMb} MB
141+
</div>
142+
)}
124143
<MediaSelectorModal
125144
selectedMedia={files}
126145
multiple={multiple}

assets/admin/components/util/admin-config-loader.js

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
const DEFAULT_CONFIG = {
2+
rejseplanenApiKey: null,
3+
touchButtonRegions: false,
4+
showScreenStatus: false,
5+
enhancedPreview: false,
6+
loginScreenText: "",
7+
mediaMaxUploadSizeMb: 200,
8+
loginMethods: [
9+
{
10+
type: "username-password",
11+
enabled: true,
12+
provider: "username-password",
13+
label: null,
14+
icon: null,
15+
},
16+
],
17+
};
18+
119
let configData = null;
220
let activePromise = null;
321

@@ -6,48 +24,25 @@ const AdminConfigLoader = {
624
if (activePromise) {
725
return activePromise;
826
}
27+
if (configData !== null) {
28+
return configData;
29+
}
930

10-
activePromise = new Promise((resolve) => {
11-
if (configData !== null) {
12-
resolve(configData);
13-
} else {
14-
fetch("/config/admin")
15-
.then((response) => response.json())
16-
.then((data) => {
17-
configData = data;
18-
resolve(configData);
19-
})
20-
.catch(() => {
21-
if (configData !== null) {
22-
resolve(configData);
23-
} else {
24-
// eslint-disable-next-line no-console
25-
console.error("Could not load config. Will use default config.");
26-
27-
// Default config.
28-
resolve({
29-
rejseplanenApiKey: null,
30-
touchButtonRegions: false,
31-
showScreenStatus: false,
32-
enhancedPreview: false,
33-
loginScreenText: "",
34-
loginMethods: [
35-
{
36-
type: "username-password",
37-
enabled: true,
38-
provider: "username-password",
39-
label: null,
40-
icon: null,
41-
},
42-
],
43-
});
44-
}
45-
})
46-
.finally(() => {
47-
activePromise = null;
48-
});
49-
}
50-
});
31+
activePromise = fetch("/config/admin")
32+
.then((response) => response.json())
33+
.then((data) => {
34+
configData = data;
35+
return configData;
36+
})
37+
.catch(() => {
38+
// eslint-disable-next-line no-console
39+
console.error("Could not load config. Will use default config.");
40+
configData = DEFAULT_CONFIG;
41+
return configData;
42+
})
43+
.finally(() => {
44+
activePromise = null;
45+
});
5146

5247
return activePromise;
5348
},

assets/admin/components/util/list/toast-component/display-toast.jsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@ export function displayError(errorString, error) {
2727
if (error && error["hydra:description"]) {
2828
errorText = error["hydra:description"];
2929
}
30-
if (error && error.data) {
31-
errorText = error.data["hydra:description"] || error.data.message;
30+
if (error?.data && typeof error.data === "object") {
31+
errorText = error.data["hydra:description"] || error.data.message || "";
3232
}
33-
if (error && error.error) {
33+
// RTK Query couldn't JSON-parse the response — typically an HTML error page
34+
// from nginx/php-fpm rejecting the request before Symfony (e.g. 413 when the
35+
// upload exceeds the proxy body-size limit). Show the HTTP status instead
36+
// of leaking the "Unexpected token '<'" SyntaxError to the user.
37+
if (!errorText && error?.status === "PARSING_ERROR") {
38+
errorText = error.originalStatus
39+
? `HTTP ${error.originalStatus}`
40+
: "Server returned an unexpected response";
41+
}
42+
if (!errorText && error?.error) {
3443
errorText = error.error;
3544
}
3645

0 commit comments

Comments
 (0)