Skip to content

Commit e443e98

Browse files
authored
Merge branch 'release/3.0.0' into feature/instantbook-busy-from-feed
2 parents 3b9f1e6 + ea0bb15 commit e443e98

39 files changed

Lines changed: 916 additions & 143 deletions

.env

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,17 @@ TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR
3030
###< symfony/framework-bundle ###
3131

3232
###> doctrine/doctrine-bundle ###
33+
# Doctrine `serverVersion` for dialect selection — must match the running
34+
# MariaDB. Default tracks the docker-compose default (mariadb:11.4);
35+
# override MARIADB_VERSION (and the matching MARIADB_IMAGE in compose) to
36+
# test against 10.11.
37+
MARIADB_VERSION=11.4.4-MariaDB
3338
# Database connection string for MariaDB/MySQL.
3439
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
3540
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
3641
#
3742
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
38-
DATABASE_URL="mysql://db:db@mariadb:3306/db?serverVersion=10.11.5-MariaDB"
43+
DATABASE_URL="mysql://db:db@mariadb:3306/db?serverVersion=${MARIADB_VERSION}"
3944
#DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
4045
###< doctrine/doctrine-bundle ###
4146

@@ -72,6 +77,13 @@ JWT_SCREEN_REFRESH_TOKEN_TTL=2592000
7277
REDIS_CACHE_PREFIX=DisplayApiService
7378
# Connection string for Redis cache server.
7479
REDIS_CACHE_DSN=redis://redis:6379/0
80+
# Session storage backend, consumed by framework.session.handler_id.
81+
# A `redis://...` DSN makes Symfony auto-build a RedisSessionHandler (with
82+
# its own `sf_s` key prefix, so cache and session keys don't collide on
83+
# the same DB). Empty value = PHP's native file handler; pick that for
84+
# single-pod deployments that don't care about flock-induced tail latency
85+
# or container-restart session survival.
86+
SESSION_HANDLER_DSN=${REDIS_CACHE_DSN}
7587
###< redis ###
7688

7789
###> Http Client ###
@@ -96,6 +108,9 @@ KEY_VAULT_JSON="{}"
96108
TRACK_SCREEN_INFO=false
97109
# Interval in seconds between screen info updates.
98110
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
99114
# Toggle the relations checksum optimisation for content updates.
100115
RELATIONS_CHECKSUM_ENABLED=true
101116
###< App ###
@@ -181,6 +196,10 @@ ADMIN_TOUCH_BUTTON_REGIONS=false
181196
ADMIN_LOGIN_METHODS='[{"type":"username-password","enabled":true,"provider":"username-password","label":""}]'
182197
# Enable enhanced preview mode with additional features.
183198
ADMIN_ENHANCED_PREVIEW=false
199+
# Optional HTML block shown in the login sidebar.
200+
# Allowed tags: strong, em, b, i, br, p, a, span. Allowed attrs: href, title, target, rel, class.
201+
# Leave empty to hide the sidebar text card entirely.
202+
ADMIN_LOGIN_SCREEN_TEXT=
184203
###< Admin configuration ###
185204

186205
###> Client configuration ###

.env.test

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SYMFONY_DEPRECATIONS_HELPER=999999
55
PANTHER_APP_ENV=panther
66
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
77

8-
DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=mariadb-10.5.13"
8+
DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=${MARIADB_VERSION}"
99

1010
###> lexik/jwt-authentication-bundle ###
1111
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
@@ -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

.github/workflows/build-images.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ jobs:
9898
uses: docker/metadata-action@v6
9999
with:
100100
images: ghcr.io/os2display/display-api-service-nginx
101+
# Override the source-repo defaults; without this the Nginx image
102+
# would publish title/description copied from display-api-service.
103+
labels: |
104+
org.opencontainers.image.title=display-api-service-nginx
105+
org.opencontainers.image.description=Nginx reverse proxy fronting the OS2Display API image
101106
tags: |
102107
# develop branch → 'develop'
103108
type=ref,event=branch

.github/workflows/doctrine.yaml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,20 @@ env:
1919
jobs:
2020
validate-doctrine-shema:
2121
runs-on: ubuntu-latest
22-
name: Validate Schema
22+
strategy:
23+
# Don't cancel the 11.x leg if 10.x fails (or vice versa); we want to
24+
# see version-specific failures rather than mask one with the other.
25+
fail-fast: false
26+
matrix:
27+
mariadb:
28+
- image: mariadb:10.11
29+
version: 10.11.13-MariaDB
30+
- image: mariadb:11.4
31+
version: 11.4.4-MariaDB
32+
name: Validate Schema (${{ matrix.mariadb.image }})
33+
env:
34+
MARIADB_IMAGE: ${{ matrix.mariadb.image }}
35+
MARIADB_VERSION: ${{ matrix.mariadb.version }}
2336
steps:
2437
- name: Checkout
2538
uses: actions/checkout@v6
@@ -38,7 +51,7 @@ jobs:
3851
run: docker compose run --rm -e APP_ENV=prod phpfpm composer install --no-dev -o
3952

4053
- name: Run Doctrine Migrations
41-
run: docker compose run --rm -e APP_ENV=prod phpfpm bin/console doctrine:migrations:migrate --no-interaction
54+
run: docker compose run --rm -e APP_ENV=prod -e MARIADB_VERSION phpfpm bin/console doctrine:migrations:migrate --no-interaction
4255

4356
- name: Validate Doctrine schema
44-
run: docker compose run --rm -e APP_ENV=prod phpfpm bin/console doctrine:schema:validate
57+
run: docker compose run --rm -e APP_ENV=prod -e MARIADB_VERSION phpfpm bin/console doctrine:schema:validate

.github/workflows/phpunit.yaml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,23 @@ env:
1919
jobs:
2020
phpunit:
2121
runs-on: ubuntu-latest
22-
name: PHP Unit tests
22+
strategy:
23+
# Don't cancel the 11.x leg if 10.x fails (or vice versa); we want to
24+
# see version-specific failures rather than mask one with the other.
25+
fail-fast: false
26+
matrix:
27+
mariadb:
28+
- image: mariadb:10.11
29+
version: 10.11.13-MariaDB
30+
- image: mariadb:11.4
31+
version: 11.4.4-MariaDB
32+
name: PHP Unit tests (${{ matrix.mariadb.image }})
33+
env:
34+
# Picked up by docker-compose.yml's `image: ${MARIADB_IMAGE:-…}`.
35+
MARIADB_IMAGE: ${{ matrix.mariadb.image }}
36+
# Forwarded into phpfpm via `-e MARIADB_VERSION` below; .env then
37+
# interpolates it into Doctrine's `serverVersion` URL parameter.
38+
MARIADB_VERSION: ${{ matrix.mariadb.version }}
2339
steps:
2440
- name: Checkout
2541
uses: actions/checkout@v6
@@ -38,7 +54,7 @@ jobs:
3854
run: docker compose run --rm phpfpm composer install
3955

4056
- name: PHP Unit - Test setup
41-
run: docker compose run --rm phpfpm composer run test-setup
57+
run: docker compose run --rm -e MARIADB_VERSION phpfpm composer run test-setup
4258

4359
- name: PHP Unit - Run tests
44-
run: docker compose run --rm phpfpm composer run test
60+
run: docker compose run --rm -e MARIADB_VERSION phpfpm composer run test

CHANGELOG.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,40 @@ All notable changes to this project will be documented in this file.
77
- Added `INSTANT_BOOK_BUSY_INTERVALS_SOURCE` to select between Graph and the slide's calendar feed as the source of busy
88
intervals for InstantBook.
99
- Changed polling interval for instant booking template.
10+
- Fixed admin toast leaking a raw `SyntaxError: Unexpected token '<'` when an upload was rejected
11+
upstream (e.g. nginx 413); the toast now shows `HTTP <status>` instead.
12+
- Made the media upload max size configurable via the new `MEDIA_MAX_UPLOAD_SIZE_MB` env var.
13+
- Fixed playlist share-target dropdown silently truncating to 30 tenants; it now loads every page.
1014
- Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types.
1115
- Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect
1216
throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted
1317
typos/string-interpolation issues.
14-
- Added `getBusyIntervals` cache (PT15M) with `@odata.nextLink` pagination and a shared `validateResourceAccess()`
15-
helper, eliminating per-poll Graph calls at the cost of up to 15-minute-stale availability in `quickBookOptions`
16-
(booking still 409s correctly).
18+
- Added `getBusyIntervals` cache (PT15M) with a shared `validateResourceAccess()` helper, eliminating per-poll Graph
19+
calls at the cost of up to 15-minute-stale availability in `quickBookOptions`.
20+
21+
## [3.0.0-rc3] - 2026-05-11
22+
23+
- Made the Admin login sidebar text configurable via the new `ADMIN_LOGIN_SCREEN_TEXT`
24+
env var. The value accepts a small allow-list of HTML tags (sanitized client-side
25+
with DOMPurify); when empty the sidebar card is hidden entirely. Removed the
26+
bundled Danish "medarbejder/borger MitID" copy that previously rendered by default.
27+
- Fixed login screen styling issue resulting in header not filling parent in some breakpoints.
1728
- Fixed Calendar and Colibo feed configuration urls and added [] result when no locationEndpoint is set.
1829
- Fixed baked-in `.env` shipping `APP_ENV=dev` in the API image; rewritten to `prod` at build time so
1930
direct reads don't try to bootstrap a dev environment the prod-only dependencies can't satisfy.
31+
- Aligned API and Nginx image labels with the OCI image spec: dropped deprecated `LABEL maintainer`,
32+
added `org.opencontainers.image.{authors,vendor,documentation,base.name}`, and fixed the Nginx image's
33+
`title`/`description` so it stops inheriting the source-repo defaults.
34+
- Bumped the local dev Redis image from `redis:6` to `redis:8`. Production deployments are unaffected
35+
(they bring their own Redis); Symfony 6.4's cache adapter and the bundled phpredis 6.3 work as-is.
36+
- Switched Symfony session storage to Redis (default `SESSION_HANDLER_DSN=${REDIS_CACHE_DSN}`); set
37+
`SESSION_HANDLER_DSN=` empty to fall back to PHP's native file handler. Removes the per-session
38+
`flock` that serialised parallel session-touching requests and lets sessions survive container
39+
restarts; multi-pod deployments now share session state without sticky routing.
40+
- Switched local dev MariaDB to upstream `mariadb:11.4` LTS (was `itkdev/mariadb:latest`); both 10.11
41+
and 11.4 LTS are now exercised by a CI matrix in the PHPUnit and Doctrine schema-validate workflows.
42+
`MARIADB_IMAGE` and `MARIADB_VERSION` env vars override the compose image and Doctrine
43+
`serverVersion`. Drops the previously-commented `ENCRYPT=1` toggle inherited from the itkdev wrapper.
2044

2145
## [3.0.0-rc2] - 2026-05-05
2246

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ The fixtures have an editor user: <editor@example.com> with the password: "apass
145145

146146
The fixtures have the image-text template, and two screen layouts: "full screen" and "two boxes".
147147

148+
### Database (MariaDB)
149+
150+
Local dev defaults to `mariadb:11.4` (LTS until May 2029). CI also exercises `mariadb:10.11` (LTS until
151+
Feb 2028) via a matrix in `phpunit.yaml` and `doctrine.yaml`. Two env vars control the version:
152+
153+
- `MARIADB_IMAGE` — the docker image used by the `mariadb` compose service.
154+
- `MARIADB_VERSION` — the Doctrine `serverVersion` interpolated into `DATABASE_URL` in `.env` /
155+
`.env.test`. Must match the running server, or Doctrine will emit dialect-incompatible SQL.
156+
157+
To run the local stack against 10.11:
158+
159+
```shell
160+
docker compose down -v
161+
MARIADB_IMAGE=mariadb:10.11 MARIADB_VERSION=10.11.13-MariaDB docker compose up -d
162+
```
163+
148164
## Production setup
149165

150166
A JWT Auth keypair should be generated. See [JWT Auth](#jwt-auth).
@@ -436,6 +452,7 @@ KEY_VAULT_SOURCE=ENVIRONMENT
436452
KEY_VAULT_JSON="{}"
437453
TRACK_SCREEN_INFO=false
438454
TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300
455+
MEDIA_MAX_UPLOAD_SIZE_MB=200
439456
###< App ###
440457
```
441458

@@ -451,6 +468,15 @@ TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300
451468
- EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS: What should the expire be for cache entries in EventDatabaseApiV2FeedType?
452469
- TRACK_SCREEN_INFO: Should screen info be tracked (true|false)?
453470
- 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.
454480

455481
### Admin configuration
456482

@@ -464,6 +490,7 @@ ADMIN_SHOW_SCREEN_STATUS=false
464490
ADMIN_TOUCH_BUTTON_REGIONS=false
465491
ADMIN_LOGIN_METHODS='[{"type":"username-password","enabled":true,"provider":"username-password","label":""}]'
466492
ADMIN_ENHANCED_PREVIEW=false
493+
ADMIN_LOGIN_SCREEN_TEXT=''
467494
###< Admin configuration ###
468495
```
469496

@@ -513,6 +540,16 @@ ADMIN_ENHANCED_PREVIEW=false
513540
See [Preview mode in the Client](#preview-mode-in-the-client).
514541

515542
**Default**: Disabled.
543+
- ADMIN_LOGIN_SCREEN_TEXT: Optional explanatory text rendered in the sidebar card on the Admin login page.
544+
Accepts a small allow-list of HTML tags (`strong`, `em`, `b`, `i`, `br`, `p`, `a`, `span`) and attributes
545+
(`href`, `title`, `target`, `rel`, `class`); the value is sanitized client-side with DOMPurify before being
546+
rendered. Leave empty to hide the sidebar card entirely.
547+
548+
```dotenv
549+
ADMIN_LOGIN_SCREEN_TEXT='<p>Er du <strong>medarbejder</strong> skal du benytte medarbejderlogin.</p><p>Er du <strong>borger</strong> skal du benytte MitID login.</p>'
550+
```
551+
552+
**Default**: Empty (no sidebar card shown).
516553

517554
### Client configuration
518555

@@ -561,6 +598,21 @@ CLIENT_DEBUG=false
561598

562599
**Default**: Disabled.
563600

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+
564616
### Other configuration options
565617

566618
- See `docs/configuration/openid-connect.md` for configuration of OpenID Connect.

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/navigation/login-sidebar/login-sidebar.jsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
1-
import { useTranslation, Trans } from "react-i18next";
1+
import { useEffect, useState } from "react";
2+
import DOMPurify from "dompurify";
3+
import AdminConfigLoader from "../../util/admin-config-loader";
24
import Logo from "../logo";
35

6+
const SANITIZE_OPTIONS = {
7+
ALLOWED_TAGS: ["strong", "em", "b", "i", "br", "p", "a", "span"],
8+
ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
9+
};
10+
411
const LoginSidebar = () => {
5-
const { t } = useTranslation("common", { keyPrefix: "login-sidebar" });
12+
const [customHtml, setCustomHtml] = useState("");
13+
14+
useEffect(() => {
15+
AdminConfigLoader.loadConfig().then((cfg) => {
16+
const raw = cfg?.loginScreenText?.trim();
17+
if (raw) setCustomHtml(DOMPurify.sanitize(raw, SANITIZE_OPTIONS));
18+
});
19+
}, []);
620

721
return (
822
<div className="background-image-screens p-4 col-md-4">
923
<Logo />
10-
<div className="card text-white bg-dark mb-3 border border-color-white">
11-
<div className="card-body">
12-
<p className="card-text" id="ad-explanation">
13-
<Trans>{t("internal-info-text")}</Trans>
14-
</p>
15-
<p className="card-text" id="mitid-explanation">
16-
<Trans>{t("external-info-text")}</Trans>
17-
</p>
24+
{customHtml && (
25+
<div className="card text-white bg-dark mb-3 border border-color-white">
26+
<div className="card-body">
27+
<div
28+
className="card-text"
29+
// eslint-disable-next-line react/no-danger
30+
dangerouslySetInnerHTML={{ __html: customHtml }}
31+
/>
32+
</div>
1833
</div>
19-
</div>
34+
)}
2035
</div>
2136
);
2237
};

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
/>

0 commit comments

Comments
 (0)