diff --git a/.coverage-baseline b/.coverage-baseline
new file mode 100644
index 000000000..90737ba31
--- /dev/null
+++ b/.coverage-baseline
@@ -0,0 +1 @@
+49.47
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..2f8e07500
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,36 @@
+# https://editorconfig.org
+
+# SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.yml]
+indent_size = 2
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.svg]
+insert_final_newline = false
+
+[package*.json]
+indent_size = 2
+indent_style = space
+
+[build/psalm-baseline.xml]
+indent_size = 2
+indent_style = space
+
+[config/*config.php]
+indent_size = 2
+indent_style = space
\ No newline at end of file
diff --git a/.forgejo/workflows/documentation.yml b/.forgejo/workflows/documentation.yml
deleted file mode 100644
index 5ccba1ce2..000000000
--- a/.forgejo/workflows/documentation.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: Publish docs
-
-# Docs deploy ONLY from the dedicated `documentation` branch — decoupled from main/development
-# so doc edits never trigger releases and code releases never trigger doc builds. No cron.
-on:
- push:
- branches: [documentation]
- pull_request:
- branches: [documentation]
- workflow_dispatch:
-
-jobs:
- build:
- uses: Conduction/.github/.forgejo/workflows/documentation-build.yml@main
- with:
- source-folder: docs
- secrets: inherit
-
- deploy:
- needs: build
- if: github.event_name != 'pull_request'
- uses: Conduction/.github/.forgejo/workflows/documentation-deploy.yml@main
- with:
- cf-project-name: launchpad-docs
- secrets: inherit
diff --git a/.forgejo/workflows/l10n-parity.yml b/.forgejo/workflows/l10n-parity.yml
deleted file mode 100644
index 3fbbf583a..000000000
--- a/.forgejo/workflows/l10n-parity.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-# Translation-PARITY hard gate.
-#
-# Asserts every required locale (nl/de/fr/es/it) is at full parity with the
-# English source — no missing keys, no empty values — for both the frontend
-# (l10n/*.js) and backend (l10n/*.json) translation sets. Without this, a new
-# English string ships and the other languages silently fall back to English.
-# Pure Node, no NC, no npm install.
-name: l10n-parity
-
-on:
- pull_request:
- push:
- branches: [main, master, development]
-
-jobs:
- l10n-parity:
- name: l10n translation parity (nl/de/fr/es/it)
- runs-on: codeberg-small
- container:
- image: node:20-alpine
- steps:
- - name: Checkout
- uses: https://code.forgejo.org/actions/checkout@v4
- - name: Assert every required locale is at full parity
- run: node tests/l10n/check-l10n-parity.js
diff --git a/.forgejo/workflows/pre-merge-check-strict.yaml b/.forgejo/workflows/pre-merge-check-strict.yaml
deleted file mode 100644
index 01aad5ad8..000000000
--- a/.forgejo/workflows/pre-merge-check-strict.yaml
+++ /dev/null
@@ -1,81 +0,0 @@
-# Pre-merge quality gate — runs composer check:strict + all 19 Hydra gates on every PR.
-# Configured as a required status check in branch protection on `development`
-# to keep the merge button disabled until this workflow passes.
-#
-# Diff-scoped per ADR-020 so legacy debt never blocks a PR — only new failures fail.
-
-name: pre-merge-check-strict
-
-on:
- pull_request:
- branches:
- - development
- - main
- - beta
-
-jobs:
- quality-gates:
- runs-on: codeberg-small
- container:
- # Docker Hub php:8.3-cli (Debian, ships bash) is reliably pullable on the
- # Codeberg runner; the previous code.forgejo.org/oci/ci-php:8.3 image could
- # not be pulled, so this job failed at setup on every PR and never ran.
- image: php:8.3-cli
- steps:
- - name: Install toolchain (git, unzip, composer, php-ext, node, python3)
- run: |
- set -eu
- apt-get update
- # nodejs is required by the JS-based actions/checkout that runs next;
- # php:8.3-cli ships no node, so without this the checkout step exits 127.
- apt-get install -y --no-install-recommends git unzip python3 nodejs curl ca-certificates
- # Precompiled PHP extensions (seconds) instead of docker-php-ext-install,
- # which compiles from source (~1m) and overran the tiny runner's time limit.
- # ext-xsl is required by the edgedesign/phpqa dev dependency in composer.lock.
- curl -sSLf -o /usr/local/bin/install-php-extensions \
- https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions
- chmod +x /usr/local/bin/install-php-extensions
- install-php-extensions zip mbstring xsl
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer --version
- php -v | head -1
- node --version
-
- - name: Checkout PR
- uses: https://code.forgejo.org/actions/checkout@v4
-
- - name: Install composer deps
- run: composer install --no-interaction --no-progress --prefer-dist
-
- - name: Run composer check:strict
- run: composer check:strict
-
- - name: Clone Hydra (for gate runner)
- uses: https://code.forgejo.org/actions/checkout@v4
- with:
- repository: Conduction/hydra
- ref: development
- path: .hydra
-
- - name: Run all 19 Hydra gates (diff-scoped per ADR-020)
- env:
- BASE_REF: ${{ github.base_ref }}
- run: |
- git fetch origin "$BASE_REF":"$BASE_REF" || true
- bash .hydra/scripts/run-hydra-gates.sh --scope-to-diff --base "origin/$BASE_REF" .
-
- - name: Gate-19 e2e coverage report (informational)
- if: always()
- run: |
- python3 .hydra/scripts/lib/check_e2e_coverage.py . --mode report || true
-
- js-lint:
- runs-on: codeberg-small
- container:
- image: node:20-alpine
- steps:
- - name: Checkout PR
- uses: https://code.forgejo.org/actions/checkout@v4
-
- - name: Run initial-state JS lint guard (REQ-INIT-003)
- run: node ./scripts/lint-initial-state.js
diff --git a/.forgejo/workflows/release-beta.yml b/.forgejo/workflows/release-beta.yml
deleted file mode 100644
index ed8215b18..000000000
--- a/.forgejo/workflows/release-beta.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Beta Release
-
-on:
- push:
- branches: [beta]
- workflow_dispatch:
-
-jobs:
- release:
- uses: Conduction/.github/.forgejo/workflows/release-semrel-beta.yml@main
- with:
- app-name: launchpad
- secrets: inherit
diff --git a/.forgejo/workflows/release-stable.yml b/.forgejo/workflows/release-stable.yml
deleted file mode 100644
index 2f54bf1eb..000000000
--- a/.forgejo/workflows/release-stable.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: Stable Release
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-jobs:
- release:
- uses: Conduction/.github/.forgejo/workflows/release-stable.yml@main
- with:
- app-name: launchpad
- secrets:
- CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
- NEXTCLOUD_SIGNING_KEY: ${{ secrets.NEXTCLOUD_SIGNING_KEY }}
- NEXTCLOUD_SIGNING_CERT: ${{ secrets.NEXTCLOUD_SIGNING_CERT }}
- NEXTCLOUD_APPSTORE_TOKEN: ${{ secrets.NEXTCLOUD_APPSTORE_TOKEN }}
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 41ba401f1..7cc99a5d3 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,2 +1,16 @@
-# Retrofit annotation commit (opsx-annotate, 2026-05-24)
-21a8c1f6ecf3a3b2346ed631bb469a8cd3b65e01
+# Revisions to skip in `git blame`.
+#
+# Enable locally, once:
+# git config blame.ignoreRevsFile .git-blame-ignore-revs
+#
+# GitHub reads this file automatically. Your terminal does not, until you run
+# the line above.
+#
+# Only ever add commits that change formatting and NOTHING else. A commit listed
+# here becomes invisible to blame, so a behaviour change hidden inside one would
+# be very hard to find later.
+
+# style: reformat with nextcloud/coding-standard — whitespace only
+# The fleet-wide move from a PEAR-derived PHPCS ruleset (4 spaces, next-line
+# braces) to Nextcloud's own standard (tabs, same-line braces).
+46e028e7046485d8e28cbc3bc786b7d577c83f51
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 000000000..798ae383e
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Committed pre-commit hook (activated via `git config core.hooksPath .githooks`,
+# which `npm install` / `composer install` set automatically — see package.json
+# "prepare" and composer.json "post-install-cmd").
+#
+# Regenerates docs/features.json whenever staged changes touch openspec/specs/
+# or the features overlay, so the commercial capability list can never go
+# stale. CI (features-check / features-extract) only VERIFIES — generation
+# happens here, before the commit, never in the pipeline.
+#
+# Best-effort by design: any failure only warns and never blocks the commit —
+# the CI gate is the enforcement backstop.
+
+if git diff --cached --name-only | grep -qE "^openspec/(specs/|features\.overlay\.json)"; then
+ CACHE=".git/extract-features.py"
+ # Fetch the canonical script (single source of truth in ConductionNL/.github);
+ # fall back to a previously cached copy when offline.
+ curl -sf --max-time 10 \
+ https://raw.githubusercontent.com/ConductionNL/.github/main/scripts/extract-features.py \
+ -o "$CACHE" 2>/dev/null || true
+
+ if [ -f "$CACHE" ]; then
+ if command -v python3 >/dev/null 2>&1; then PY="python3";
+ elif command -v py >/dev/null 2>&1; then PY="py -3";
+ else PY="python"; fi
+
+ if $PY "$CACHE" --app-root . >/dev/null 2>&1; then
+ git add docs/features.json
+ echo "pre-commit: docs/features.json regenerated from openspec/specs/."
+ else
+ echo "pre-commit: WARNING — could not regenerate docs/features.json (python or pyyaml missing?). CI features-check will verify." >&2
+ fi
+ else
+ echo "pre-commit: WARNING — could not fetch extract-features.py (offline?). CI features-check will verify." >&2
+ fi
+fi
+
+exit 0
diff --git a/.forgejo/issue_template/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
similarity index 100%
rename from .forgejo/issue_template/bug-report.yml
rename to .github/ISSUE_TEMPLATE/bug-report.yml
diff --git a/.forgejo/issue_template/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
similarity index 100%
rename from .forgejo/issue_template/feature-request.yml
rename to .github/ISSUE_TEMPLATE/feature-request.yml
diff --git a/.forgejo/issue_template/technical-task.yml b/.github/ISSUE_TEMPLATE/technical-task.yml
similarity index 100%
rename from .forgejo/issue_template/technical-task.yml
rename to .github/ISSUE_TEMPLATE/technical-task.yml
diff --git a/.forgejo/issue_template/user-story.yml b/.github/ISSUE_TEMPLATE/user-story.yml
similarity index 100%
rename from .forgejo/issue_template/user-story.yml
rename to .github/ISSUE_TEMPLATE/user-story.yml
diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml
deleted file mode 100644
index 7c7a24224..000000000
--- a/.github/workflows/branch-policy.yml
+++ /dev/null
@@ -1,69 +0,0 @@
-name: Branch Policy
-
-on:
- pull_request:
- branches: [main, beta, development]
-
-jobs:
- check-source-branch:
- name: Branch Policy Check
- runs-on: ubuntu-latest
- # Observed fleet-wide: n=31 runs, max 0.1 min. Loose bound to catch a hang, not to enforce speed.
- timeout-minutes: 10
- steps:
- - name: Verify source branch
- run: |
- SOURCE="${{ github.head_ref }}"
- TARGET="${{ github.base_ref }}"
-
- # hotfix/* is always allowed
- if [[ "$SOURCE" == hotfix/* ]]; then
- echo "✅ Hotfix branch '$SOURCE' is allowed to merge into '$TARGET'."
- exit 0
- fi
-
- case "$TARGET" in
- main)
- if [ "$SOURCE" = "beta" ]; then
- echo "✅ Branch '$SOURCE' is allowed to merge into main."
- else
- echo "❌ Branch '$SOURCE' is not allowed to merge into main."
- echo "Only 'beta' and 'hotfix/*' branches can be merged into main."
- exit 1
- fi
- ;;
- beta)
- if [ "$SOURCE" = "development" ]; then
- echo "✅ Branch '$SOURCE' is allowed to merge into beta."
- else
- echo "❌ Branch '$SOURCE' is not allowed to merge into beta."
- echo "Only 'development' and 'hotfix/*' branches can be merged into beta."
- exit 1
- fi
- ;;
- development)
- # Allow Conventional-Commits style prefixes plus Dependabot.
- if [[ "$SOURCE" == feature/* \
- || "$SOURCE" == feat/* \
- || "$SOURCE" == fix/* \
- || "$SOURCE" == chore/* \
- || "$SOURCE" == refactor/* \
- || "$SOURCE" == docs/* \
- || "$SOURCE" == test/* \
- || "$SOURCE" == perf/* \
- || "$SOURCE" == build/* \
- || "$SOURCE" == ci/* \
- || "$SOURCE" == style/* \
- || "$SOURCE" == spec/* \
- || "$SOURCE" == dependabot/* ]]; then
- echo "✅ Branch '$SOURCE' is allowed to merge into development."
- else
- echo "❌ Branch '$SOURCE' is not allowed to merge into development."
- echo "Allowed prefixes: feature/*, feat/*, fix/*, chore/*, refactor/*, docs/*, test/*, perf/*, build/*, ci/*, style/*, spec/*, hotfix/*, dependabot/*."
- exit 1
- fi
- ;;
- *)
- echo "✅ No branch policy for target '$TARGET'."
- ;;
- esac
diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml
index 1642dafe4..7ef08ceae 100644
--- a/.github/workflows/branch-protection.yml
+++ b/.github/workflows/branch-protection.yml
@@ -2,10 +2,10 @@ name: Branch Protection
on:
pull_request:
- branches:
- - main
- - beta
+ branches: [main, beta]
+
+permissions: {}
jobs:
- check:
+ branch-protection:
uses: ConductionNL/.github/.github/workflows/branch-protection.yml@main
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 334d03c42..3e313199d 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -2,17 +2,180 @@ name: Code Quality
on:
push:
- branches: [main, development, feature/**, bugfix/**, hotfix/**]
+ # DEFAULT BRANCHES ONLY. `pull_request` below carries every other branch.
+ #
+ # This was an allow-list of branch prefixes, and that was a gate with a
+ # SILENT hole: a branch matching nothing got no CI at all, and its last
+ # visible status was whatever it inherited — indistinguishable, on every
+ # dashboard, from a branch that passed. Two live examples, both found
+ # 2026-08-14: `perf/**` was uncovered in openconnector, where a merge
+ # carrying unresolved conflict markers and 84 failing tests was pushed and
+ # nothing ran; and `feat/**` was uncovered in openregister, because the
+ # list said `feature/**`.
+ #
+ # The comment that stood here said adding prefixes was not the durable fix,
+ # and that the durable fix was to let the pull_request trigger gate it.
+ # THIS IS THAT CHANGE.
+ #
+ # What forced it now: a push to a branch with an open PR ran the SAME 34
+ # jobs TWICE on the same commit. `concurrency` cannot dedupe them — the
+ # group is suffixed by event name deliberately (.github#540: a
+ # default-branch push carries jobs a PR run does not, and a dispatch must
+ # not be cancellable by a standing release PR), so the two events sit in
+ # different lanes BY DESIGN and both run to completion. Measured fleet-wide
+ # 2026-08-25..27, 659 of 2,106 Code Quality runs were that duplicate — 31%
+ # of the fleet's most expensive workflow, re-deciding a commit another run
+ # was already deciding. The account ceiling is 60 concurrent jobs (Team
+ # plan); the fleet was measured at 53 running with 1,528 jobs queued behind
+ # them, the oldest run 7 hours old and not yet started.
+ #
+ # NO BRANCH LOSES ITS FLOOR. merge-hygiene.yml runs on `'**'` — every
+ # branch anyone pushes, no prefix list to forget — and it is the check
+ # `development` actually requires. That is the smoke alarm; this workflow
+ # is the fire brigade and belongs on the PR. Of 668 feature-branch push
+ # runs in that window, only NINE were on a branch with no PR run beside
+ # them.
+ #
+ # The default branches STAY: their push runs are not duplicates, they are
+ # the only carrier of Coverage Baseline Check, SBOM and Features Extract,
+ # none of which run on a pull_request event.
+ branches:
+ - main
+ - development
pull_request:
branches: [main, beta, development]
workflow_dispatch:
+# Deduplicating a `push` run against the `pull_request` run for the SAME head
+# ref is the point of this block, and for a feature branch it is exactly right:
+# two runs of identical jobs, one of them wasted.
+#
+# It is wrong for `main` and `development`, because the push run there is NOT a
+# duplicate — it is the only carrier of the push-only jobs: "Coverage Baseline
+# Check" (`github.event_name == 'push'`), "SBOM" and "Features Extract". And
+# those two branches always have an open PR whose `head_ref` IS the branch
+# name: the standing "Release: merge development into beta" (#22 here).
+# `github.head_ref` on that PR run and `github.ref_name` on the push run both
+# render `development`, so both landed in the identical group
+# `quality-development`, and `cancel-in-progress` killed whichever started
+# first — always the push run, by a few seconds.
+#
+# Measured on this repo: 14 of the last 20 `development` push runs were
+# cancelled within ~70s of starting — e.g. 31047886687 (41s), 31038167672
+# (68s), 31034263500 (54s), 31031059278 (42s). That duration is the
+# discriminator: the shared workflow's `timeout-minutes: 45` cancellation lands
+# at 45m16s–45m28s, so these are concurrency kills. This repo is the worst hit
+# in the fleet.
+#
+# On the surviving PR run "Coverage Baseline Check" reports `skipped`, which is
+# CORRECT for a pull_request event and renders exactly like a pass. So the gate
+# appears on both runs and executes on neither — a dead gate of the
+# permanently-pending shape.
+#
+# Suffixing only the default-branch push keeps feature-branch dedup untouched
+# (`quality-feature/x` for both events, exactly as before) and gives the two
+# default branches' push runs a lane of their own.
+#
+# Proven in openconnector#1158: its first-ever completed `development` push run
+# (31048998594) executed Coverage Baseline Check, SBOM and Features Extract.
+concurrency:
+ # SUFFIXED BY EVENT NAME, not just by `-push`.
+ #
+ # The previous expression gave a push on `development` its own lane
+ # (`-push`) but left EVERYTHING ELSE sharing `quality-development` — and
+ # that is not a quiet lane: `Sync to Beta` keeps a PR open whose head_ref
+ # IS `development`, so its run computes the same group and is re-triggered
+ # on every merge.
+ #
+ # A `workflow_dispatch` therefore shared a group with that PR and was
+ # cancelled by it. Measured on shillinq 2026-08-21: dispatch 32487948678
+ # cancelled by pull_request run 32490160836 (head_branch `development`).
+ # A run someone deliberately asked for could essentially never complete.
+ #
+ # That reaches past ad-hoc verification: the fleet gate-drift sweep
+ # (.github#523) dispatches per app with `--ref development`, because
+ # `schedule:` cannot choose a branch. Under the old group those runs are
+ # cancelled and report neither pass nor fail — and a routine that produces
+ # no verdict is indistinguishable from one that never ran.
+ #
+ # This is hermiq's form, already live there. Pull requests keep the bare
+ # group (so a PR still supersedes its own earlier run); push, dispatch and
+ # schedule each get their own lane.
+ #
+ # THE BRANCH RESTRICTION IS GONE, because it contradicted the sentence above.
+ #
+ # The suffix used to apply only when `ref_name` was `main` or `development`,
+ # so on every OTHER branch push and pull_request computed the SAME group —
+ # and `cancel-in-progress` made them kill each other. That became reachable
+ # when the push allow-list widened on 2026-08-14 to include `feat/**`,
+ # `fix/**`, `perf/**`, `refactor/**` and `chore/**`: those branches now get
+ # both a push run and a pull_request run for one commit.
+ #
+ # `quality / Quality Report` is a `needs:`-gated aggregator and reports
+ # FAILURE when its dependencies are CANCELLED, so the collision shows up as a
+ # red gate on a PR that was never actually evaluated — and re-running collides
+ # the same way. Measured on openregister#2821: a push run left queued and a
+ # pull_request run cancelled, 18 seconds apart, on one commit.
+ #
+ # A branch name is not a unique lane when two event types can each produce a
+ # run for it, so the event is now always part of the key.
+ group: quality-${{ github.head_ref || github.ref_name }}${{ github.event_name != 'pull_request' && format('-{0}', github.event_name) || '' }}
+
+ # PUSH RUNS ARE NOT CANCELLED — and this has to be said HERE, not only in the
+ # shared workflow. .github#597 set `cancel-in-progress` on quality.yml itself,
+ # but a caller's own concurrency cancels the whole run before the called
+ # workflow's setting can apply, so that fix reached only the apps that declare
+ # no concurrency of their own. Measured 2026-08-28 over push runs on
+ # `development` since #597: 0 of 11 cancelled where the caller was silent, 7 of
+ # 13 (54%) cancelled where the caller still said `true`.
+ #
+ # An integration branch needs a verdict per commit: the run being cancelled is
+ # the only thing that would have said whether what just landed is sound, and
+ # its replacement is cancelled too. `pull_request` keeps cancelling, where
+ # superseding really is correct.
+ cancel-in-progress: ${{ github.event_name != 'push' }}
+
+# Permission CEILING for the called quality pipeline. GitHub statically
+# validates the called workflow's declared job permissions against this
+# grant — even for jobs that are disabled — so it must cover the maximum
+# any nested job declares: journeydoc-capture (contents+actions write),
+# update-baseline / features-extract (contents write), and the Quality
+# Report PR comment (issues / pull-requests write).
+permissions:
+ contents: write
+ actions: write
+ issues: write
+ pull-requests: write
+
jobs:
quality:
uses: ConductionNL/.github/.github/workflows/quality.yml@main
with:
app-name: launchpad
php-version: "8.3"
+ # Pinned, because the shared workflow's DEFAULT is '["stable31", "stable32"]'
+ # and stable31 CANNOT WORK here. `additional-apps` below installs
+ # openregister, which declares min-version="32" (its
+ # lib/ContextChat/ContentProvider.php implements
+ # OCP\ContextChat\IContentProvider, absent from core before NC32). On NC31
+ # `occ app:enable openregister` fails with
+ # App "Open Register" cannot be installed because it is not compatible
+ # with this version of the server.
+ # and the run continues anyway, because that failure is only a ::warning::.
+ # Every /apps/openregister/... call then returns Nextcloud's HTML 404 page.
+ #
+ # Order matters as much as membership: newman, playwright and
+ # journeydoc-capture all check out `fromJSON(nextcloud-test-refs)[0]` as
+ # their single server, so with the inherited default those three jobs ran
+ # entirely on the version openregister cannot load.
+ #
+ # THE LIST IS THE WHOLE DECLARED RANGE. This comment previously said
+ # "launchpad's own appinfo/info.xml floor stays at 29 … the NC32 constraint
+ # here is a property of the CI fixture, not of launchpad's code" — that is
+ # no longer true of the file it describes. info.xml on this branch declares
+ # , so 32 is the app's own
+ # floor, not a fixture artefact, and 32, 33 and 34 each get a leg.
+ nextcloud-test-refs: '["stable34", "stable32", "stable33"]'
enable-psalm: true
enable-phpstan: true
enable-phpmetrics: true
@@ -29,11 +192,25 @@ jobs:
# postman fixture-id wiring repaired and assertion drift
# resolved. 196 assertions / 0 failures locally.
#
- # Playwright remains disabled — the shared workflow's
- # PHP-built-in-server lifetime is the blocker (cross-repo PR
- # `ConductionNL/.github#37`). The `tests/e2e/global-setup.ts`
- # `ensureBundleBuilt()` helper is in place so once the shared
- # workflow lands, flipping the gate Just Works.
+ # - Playwright — ENABLED, against the root suite minus a measured
+ # exclusion list, NOT against the four-test `tests/e2e/ci/` subset
+ # it used to run.
+ #
+ # The subset was a deliberate "green floor that grows", and as a
+ # floor it worked. What it could not do is tell anyone the truth
+ # about coverage. gate-19 reads the ROOT `playwright.config.ts`;
+ # the workflow resolves `/playwright.config.ts`
+ # FIRST, so it read a different file — and nothing compared them.
+ # Measured 2026-08-10 (launchpad#82): **CI executed 4 tests while
+ # 113 existed**, and of **117 `@e2e` annotations only 9** were in
+ # files CI ran. gate-19 reported 71 scenarios covered; 4 had an
+ # executing test behind them.
+ #
+ # `tests/e2e` deliberately contains NO `playwright.config.ts`, so
+ # the workflow's fallback selects the root one — the same file
+ # gate-19 parses. The two cannot drift without an edit to that file.
+ # Its `testIgnore` names every excluded spec with the run that
+ # measured it (31367057618: 65 of 80 passed).
enable-phpunit: true
# Previously unset, so this inherited the shared workflow's default of
# '["stable31", "stable32"]'. Pinned explicitly with the NC floor raise to 32.
@@ -47,3 +224,159 @@ jobs:
nextcloud-test-refs: '["stable32"]'
enable-newman: true
newman-environment-path: "tests/integration/local.env.json"
+ # Creates the non-admin account the collection's authorization assertions
+ # need. Without it `{{regularUser}}` stays unresolved, the request arrives
+ # with junk credentials, and `POST /api/role-feature-permissions
+ # non-admin → 403` gets a 400 instead — failing while testing nothing about
+ # authorization.
+ #
+ # A SCRIPT, not an inline command: the shared workflow runs this through
+ # `eval ` UNQUOTED, so any shell metacharacter is parsed at the outer
+ # level. Measured on the Playwright equivalent, `( … ) && ( … )` is a syntax
+ # error and `sh -c '… ; …'` splits at the wrong level. `bash ` is one
+ # word and cannot be mis-parsed. Path is relative to `server/`.
+ newman-seed-command: bash apps/launchpad/tests/integration/seed.sh
+ # OpenRegister must be present for the integration suite to mean anything.
+ # The Newman collection asserts OpenRegister-backed behaviour — the
+ # AppHost observability engine behind /api/health and /api/metrics, and the
+ # dashboard objects behind the v2 manifest — so without it the suite was
+ # measuring a degraded instance. The job previously reported success only
+ # because `composer test:all` ended in `|| echo '…skipping'` and always
+ # exited 0; with that removed, 17 of 220 assertions surfaced as failures.
+ #
+ # `ref: development` is REQUIRED, not cosmetic. The default is `main`, and
+ # `lib/Service/Rbac/ObjectGrantResolver.php` — which
+ # ManifestController::fetchGrantedDashboards() resolves for the shared-
+ # dashboard source — exists only on `development`. Pinned to `main` the
+ # grant lookup would fail soft to owned-only and the suite would quietly
+ # test less than it appears to.
+ additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
+ enable-playwright: true
+ # Enforce E2E skip discipline. Re-added: #354 set this and #356 removed
+ # it 16 minutes later while rewriting this file's triggers -- not a
+ # revert, just a casualty of two edits landing together.
+ #
+ # It is now backed by a real measurement rather than an assumption. The
+ # gate finally EXECUTES here (it needed hydra-gates v1.10.0 via #359 and
+ # the path fix in ConductionNL/.github#595), and on development's current
+ # head it reported:
+ #
+ # 0/137 tests skipped (0.0%) across 30 spec files
+ # V1 spec files executing ZERO tests : 0
+ # V2 skips deferring to a deploy state CI decides: 0
+ # V3 skips/fixmes with no reason recorded : 0
+ # Every skip names a real absence and every spec file ran something.
+ #
+ # So launchpad passes the gate on real data, and turning it on can only
+ # keep it that way. tests/e2e/docs-screenshots.spec.ts does NOT trip the
+ # zero-test rule, which was the open question when this was first tried.
+ e2e-skip-blocking: true
+ # Names the real suite directory. It holds no config of its own, so the
+ # workflow falls back to the root `playwright.config.ts` — which is the
+ # file gate-19 reads. Same file, same testIgnore, no drift.
+ playwright-test-path: tests/e2e
+ # The grant spec needs a second, non-admin account to be the share
+ # recipient; a grant to yourself proves nothing.
+ #
+ # This was an inline `… user:add … || true`. The `|| true` was there for a
+ # real reason — the suite must survive a re-run against a warm instance —
+ # but it tolerated EVERYTHING, not just "already exists": a rejected
+ # password, a missing occ or a broken database all exited 0 and let
+ # Playwright start with no grantee. The script keeps the tolerance and
+ # narrows it to the postcondition that matters (the account exists
+ # afterwards), which it establishes by asking the instance. See
+ # tests/e2e/seed.test.sh, which asserts both arms — including the one
+ # that must fail.
+ playwright-seed-command: bash apps/launchpad/tests/e2e/seed.sh
+
+ # ── Frontend Check legs ──────────────────────────────────────────────
+ # `frontend-checks` defaults to `[]`, and an empty list means the shared
+ # workflow emits NO "Frontend Check" job at all — so `check:manifest` ran
+ # nowhere while the run still looked complete. It is a self-contained
+ # `node scripts/check-manifest.js`, which is what a leg has to be (each
+ # leg is a fresh job with its own checkout + `npm ci`).
+ # Measured on this tree before enabling: PASSES. It is enabled to keep it
+ # passing, not because it is currently broken.
+ # `test` is NOT listed: "Frontend Tests (unit)" already runs it.
+ #
+ # `format` (prettier --check) is listed because the shared workflow has NO
+ # prettier job of its own — `quality.yml` runs eslint and stylelint and
+ # mentions prettier ZERO times. This repo already carries
+ # `@nextcloud/prettier-config` and a `format` script, so without this leg
+ # `npm run format` never runs outside a developer's editor and the tree
+ # drifts straight back out of format between merges — the same inert-
+ # formatter failure mode that made the old `.prettierrc` worth deleting.
+ # Centralising the config never stopped drift; the gate does.
+ # Measured on this tree before enabling: PASSES, 237 of 279 tracked
+ # frontend files in scope (l10n/ and docs/ excluded via .prettierignore /
+ # .gitignore, which prettier 3 also reads).
+ # `check:schema-l10n` is a RATCHET, not a gate. Every string inside a form
+ # comes from the schema and is a key in THIS app's catalogue; an absent key
+ # renders the English source inside an otherwise translated form, silently.
+ # The fleet had 30,459 such strings, so this records the current count and
+ # fails only when it GROWS — burning it down stays an ordinary PR.
+ frontend-checks: '["check:manifest", "format", "check:schema-l10n"]'
+
+ # ── Coverage ratchet ─────────────────────────────────────────────────
+ # `enable-coverage-guard` defaults to FALSE, which is why both
+ # "Coverage Baseline Protection" and "Coverage Baseline Check" have only
+ # ever reported `skipped`. It needs two inputs this repo did not have,
+ # both added in this commit: `scripts/coverage-guard.php` (byte-identical
+ # to openregister's) and `.coverage-baseline` = 49.47, this repo's own
+ # measured coverage (11200 of 22642 statements) read from clover.xml in
+ # the `coverage-report` artifact of run 30911179742.
+ enable-coverage-guard: true
+
+ # ── Hydra mechanical gates ───────────────────────────────────────────
+ # `enable-hydra-gates` defaults to FALSE, so this tier has never executed
+ # here — the job reported `skipped`, which the Quality Report renders
+ # identically to a pass. Pinned to v1.0.1 so a change to the gate package
+ # cannot move this repo's verdict without a commit here.
+ # `enable-axe` deliberately NOT set: a vanilla Nextcloud 34 already carries
+ # serious/critical violations from core's own UI.
+ #
+ # v1.0.1 -> v1.3.0 (ConductionNL/.github#159). Two defects, one bump.
+ #
+ # 1. STALE. v1.0.1 is `f4d9756` (2026-08-03) and predates three gate
+ # fixes, so every Hydra Gates run this repo has ever made executed a
+ # script in which 16 gates reported PASS when their helper never ran
+ # (#147), gate-33 had no axe report to read and never said so (#148),
+ # and gates 6 and 7 reported PASS on an EMPTY scope (#149). A gate
+ # that reports PASS without running emits a tick identical to a real
+ # one, which is why nothing in this repo's history shows it.
+ #
+ # 2. RED. quality.yml is referenced `@main` while this package is
+ # PINNED, so the two can desync — and on 2026-08-05 they did. #164
+ # flipped `hydra-gates-require-full-coverage` to default TRUE, but
+ # the accounting that makes that flag survivable (NOT APPLICABLE, as
+ # distinct from a structural or a wiring gap) ships in the PACKAGE.
+ # So every pin older than `f7eaf2a` now fails the coverage assertion
+ # for gates it has no subject matter for. Measured diff-scoped,
+ # exactly as CI scopes it:
+ # v1.0.1 exit 98 FAIL — "GATES THAT DID NOT RUN: 24 33"
+ # v1.3.0 exit 0 PASS — those gates named NOT APPLICABLE
+ # The old pin was not merely stale, it was failing this repo's CI for
+ # a reason that had nothing to do with this repo.
+ #
+ # 3. DEAD AGAIN, same mechanism, third time (2026-08-06). The pin was
+ # the defect, not its value. quality.yml floats `@main` and executes
+ # gate scripts BY PATH inside the pinned package, so every new gate
+ # added at @main is a path that v1.3.0 does not contain:
+ #
+ # hydra-gates-ref 'v1.3.0' does not contain:
+ # scripts/axe-run.cjs
+ # scripts/lib/check_spec_anchors.py
+ # scripts/lib/check_form_labels.py
+ # scripts/lib/check_license_triangle.py
+ #
+ # The job failed for a reason that, again, had nothing to do with
+ # this repo — CI itself says so: "This is NOT a code-quality finding
+ # about your repository." Bumping the pin to today's tag would only
+ # reset the same expiry clock a fourth time.
+ #
+ # So: the pin is REMOVED, not bumped. The shared workflow defaults this
+ # input to `main` and states "CALLERS SHOULD NOT SET THIS AT ALL"; the
+ # rest of the fleet sets nothing (scholiq notes the omission is
+ # deliberate). Floating both halves keeps caller and callee in step,
+ # which is the only configuration in which a pinned path cannot expire.
+ enable-hydra-gates: true
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 181382787..1f7ccf072 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -1,13 +1,35 @@
name: Documentation
+# Publishes the docs site to the Cloudflare Worker that serves it.
+#
+# TRIGGERS ON `development`, NOT ON A `documentation` BRANCH. This file used to
+# listen on a branch called `documentation`; nobody has pushed to one since
+# 2026-05-25, so the site simply stopped being rebuilt while every docs change
+# merged to development satisfied its review and published nothing.
on:
push:
- branches: [documentation]
+ branches: [development]
pull_request:
- branches: [documentation]
+ branches: [development]
jobs:
deploy:
uses: ConductionNL/.github/.github/workflows/documentation.yml@main
+ # A reusable workflow receives NO secrets by default. Without this block the
+ # callee's publish step finds CF_API_TOKEN empty, skips itself on its own
+ # `if:` guard, and the run finishes GREEN having changed nothing -- the
+ # failure that left the fleet's docs sites on May builds. The names are the
+ # same on both sides; the org secrets really are CF_API_TOKEN/CF_ACCOUNT_ID.
+ secrets:
+ CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
with:
cname: launchpad.conduction.nl
+ # EVERY host this worker answers on, in FULL: wrangler reconciles the
+ # worker's triggers against this list, so a host left out is REMOVED and
+ # goes dark.
+ docs-hosts: launchpad.conduction.nl
+ # PINNED. Deriving the name is how a deploy goes green and reaches
+ # nobody: wrangler creates the derived worker and publishes there while
+ # the custom domains keep routing to the real one.
+ worker-name: launchpad-docs
diff --git a/.github/workflows/merge-hygiene.yml b/.github/workflows/merge-hygiene.yml
new file mode 100644
index 000000000..4852cee5e
--- /dev/null
+++ b/.github/workflows/merge-hygiene.yml
@@ -0,0 +1,111 @@
+name: Merge Hygiene
+
+# WHY THIS EXISTS, and why it is separate from Code Quality.
+#
+# On 2026-08-14 a merge of origin/development was committed and PUSHED to
+# `perf/predicted-page-fanout` with UNRESOLVED CONFLICT MARKERS in two files.
+# `lib/Service/SynchronizationService.php` did not parse. Eighty-four tests were
+# red. Nothing stopped it, and nothing reported it — because Code Quality's push
+# trigger allows only `[main, development, feature/**, bugfix/**, hotfix/**]`,
+# and `perf/**` matches none of them. The branch had no CI at all, so its last
+# visible state was green from before the branch existed.
+#
+# The lesson is not "add perf/** to the list" — that fixes this branch and leaves
+# the next prefix uncovered. Any branch anyone pushes should get at least the
+# checks that take seconds, so this runs on `**` and stays deliberately cheap:
+# no matrix, no containers, no dependencies, no Playwright. It is a smoke alarm,
+# not the fire brigade. Code Quality remains the real gate on PRs.
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: merge-hygiene-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ hygiene:
+ name: Conflict markers and PHP syntax
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # Conflict markers, anywhere in the tree we author. A marker means a merge
+ # was committed half-finished; every downstream signal from that commit is
+ # meaningless, so this fails first and says so plainly.
+ #
+ # Anchored to line start: `<<<<<<<` inside a string, a diff fixture or a
+ # docs example is legitimate and must not fail the build. Matching only at
+ # column 0 is what git itself writes.
+ - name: No unresolved conflict markers
+ run: |
+ set -euo pipefail
+ # SCOPED TO CODE, and to paths we author. A marker is only a defect
+ # where it would break something: prose that DOCUMENTS a conflict is
+ # legitimate, and so are agent-eval artifacts that capture one as
+ # sample output. openbuild failed this gate on
+ # `.claude/skills/create-pr/evals/.../summary.md` — a correct file.
+ #
+ # That matters more than the miss it allows. A gate that fails on
+ # correct files gets switched off, and takes the checks that were
+ # working with it; a marker in a markdown file breaks nothing.
+ if git grep -nE '^(<{7}|={7}|>{7})( |$)' -- \
+ '*.php' '*.js' '*.mjs' '*.ts' '*.vue' '*.json' '*.yml' '*.yaml' '*.css' '*.scss' \
+ ':!vendor' ':!node_modules' ':!*.lock' ':!tests/fixtures' ':!.claude' \
+ ':!**/evals/**' ':!**/fixtures/**' > /tmp/markers.txt; then
+ echo "::error::Unresolved merge conflict markers are committed. This branch does not build."
+ cat /tmp/markers.txt
+ exit 1
+ fi
+ echo "No conflict markers."
+
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+
+ # Every PHP file parses. A conflict marker is caught above, but so is any
+ # other way a file can be committed unparseable — and this is the check
+ # that would have failed within seconds of the merge landing.
+ - name: PHP syntax
+ run: |
+ set -euo pipefail
+ fail=0
+ while IFS= read -r f; do
+ php -l "$f" > /dev/null 2>&1 || { echo "::error file=$f::PHP syntax error"; php -l "$f" || true; fail=1; }
+ done < <(git ls-files '*.php' | grep -v '^vendor/' | grep -v '^tests/fixtures/')
+ exit "$fail"
+
+ # JSON that will not parse breaks register fragments and app metadata,
+ # and is the other thing a bad merge leaves behind.
+ #
+ # SCOPED TWICE, because each widening found another honest file. The
+ # first version parsed every tracked .json and died on tsconfig/eslint
+ # JSONC. The second still reached `lib/**/*.json`, which in openbuild
+ # includes an entire app TEMPLATE — `.vscode/settings.json` and all.
+ # A template is not this app's configuration, and an editor file is not
+ # loaded by anything. What is left is what OpenRegister actually reads.
+ #
+ # SCOPED, because the first version was not and failed immediately on
+ # honest files: editor and tooling configs (tsconfig, eslint, devcontainer)
+ # are JSONC — comments and trailing commas — which is valid for their
+ # consumers and invalid for a strict parser. A gate that fails on correct
+ # files is worse than no gate: it gets switched off, and takes the checks
+ # that were working with it. Only the JSON the app itself loads is checked.
+ - name: JSON parses
+ run: |
+ set -euo pipefail
+ fail=0
+ while IFS= read -r f; do
+ [ -f "$f" ] || continue
+ python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$f" \
+ || { echo "::error file=$f::invalid JSON"; fail=1; }
+ done < <(git ls-files 'composer.json' 'package.json' 'appinfo/*.json' 'lib/Settings/**/*.json' \
+ | grep -v '^vendor/' | grep -v '^node_modules/' \
+ | grep -v '/\.vscode/' | grep -v '^lib/Resources/template/')
+ exit "$fail"
diff --git a/.github/workflows/openspec-sync.yml b/.github/workflows/openspec-sync.yml
index faf0179fa..cd5de2c3a 100644
--- a/.github/workflows/openspec-sync.yml
+++ b/.github/workflows/openspec-sync.yml
@@ -1,5 +1,41 @@
name: OpenSpec Sync
+# RESTORED, on evidence that the condition which justified dropping it is gone.
+#
+# This caller was removed from `development` in #42 on 2026-08-04 for a good
+# reason: every run in its history was a startup failure reporting `jobs=0`.
+# That is the signature of a reusable workflow that never resolved — no log, no
+# step, no verdict, just a permanently red branch. As #42 put it, a
+# permanently-red workflow is worse than an absent one, because the next
+# genuine failure lands on an already-red branch and cannot be told apart from
+# the standing noise. That argument was correct.
+#
+# What has changed since:
+#
+# 1. The unresolvable ref was FIXED — on `main`, by 9a695ae on 2026-08-03
+# ("fix all 8 shared-workflow callers on main (wrong org)"), one day
+# BEFORE #42 dropped this file from development. The fix and the removal
+# crossed: `main` got the repair, `development` got the deletion, and
+# because main is 345 commits behind development the two never met.
+#
+# 2. It has now been observed working. Dispatched against main on
+# 2026-08-21, this exact caller ran to success and created 12 OpenSpec
+# issues — the first non-zero result this workflow has ever produced.
+#
+# On the PROJECT_TOKEN: #42 and openregister#2111 both attributed the failure
+# to an expired project-board PAT. That is not what blocks it here — launchpad
+# has no PROJECT_TOKEN secret at all, and the 2026-08-21 run succeeded anyway.
+# The token is for the project BOARD; the issues themselves are written with
+# the workflow's own github.token. The line below is kept so the board sync
+# starts working by itself if a token is ever added, and is harmless while the
+# secret is absent.
+#
+# Why it belongs on `development` and not only on `main`: the push trigger
+# below watches `development`, and GitHub reads the workflow file from the
+# branch being pushed. With the file only on main, that trigger could never
+# fire — which is why the sync had to be kicked by hand and why only main's 16
+# changes were covered while development's 28 went unsynced.
+
on:
push:
branches: [development]
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
deleted file mode 100644
index 0886f288d..000000000
--- a/.github/workflows/release-beta.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Beta Release
-
-on:
- push:
- branches:
- - beta
-
-jobs:
- release:
- uses: ConductionNL/.github/.github/workflows/release-beta.yml@main
- with:
- app-name: launchpad
- secrets: inherit
diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
deleted file mode 100644
index c7b8fa016..000000000
--- a/.github/workflows/release-stable.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Stable Release
-
-on:
- push:
- branches:
- - main
-
-jobs:
- release:
- uses: ConductionNL/.github/.github/workflows/release-stable.yml@main
- with:
- app-name: launchpad
- secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 000000000..1a4950612
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,53 @@
+name: Release
+
+# ONE release workflow for the whole fleet.
+#
+# Every app used to carry three caller files (`release-development.yml`,
+# `release-beta.yml`, `release-stable.yml`) pointing at two different shared
+# workflows. Those two drifted: the pair edited `appinfo/info.xml` in the
+# working tree only, so their tags named commits still showing the PREVIOUS
+# version — harmless for a timestamped dev build, wrong for a stable release,
+# because Nextcloud's release template asserts
+# [ "$APP_VERSION" = "v$(xpath info.xml //version)" ]
+#
+# `release.yml` is the strict one and is now the only one. It commits the bump
+# to a `release/v` branch, tags THAT commit, and opens a pull request
+# to bring the integration branch up — so the tag and the package agree without
+# anything pushing at a protected branch.
+
+on:
+ push:
+ branches: [main, beta, development]
+ workflow_dispatch:
+
+# Releases publish artifacts. A cancelled release is neither a success nor a
+# rollback — it is a half-published version — so queued runs wait rather than
+# cancelling the one in flight.
+concurrency:
+ group: release-${{ github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ unstable:
+ if: github.ref == 'refs/heads/development'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: unstable
+ app-name: launchpad
+ secrets: inherit
+
+ beta:
+ if: github.ref == 'refs/heads/beta'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: beta
+ app-name: launchpad
+ secrets: inherit
+
+ stable:
+ if: github.ref == 'refs/heads/main'
+ uses: ConductionNL/.github/.github/workflows/release.yml@main
+ with:
+ release-type: stable
+ app-name: launchpad
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index da994211e..5f731da4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,13 @@ bom-npm.cdx.json
/docs/sendent-analysis.md
/sendent-workspace-main/
/2026.*_sendent-workspace-main.zip
+
+# Local MCP server configuration — carries API keys, must never be committed.
+# A live n8n API key reached the tip tree of 37 local branches before this was added.
+.mcp.json
+
+# Agent/test scratch and tool caches — generated, never source.
+# Added by the 2026-08-25 fleet hygiene sweep (ADR-100 Decision 2).
+.stale/
+/.e2e-state/
+.phpunit.cache
diff --git a/.npmrc b/.npmrc
index e4e8835b7..6f51e200d 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,5 +1,17 @@
-# Supply-chain hardening: reject any npm package published less than
-# 24h ago. Compromised first-party-Conduction packages are excluded via
-# Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/*
-# releases, override per-install with `npm install --min-release-age=0`.
-min-release-age=0
+# Supply-chain hardening: npm will not install a version published less than
+# 2 days ago, so a compromised release has a window in which it can be pulled
+# before it reaches this repo.
+#
+# npm 11+ ONLY. On npm 10 `min-release-age` does not exist — `npm config get
+# min-release-age` answers `undefined` — so this file is INERT on that
+# toolchain and the guard is not in effect. `engines.npm` declares the floor
+# and CI runs Node 24, which bundles npm 11; every Node 22 release bundles
+# npm 10 and cannot enforce this. gate-84 checks the three move together.
+#
+# @conduction/* is exempt so our own same-day releases still resolve. Without
+# the exemption the cooldown does NOT fail loudly — it silently resolves
+# backwards: measured 2026-08-15, installing @conduction/nextcloud-vue on
+# release day picked 2.0.7 instead of 2.3.0 and exited 0. Only the named
+# packages are exempt; their own dependencies still follow the policy.
+min-release-age=2
+min-release-age-exclude[]=@conduction/*
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..2bd5a0a98
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 000000000..db5845325
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,20 @@
+getFinder()
+ ->notPath('vendor')
+ ->notPath('node_modules')
+ ->notPath('build')
+ ->in(__DIR__ . '/lib')
+ ->in(__DIR__ . '/tests');
+
+return $config;
diff --git a/.phpunit.result.cache b/.phpunit.result.cache
deleted file mode 100644
index 1bbcd6f31..000000000
--- a/.phpunit.result.cache
+++ /dev/null
@@ -1 +0,0 @@
-{"version":2,"defects":{"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsReturnsAllExpectedKeys":8,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesDefaultsWhenEmpty":8,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesStoredValues":8},"times":{"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsReturnsAllExpectedKeys":0.02,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesDefaultsWhenEmpty":0.002,"OCA\\LaunchPad\\Tests\\Unit\\Service\\AdminSettingsServiceTest::testGetSettingsUsesStoredValues":0.001}}
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..6de4d85bd
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,22 @@
+# NOTE: prettier 3 reads `.gitignore` as an ignore path too, so anything already
+# gitignored (js/, coverage/, phpmetrics/) is excluded even without an entry
+# here. The entries below are kept explicit anyway — an ignore that depends on
+# another file's contents is one refactor away from silently switching off.
+
+# Build output, vendored trees and the separate Docusaurus site.
+js/
+dist/
+build/
+node_modules/
+vendor/
+coverage/
+coverage-frontend/
+phpmetrics/
+docs/
+*.min.*
+
+# Generated by the translation workflow — `l10n/*.js` is emitted by the
+# Nextcloud l10n tooling (`OC.L10N.register(...)`, four-space indent). NOT
+# gitignored, so this entry is load-bearing: without it prettier and the
+# translation sync would rewrite the same 37 files in opposite directions.
+l10n/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..3e19f6a18
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,7 @@
+# Launchpad
+
+This is a standalone repository that happens to live under
+`nextcloud-docker-dev/workspace/server/apps-extra/`. The instructions in the
+parent `server/` directory (its `CLAUDE.md` / `AGENTS.md`) describe the
+Nextcloud server core and its bundled apps — they do **not** govern this repo.
+Treat launchpad's own conventions as authoritative here.
diff --git a/README.md b/README.md
index 63bf16293..d4911e6e3 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/appinfo/info.xml b/appinfo/info.xml
index d67680875..1413dcfcf 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -1,7 +1,7 @@
@@ -23,6 +23,9 @@
- **Widget styling** — Customize colors, borders, and titles for each individual widget
- **Compulsory widgets** — Admins can pin important widgets that users cannot remove
- **Full Nextcloud compatibility** — Works with every existing Nextcloud dashboard widget out of the box
+- **Role-based widget access** — Restrict which widget types a group of users may add, resolved from Nextcloud group membership
+- **Dashboard sharing** — Share a dashboard with specific users or groups, or publish a brute-force-protected read-only public link
+- **Group dashboards** — One shared dashboard per group, in addition to personal dashboards
Perfect for organizations that want consistent, curated dashboards for their teams while still giving users freedom to personalize.
@@ -41,13 +44,16 @@ Free and open source under the EUPL-1.2 license.
- **Widget-styling** — Pas kleuren, randen en titels aan voor elke individuele widget
- **Verplichte widgets** — Beheerders kunnen belangrijke widgets vastzetten die gebruikers niet kunnen verwijderen
- **Volledige Nextcloud-compatibiliteit** — Werkt direct met elke bestaande Nextcloud dashboard-widget
+- **Rolgebaseerde widget-toegang** — Beperk welke widget-types een gebruikersgroep mag toevoegen, op basis van Nextcloud-groepslidmaatschap
+- **Dashboards delen** — Deel een dashboard met specifieke gebruikers of groepen, of publiceer een tegen brute-force beveiligde alleen-lezen publieke link
+- **Groepsdashboards** — Eén gedeeld dashboard per groep, naast persoonlijke dashboards
Ideaal voor organisaties die consistente, samengestelde dashboards willen voor hun teams, terwijl gebruikers de vrijheid houden om te personaliseren.
Vrij en open source onder de EUPL-1.2-licentie.
]]>
- 1.0.5-unstable.11
- agpl
+ 1.0.16-unstable.20260829173006
+ EUPL-1.2
Conduction
LaunchPad
@@ -59,13 +65,19 @@ Vrij en open source onder de EUPL-1.2-licentie.
organization
dashboard
https://github.com/ConductionNL/launchpad
- https://github.com/ConductionNL/launchpad/discussions
https://github.com/ConductionNL/launchpad/issues
https://github.com/ConductionNL/launchpad
- https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/app-store.svg
- https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot.png
+ https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot-dashboard.png
+ https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot-widgets.png
+ https://raw.githubusercontent.com/ConductionNL/launchpad/main/img/screenshot-admin.png
+
- openregister
-
-
-
-
+
OCA\LaunchPad\BackgroundJob\OrphanedDataCleanupJob
+
+ OCA\LaunchPad\BackgroundJob\HealthPingRefreshJob
-
- OCA\LaunchPad\Repair\InitializeActions
-
- OCA\LaunchPad\Repair\SeedRolePermissions
-
- OCA\LaunchPad\Repair\RegisterBackgroundJobs
-
OCA\LaunchPad\Repair\InitializeActions
+
+ OCA\LaunchPad\Repair\ApplyActionBaseline
OCA\LaunchPad\Repair\PurgeOrphanedCascadeData
OCA\LaunchPad\Repair\RegisterBackgroundJobs
+
+ OCA\LaunchPad\Repair\ImportLaunchpadRegister
+
+ OCA\LaunchPad\Repair\SeedDefaultDashboard
+
+ OCA\LaunchPad\Repair\InitializeActions
+
+ OCA\LaunchPad\Repair\ApplyActionBaseline
+
+ OCA\LaunchPad\Repair\SeedRolePermissions
+
+ OCA\LaunchPad\Repair\RegisterBackgroundJobs
+
+ OCA\LaunchPad\Repair\ImportLaunchpadRegister
+
+ OCA\LaunchPad\Repair\SeedDefaultDashboard
+
-
- OCA\LaunchPad\Settings\LaunchPadAdmin
- OCA\LaunchPad\Settings\LaunchPadAdminSection
-
-
OCA\LaunchPad\Command\ExportCommand
@@ -133,12 +171,25 @@ Vrij en open source onder de EUPL-1.2-licentie.
OCA\LaunchPad\Command\DemoShowcasesListCommand
OCA\LaunchPad\Command\SetupCommand
-
- OCA\LaunchPad\Command\MigrateStorageToGroupFolder
-
- OCA\LaunchPad\Command\ToggleStorageSetting
+
+ OCA\LaunchPad\Settings\LaunchPadAdmin
+ OCA\LaunchPad\Settings\LaunchPadAdminSection
+
+
+
+
+
+ OCA\LaunchPad\Activity\Extension
+
+
+
LaunchPad
@@ -147,11 +198,4 @@ Vrij en open source onder de EUPL-1.2-licentie.
-5
-
-
-
- OCA\LaunchPad\Activity\Extension
-
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 7d8f9c2ca..366202446 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -3,14 +3,17 @@
declare(strict_types=1);
/**
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
return [
'routes' => [
// v2 runtime manifest (ADR-036 Decision 8). Registered FIRST so the
// literal '/api/manifest' segment is matched before any wildcard.
+ // First-time setup wizard (ADR-042) — the standard CnSetupWizard contract.
+ ['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'],
+ ['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']],
['name' => 'manifest#index', 'url' => '/api/manifest', 'verb' => 'GET'],
// Metrics and health
@@ -86,6 +89,21 @@
// dashboard does not exist.
['name' => 'dashboardApi#viewEvent', 'url' => '/api/dashboards/{uuid}/view-event', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
+ // REQ-TANLT-002: record a tile click. Authed users only; the
+ // controller short-circuits silently when the user has opted out
+ // or analytics is globally disabled (same reused REQ-ANLT-003/004/005
+ // gates as the dashboard view-event route above). Returns HTTP 204
+ // on success, 404 when the placement does not exist. `placementId`
+ // is constrained to digits so the router never confuses it with a
+ // literal segment.
+ ['name' => 'tileAnalytics#recordClick', 'url' => '/api/tile-click/{placementId}', 'verb' => 'POST',
+ 'requirements' => ['placementId' => '\d+']],
+ // REQ-TANLT-003: lets the frontend hook know whether tracking is
+ // currently active for the calling user, so it can suppress the
+ // record call without re-implementing the gate logic client-side.
+ ['name' => 'tileAnalytics#config', 'url' => '/api/tile-analytics/config', 'verb' => 'GET'],
+
// REQ-DASH-026: nested dashboard tree.
['name' => 'dashboardApi#tree', 'url' => '/api/dashboards/tree', 'verb' => 'GET'],
// REQ-DASH-027: slug-chain path resolution. The {path} placeholder
@@ -150,12 +168,18 @@
'url' => '/api/dashboards/{uuid}/public-shares/{id}', 'verb' => 'DELETE',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+', 'id' => '\d+']],
// Public (anonymous) share render and unlock (REQ-PSHR-004, REQ-PSHR-005).
- // Both are #[PublicPage] + #[NoCSRFRequired] on the controller methods.
- // Registered BEFORE the deep-link catch-all at the bottom.
- ['name' => 'publicShare#show', 'url' => '/s/{token}', 'verb' => 'GET',
+ // All #[PublicPage] + #[NoCSRFRequired] on the controller methods.
+ // Registered BEFORE the deep-link catch-all at the bottom. `/s/{token}`
+ // serves the anonymous read-only HTML page (page#publicShare); the SPA it
+ // boots fetches its data from `/s/{token}/data` (publicShare#show). The
+ // more-specific /data + /unlock segments are declared before the bare
+ // token page route so they win in matching.
+ ['name' => 'publicShare#show', 'url' => '/s/{token}/data', 'verb' => 'GET',
'requirements' => ['token' => '[A-Za-z0-9]+']],
['name' => 'publicShare#unlock', 'url' => '/s/{token}/unlock', 'verb' => 'POST',
'requirements' => ['token' => '[A-Za-z0-9]+']],
+ ['name' => 'page#publicShare', 'url' => '/s/{token}', 'verb' => 'GET',
+ 'requirements' => ['token' => '[A-Za-z0-9]+']],
// Kiosk playlist management endpoints (REQ-KIOSK-002). Owner-or-admin,
// `#[NoAdminRequired]` + service-layer per-dashboard guards. The literal
@@ -204,6 +228,21 @@
'url' => '/api/dashboards/{uuid}/reactions', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Mandatory-read acknowledgement endpoints (REQ-ACK-002..006).
+ // The `/report/{announcementKey}/csv` route is registered BEFORE the
+ // plain report route so the `/csv` suffix is matched first, and both
+ // come before the literal `/pending` and root POST routes.
+ ['name' => 'acknowledgement#reportCsv',
+ 'url' => '/api/acknowledgements/report/{announcementKey}/csv', 'verb' => 'GET',
+ 'requirements' => ['announcementKey' => '[A-Za-z0-9\-]+']],
+ ['name' => 'acknowledgement#report',
+ 'url' => '/api/acknowledgements/report/{announcementKey}', 'verb' => 'GET',
+ 'requirements' => ['announcementKey' => '[A-Za-z0-9\-]+']],
+ ['name' => 'acknowledgement#pending',
+ 'url' => '/api/acknowledgements/pending', 'verb' => 'GET'],
+ ['name' => 'acknowledgement#acknowledge',
+ 'url' => '/api/acknowledgements', 'verb' => 'POST'],
+
// Dashboard versioning endpoints (REQ-VERS-001..009).
// `{uuid}` is the dashboard UUID; `{versionNumber}` is the integer
// version number. Routes are registered BEFORE the personal
@@ -257,6 +296,11 @@
['name' => 'ruleApi#updateRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'PUT'],
['name' => 'ruleApi#deleteRule', 'url' => '/api/rules/{ruleId}', 'verb' => 'DELETE'],
+ // conditional-visibility-editor: read-only, non-persisting
+ // "preview as audience/date" — #[NoAdminRequired] on
+ // VisibilityPreviewController::preview().
+ ['name' => 'visibilityPreview#preview', 'url' => '/api/visibility/preview', 'verb' => 'POST'],
+
// Role-feature permissions (REQ-RFP-001..010). Admin-only — the
// controller calls `requireAdmin()` on every method. Sits with
// the rest of the admin-scoped routes; the duplicate
@@ -298,6 +342,11 @@
// streamer is intentionally NOT under `/api/...` because it
// returns binary bytes, not a JSON envelope.
['name' => 'resource#upload', 'url' => '/api/resources', 'verb' => 'POST'],
+ // Raw multipart upload — REQ-RES-014. Admin-only (same security as the
+ // base64 endpoint above); accepts a single `file` multipart field with
+ // no base64 so large images/GIFs never become a huge in-browser string.
+ // Registered before the wildcard `/resource/{filename}` streamer.
+ ['name' => 'resource#uploadMultipart', 'url' => '/api/resources/upload', 'verb' => 'POST'],
// Resource listing — REQ-RES-007. Logged-in user only (no admin
// gate); the listed names are already referenced from rendered
// dashboards so admin gating would lock dashboards out of their
@@ -326,6 +375,10 @@
// `{uuid}/preview-image` suffix matches first.
['name' => 'admin#uploadTemplatePreviewImage', 'url' => '/api/admin/templates/{uuid}/preview-image', 'verb' => 'POST',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Admin template re-sync (REQ-RESYNC-001). Registered BEFORE the
+ // `/api/admin/templates/{id}` wildcard routes so the literal
+ // `{id}/resync` suffix matches first, same as preview-image above.
+ ['name' => 'admin#resyncTemplate', 'url' => '/api/admin/templates/{id}/resync', 'verb' => 'POST'],
['name' => 'admin#getTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'GET'],
['name' => 'admin#updateTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'PUT'],
['name' => 'admin#deleteTemplate', 'url' => '/api/admin/templates/{id}', 'verb' => 'DELETE'],
@@ -427,6 +480,18 @@
['name' => 'analytics#dashboardDetail', 'url' => '/api/admin/analytics/dashboards/{uuid}', 'verb' => 'GET',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+ // Tile usage-analytics admin endpoints (REQ-TANLT-004..005) — a
+ // strict downward extension of the dashboard view-analytics admin
+ // endpoints above. All admin-only via ADR-023 action authorization
+ // inside the controller. The literal `top` and `export` segments
+ // and the `by-dashboard` prefix precede any wildcard so the router
+ // never confuses them.
+ ['name' => 'tileAnalytics#topTiles', 'url' => '/api/admin/analytics/tiles/top', 'verb' => 'GET'],
+ ['name' => 'tileAnalytics#exportCsv', 'url' => '/api/admin/analytics/tiles/export', 'verb' => 'GET'],
+ ['name' => 'tileAnalytics#dashboardBreakdown',
+ 'url' => '/api/admin/analytics/tiles/by-dashboard/{uuid}', 'verb' => 'GET',
+ 'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],
+
// Background feed-refresh trigger (REQ-FRJ-010). Admin-only via
// runtime `IGroupManager::isAdmin` check inside the controller.
['name' => 'admin#refreshFeedsNow', 'url' => '/api/admin/feeds/refresh-now', 'verb' => 'POST'],
@@ -479,6 +544,41 @@
'url' => '/api/admin/demo-showcases/{id}', 'verb' => 'DELETE',
'requirements' => ['id' => '[a-z0-9\-]+']],
+ // Weather widget — cached reading for one placement (REQ-WEATHER-001).
+ // View-time ACL guarded in the controller; never returns the provider
+ // API key or raw provider URL.
+ ['name' => 'weather#show', 'url' => '/api/weather/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
+ // Live-data tile widget — cached, resolved value for one placement
+ // (REQ-LIVETILE-003). View-time ACL guarded in the controller; never
+ // returns the source URL, headers, or credentials. The two
+ // multi-segment routes below are registered BEFORE the single-segment
+ // `{placementId}` route so a literal `connector/status` /
+ // `validate-source` path is never mistaken for a numeric placement id.
+ ['name' => 'liveTile#connectorStatus', 'url' => '/api/livetile/connector/status', 'verb' => 'GET'],
+ ['name' => 'liveTile#validateSource', 'url' => '/api/livetile/validate-source', 'verb' => 'POST'],
+ ['name' => 'liveTile#show', 'url' => '/api/livetile/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
+ // Iframe-embed widget — save-time allow-list validation
+ // (REQ-IFRAME-002). No per-placement data endpoint: the browser
+ // embeds the target URL directly, config lives in `widgetContent`.
+ ['name' => 'iframe#validateUrl', 'url' => '/api/iframe/validate-url', 'verb' => 'POST'],
+ // Server-side framing-refusal check (REQ-IFRAME-003) — the browser
+ // cannot detect an X-Frame-Options / frame-ancestors block, so the
+ // widget asks the server before rendering the iframe.
+ ['name' => 'iframe#checkFramable', 'url' => '/api/iframe/framable', 'verb' => 'POST'],
+
+ // Service health ping — cached online/offline/degraded badge for one
+ // placement (REQ-HPING-003). View-time ACL guarded in the controller;
+ // never returns the health URL, headers, or upstream response body.
+ // The literal `validate` route is registered BEFORE the single-segment
+ // `{placementId}` route so it is never mistaken for a numeric placement id.
+ ['name' => 'healthPing#validate', 'url' => '/api/health-ping/validate', 'verb' => 'POST'],
+ ['name' => 'healthPing#show', 'url' => '/api/health-ping/{placementId}', 'verb' => 'GET',
+ 'requirements' => ['placementId' => '\d+']],
+
// Resolve a dashboard's canonical slug-chain path (used by the
// frontend for outbound URL sync after a sidebar switch).
// Registered BEFORE the catch-all deep-link route so the literal
diff --git a/composer.json b/composer.json
index 82cd9233e..70c89abd7 100644
--- a/composer.json
+++ b/composer.json
@@ -1,101 +1,104 @@
{
- "name": "conductionnl/launchpad",
- "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud",
- "type": "project",
- "license": "EUPL-1.2",
- "authors": [
- {
- "name": "LaunchPad Contributors"
- }
- ],
- "require": {
- "php": "^8.3"
- },
- "require-dev": {
- "cyclonedx/cyclonedx-php-composer": "^6.2",
- "edgedesign/phpqa": "^1.27",
- "nextcloud/coding-standard": "^1.4",
- "nextcloud/ocp": "^31.0",
- "phpcsstandards/phpcsextra": "^1.4",
- "phpmd/phpmd": "^2.15",
- "phpmetrics/phpmetrics": "^2.8",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^10",
- "roave/security-advisories": "dev-latest",
- "squizlabs/php_codesniffer": "^3.9",
- "twig/twig": "^3.27.0",
- "vimeo/psalm": "^5.26"
- },
- "autoload": {
- "psr-4": {
- "OCA\\LaunchPad\\": "lib/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "OCP\\": "vendor/nextcloud/ocp/OCP/",
- "NCU\\": "vendor/nextcloud/ocp/NCU/",
- "Unit\\": "tests/Unit/"
- }
- },
- "scripts": {
- "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
- "lint:initial-state": "php scripts/lint-initial-state.php",
- "lint:spec-annotations": "php tools/check-spec-annotations.php",
- "cs:check": "./vendor/bin/phpcs --standard=phpcs.xml",
- "cs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
- "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
- "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
- "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json",
- "phpmd": "vendor/bin/phpmd lib text phpmd.xml",
- "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
- "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
- "psalm": "./vendor/bin/psalm --threads=1 --no-cache || echo 'Psalm not installed, skipping...'",
- "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
- "test": "phpunit --configuration phpunit.xml",
- "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
- "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always || echo 'Tests require Nextcloud environment, skipping...'",
- "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi",
- "newman": "@test:integration",
- "newman:coverage": "node tests/integration/.coverage-check.js",
- "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
- "fix": [
- "@cs:fix"
- ],
- "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa",
- "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0",
- "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint",
- "qa:check": [
- "@phpqa"
- ],
- "qa:full": [
- "@phpqa:full"
- ],
- "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
- "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
- "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
- "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
- "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'",
- "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'",
- "quality:score": [
- "@quality:phpcs-score",
- "@quality:phpmd-score",
- "@quality:psalm-score",
- "@quality:phpstan-score"
- ]
- },
- "config": {
- "allow-plugins": {
- "composer/package-versions-deprecated": true,
- "dealerdirect/phpcodesniffer-composer-installer": true,
- "cyclonedx/cyclonedx-php-composer": true
- },
- "optimize-autoloader": true,
- "sort-packages": true,
- "platform": {
- "php": "8.3"
- }
- }
+ "name": "conductionnl/launchpad",
+ "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud",
+ "type": "project",
+ "license": "EUPL-1.2",
+ "authors": [
+ {
+ "name": "LaunchPad Contributors"
+ }
+ ],
+ "require": {
+ "php": "^8.3"
+ },
+ "require-dev": {
+ "conduction/coding-standard": "^1.0",
+ "conduction/hydra-gates": "^1.10.0",
+ "cyclonedx/cyclonedx-php-composer": "^6.2",
+ "edgedesign/phpqa": "^1.27",
+ "nextcloud/ocp": "^34.0",
+ "phpcsstandards/phpcsextra": "^1.4",
+ "phpmd/phpmd": "^2.15",
+ "phpmetrics/phpmetrics": "^2.8",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^10",
+ "roave/security-advisories": "dev-latest",
+ "squizlabs/php_codesniffer": "^4.0",
+ "twig/twig": "^3.27.0",
+ "vimeo/psalm": "^5.26"
+ },
+ "autoload": {
+ "psr-4": {
+ "OCA\\LaunchPad\\": "lib/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Unit\\": "tests/Unit/"
+ }
+ },
+ "scripts": {
+ "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
+ "lint:initial-state": "php scripts/lint-initial-state.php",
+ "lint:spec-annotations": "php tools/check-spec-annotations.php",
+ "lint:licenses": "bash scripts/check-license-headers.sh",
+ "cs:check": "php-cs-fixer fix --dry-run --diff",
+ "cs:fix": "php-cs-fixer fix",
+ "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
+ "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
+ "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json",
+ "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml --baseline-file phpmd.baseline.xml || E=$?; exit $E",
+ "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
+ "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
+ "psalm": "./vendor/bin/psalm --threads=1 --no-cache",
+ "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
+ "test": "phpunit --configuration phpunit.xml",
+ "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
+ "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always",
+ "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi",
+ "newman": "@test:integration",
+ "newman:coverage": "node tests/integration/.coverage-check.js",
+ "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations lint:licenses phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E",
+ "fix": [
+ "@cs:fix"
+ ],
+ "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa",
+ "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0",
+ "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint",
+ "qa:check": [
+ "@phpqa"
+ ],
+ "qa:full": [
+ "@phpqa:full"
+ ],
+ "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
+ "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
+ "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
+ "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
+ "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'",
+ "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'",
+ "quality:score": [
+ "@quality:phpcs-score",
+ "@quality:phpmd-score",
+ "@quality:psalm-score",
+ "@quality:phpstan-score"
+ ],
+ "post-install-cmd": [
+ "git config core.hooksPath .githooks || true"
+ ]
+ },
+ "config": {
+ "allow-plugins": {
+ "composer/package-versions-deprecated": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "cyclonedx/cyclonedx-php-composer": true
+ },
+ "optimize-autoloader": true,
+ "sort-packages": true,
+ "platform": {
+ "php": "8.3"
+ }
+ }
}
diff --git a/composer.lock b/composer.lock
index a7676d2c5..9544ed713 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b298f765de93d95487463f84a88603b1",
+ "content-hash": "33539639b3197093b06dea396b5b8a91",
"packages": [],
"packages-dev": [
{
@@ -460,6 +460,110 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
+ {
+ "name": "conduction/coding-standard",
+ "version": "v1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ConductionNL/coding-standard.git",
+ "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ConductionNL/coding-standard/zipball/a1854f13cb735e46ecd010767593b4d7bc90d974",
+ "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974",
+ "shasum": ""
+ },
+ "require": {
+ "nextcloud/coding-standard": "^1.4",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Conduction\\CodingStandard\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "EUPL-1.2"
+ ],
+ "authors": [
+ {
+ "name": "Conduction",
+ "homepage": "https://conduction.nl"
+ }
+ ],
+ "description": "Conduction coding standards for the PHP CS Fixer. Extends nextcloud/coding-standard — adds rules, never overrides them.",
+ "homepage": "https://github.com/ConductionNL/coding-standard",
+ "keywords": [
+ "coding-standard",
+ "conduction",
+ "dev",
+ "nextcloud",
+ "php-cs-fixer"
+ ],
+ "support": {
+ "docs": "https://docs.conduction.nl/WayOfWork/ci-cd/",
+ "issues": "https://github.com/ConductionNL/coding-standard/issues",
+ "source": "https://github.com/ConductionNL/coding-standard/tree/v1.0.0"
+ },
+ "time": "2026-08-12T08:27:21+00:00"
+ },
+ {
+ "name": "conduction/hydra-gates",
+ "version": "v1.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ConductionNL/.github.git",
+ "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/d143bc27aecbb44a66c843dba89d374628bf9eef",
+ "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "bin": [
+ "hydra-gates/bin/hydra-gates"
+ ],
+ "type": "library",
+ "extra": {
+ "hydra-gates": {
+ "runner": "hydra-gates/scripts/run-hydra-gates.sh",
+ "helpers": "hydra-gates/scripts/lib",
+ "schemas": "hydra-gates/scripts/schemas"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "EUPL-1.2"
+ ],
+ "authors": [
+ {
+ "name": "Conduction",
+ "homepage": "https://conduction.nl"
+ }
+ ],
+ "description": "Hydra's mechanical quality gates, packaged so any repo can run them against its own diff. The exit code is the failure COUNT.",
+ "homepage": "https://github.com/ConductionNL/.github/tree/main/hydra-gates",
+ "keywords": [
+ "conduction",
+ "gates",
+ "nextcloud",
+ "quality",
+ "static-analysis"
+ ],
+ "support": {
+ "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md",
+ "issues": "https://github.com/ConductionNL/.github/issues",
+ "source": "https://github.com/ConductionNL/.github/tree/v1.10.0"
+ },
+ "time": "2026-08-27T16:18:04+00:00"
+ },
{
"name": "consolidation/annotated-command",
"version": "4.10.5",
@@ -985,16 +1089,16 @@
},
{
"name": "dealerdirect/phpcodesniffer-composer-installer",
- "version": "v1.2.0",
+ "version": "v1.2.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/composer-installer.git",
- "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1"
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1",
- "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
"shasum": ""
},
"require": {
@@ -1077,7 +1181,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-11-11T04:32:07+00:00"
+ "time": "2026-05-06T08:26:05+00:00"
},
{
"name": "dflydev/dot-access-data",
@@ -1531,16 +1635,16 @@
},
{
"name": "kubawerlos/php-cs-fixer-custom-fixers",
- "version": "v3.37.1",
+ "version": "v3.37.2",
"source": {
"type": "git",
"url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git",
- "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804"
+ "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/e0ec1f602a1d0836909e9079262dbaf58eaf3804",
- "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804",
+ "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/678df979ce743466b42ddb6eea46b3f4c9a7bade",
+ "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade",
"shasum": ""
},
"require": {
@@ -1571,7 +1675,7 @@
"description": "A set of custom fixers for PHP CS Fixer",
"support": {
"issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues",
- "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.1"
+ "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.2"
},
"funding": [
{
@@ -1579,7 +1683,7 @@
"type": "github"
}
],
- "time": "2026-04-28T16:41:56+00:00"
+ "time": "2026-05-12T16:22:19+00:00"
},
{
"name": "league/container",
@@ -1776,16 +1880,16 @@
},
{
"name": "nextcloud/coding-standard",
- "version": "v1.4.0",
+ "version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/nextcloud/coding-standard.git",
- "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011"
+ "reference": "80547a93236fbb9c783e05f0f0899043851b0dba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011",
- "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011",
+ "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/80547a93236fbb9c783e05f0f0899043851b0dba",
+ "reference": "80547a93236fbb9c783e05f0f0899043851b0dba",
"shasum": ""
},
"require": {
@@ -1815,35 +1919,36 @@
],
"support": {
"issues": "https://github.com/nextcloud/coding-standard/issues",
- "source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0"
+ "source": "https://github.com/nextcloud/coding-standard/tree/v1.5.0"
},
- "time": "2025-06-19T12:27:27+00:00"
+ "time": "2026-05-19T18:30:09+00:00"
},
{
"name": "nextcloud/ocp",
- "version": "v31.0.9",
+ "version": "v34.0.3",
"source": {
"type": "git",
"url": "https://github.com/nextcloud-deps/ocp.git",
- "reference": "abd32429d794ede1d92b7b0a88a1070371c907b5"
+ "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/abd32429d794ede1d92b7b0a88a1070371c907b5",
- "reference": "abd32429d794ede1d92b7b0a88a1070371c907b5",
+ "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/3fb764be792476e4dcf1593101d978fc1dc8ac9a",
+ "reference": "3fb764be792476e4dcf1593101d978fc1dc8ac9a",
"shasum": ""
},
"require": {
- "php": "~8.1 || ~8.2 || ~8.3 || ~8.4",
+ "php": "~8.2 || ~8.3 || ~8.4 || ~8.5",
"psr/clock": "^1.0",
"psr/container": "^2.0.2",
"psr/event-dispatcher": "^1.0",
+ "psr/http-client": "^1.0.3",
"psr/log": "^3.0.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-stable31": "31.0.0-dev"
+ "dev-stable34": "34.0.0-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1863,9 +1968,9 @@
"description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API",
"support": {
"issues": "https://github.com/nextcloud-deps/ocp/issues",
- "source": "https://github.com/nextcloud-deps/ocp/tree/v31.0.9"
+ "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.3"
},
- "time": "2025-07-31T00:57:37+00:00"
+ "time": "2026-08-07T02:03:36+00:00"
},
{
"name": "nikic/php-parser",
@@ -2461,16 +2566,16 @@
},
{
"name": "php-cs-fixer/shim",
- "version": "v3.95.1",
+ "version": "v3.95.18",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/shim.git",
- "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a"
+ "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
- "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a",
+ "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/9b815f2ba5c581faaaec1386dcda4c16d511e6bb",
+ "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb",
"shasum": ""
},
"require": {
@@ -2507,27 +2612,27 @@
"description": "A tool to automatically fix PHP code style",
"support": {
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
- "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.1"
+ "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.18"
},
- "time": "2026-04-12T17:00:34+00:00"
+ "time": "2026-07-30T15:46:28+00:00"
},
{
"name": "phpcsstandards/phpcsextra",
- "version": "1.5.0",
+ "version": "1.5.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
- "reference": "b598aa890815b8df16363271b659d73280129101"
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101",
- "reference": "b598aa890815b8df16363271b659d73280129101",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c",
+ "reference": "39467533fdb742446d68c1d10ac33d625ee0311c",
"shasum": ""
},
"require": {
"php": ">=5.4",
- "phpcsstandards/phpcsutils": "^1.2.0",
+ "phpcsstandards/phpcsutils": "^1.2.3",
"squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
@@ -2591,20 +2696,20 @@
"type": "thanks_dev"
}
],
- "time": "2025-11-12T23:06:57+00:00"
+ "time": "2026-07-27T11:13:17+00:00"
},
{
"name": "phpcsstandards/phpcsutils",
- "version": "1.2.2",
+ "version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
- "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55"
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
- "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f",
+ "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f",
"shasum": ""
},
"require": {
@@ -2684,7 +2789,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-12-08T14:27:58+00:00"
+ "time": "2026-07-27T10:28:41+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -2946,16 +3051,16 @@
},
{
"name": "phpmetrics/phpmetrics",
- "version": "v2.9.1",
+ "version": "v2.11.0",
"source": {
"type": "git",
"url": "https://github.com/phpmetrics/PhpMetrics.git",
- "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3"
+ "reference": "55c5e23b34afb8fa9b6c6ea95cbd59b710160235"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpmetrics/PhpMetrics/zipball/e2e68ddd1543bc3f44402c383f7bccb62de1ece3",
- "reference": "e2e68ddd1543bc3f44402c383f7bccb62de1ece3",
+ "url": "https://api.github.com/repos/phpmetrics/PhpMetrics/zipball/55c5e23b34afb8fa9b6c6ea95cbd59b710160235",
+ "reference": "55c5e23b34afb8fa9b6c6ea95cbd59b710160235",
"shasum": ""
},
"require": {
@@ -3004,7 +3109,7 @@
],
"support": {
"issues": "https://github.com/PhpMetrics/PhpMetrics/issues",
- "source": "https://github.com/phpmetrics/PhpMetrics/tree/v2.9.1"
+ "source": "https://github.com/phpmetrics/PhpMetrics/tree/v2.11.0"
},
"funding": [
{
@@ -3012,7 +3117,7 @@
"type": "github"
}
],
- "time": "2025-09-25T05:21:02+00:00"
+ "time": "2026-08-09T06:58:42+00:00"
},
{
"name": "phpowermove/docblock",
@@ -3115,15 +3220,15 @@
},
{
"name": "phpstan/phpstan",
- "version": "1.12.33",
+ "version": "2.2.9",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1",
- "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0",
+ "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0"
+ "php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
@@ -3142,6 +3247,17 @@
"license": [
"MIT"
],
+ "authors": [
+ {
+ "name": "Ondřej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
@@ -3164,7 +3280,7 @@
"type": "github"
}
],
- "time": "2026-02-28T20:30:03+00:00"
+ "time": "2026-08-22T07:38:16+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -3747,6 +3863,111 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
{
"name": "psr/log",
"version": "3.0.2",
@@ -3803,18 +4024,19 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "87a281378fdad8f5926efe259f6ca72e7a395e68"
+ "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/87a281378fdad8f5926efe259f6ca72e7a395e68",
- "reference": "87a281378fdad8f5926efe259f6ca72e7a395e68",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/3c9ad688ad8826203588ec49363f73f4deb590c1",
+ "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1",
"shasum": ""
},
"conflict": {
"3f/pygmentize": "<1.2",
"adaptcms/adaptcms": "<=1.3",
- "admidio/admidio": "<5.0.8",
+ "adawolfa/isdoc": "<1.4.3|>=1.5,<1.5.1|>=1.6,<1.6.1",
+ "admidio/admidio": "<=5.0.11",
"adodb/adodb-php": "<=5.22.9",
"aheinze/cockpit": "<2.2",
"aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2",
@@ -3825,6 +4047,7 @@
"aimeos/aimeos-core": ">=2022.04.1,<2022.10.17|>=2023.04.1,<2023.10.17|>=2024.04.1,<2024.04.7",
"aimeos/aimeos-laravel": "==2021.10",
"aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5",
+ "aimeos/pagible": "<0.10.4",
"airesvsg/acf-to-rest-api": "<=3.1",
"akaunting/akaunting": "<2.1.13",
"akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53",
@@ -3847,8 +4070,10 @@
"aoe/restler": "<1.7.1",
"apache-solr-for-typo3/solr": "<2.8.3",
"apereo/phpcas": "<1.6",
- "api-platform/core": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5",
+ "api-platform/core": "<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
"api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5",
+ "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
+ "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8",
"appwrite/server-ce": "<=1.2.1",
"arc/web": "<3",
"area17/twill": "<1.2.5|>=2,<2.5.3",
@@ -3861,21 +4086,21 @@
"austintoddj/canvas": "<=3.4.2",
"auth0/auth0-php": ">=3.3,<=8.18",
"auth0/login": "<=7.20",
- "auth0/symfony": "<=5.7",
+ "auth0/symfony": "<=5.8",
"auth0/wordpress": "<=5.5",
- "automad/automad": "<2.0.0.0-alpha5",
+ "automad/automad": "<=2.0.0.0-beta27",
"automattic/jetpack": "<9.8",
"awesome-support/awesome-support": "<=6.0.7",
"aws/aws-sdk-php": "<=3.371.3",
"ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5",
- "azuracast/azuracast": "<=0.23.3",
+ "azuracast/azuracast": "<=0.23.5",
"b13/seo_basics": "<0.8.2",
"backdrop/backdrop": "<=1.32",
- "backpack/crud": "<3.4.9",
+ "backpack/crud": "<4.0.63|>=4.1,<4.1.69|>=5,<5.0.13",
"backpack/filemanager": "<2.0.2|>=3,<3.0.9",
"bacula-web/bacula-web": "<9.7.1",
"badaso/core": "<=2.9.11",
- "bagisto/bagisto": "<2.3.10",
+ "bagisto/bagisto": "<=2.3.15",
"barrelstrength/sprout-base-email": "<1.2.7",
"barrelstrength/sprout-forms": "<3.9",
"barryvdh/laravel-translation-manager": "<0.6.8",
@@ -3888,6 +4113,7 @@
"bedita/bedita": "<4",
"bednee/cooluri": "<1.0.30",
"bigfork/silverstripe-form-capture": ">=3,<3.1.1",
+ "billabear/billabear": "<=2025.01.03",
"billz/raspap-webgui": "<3.3.6",
"binarytorch/larecipe": "<2.8.1",
"bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3",
@@ -3908,13 +4134,14 @@
"bytefury/crater": "<6.0.2",
"cachethq/cachet": "<2.5.1",
"cadmium-org/cadmium-cms": "<=0.4.9",
- "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10|>=5.2.10,<5.2.12|==5.3",
+ "cakephp/authentication": "<3.3.6|>=4,<4.1.1",
+ "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6",
"cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10",
"cardgate/magento2": "<2.0.33",
"cardgate/woocommerce": "<=3.1.15",
- "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
+ "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4",
"cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
- "cartalyst/sentry": "<=2.1.6",
+ "cartalyst/sentry": "<2.1.7",
"catfan/medoo": "<1.7.5",
"causal/oidc": "<4",
"cecil/cecil": "<7.47.1",
@@ -3923,41 +4150,42 @@
"cesnet/simplesamlphp-module-proxystatistics": "<3.1",
"chriskacerguis/codeigniter-restserver": "<=2.7.1",
"chrome-php/chrome": "<1.14",
- "ci4-cms-erp/ci4ms": "<0.31.5",
+ "ci4-cms-erp/ci4ms": "<=0.31.8",
"civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3",
"ckeditor/ckeditor": "<4.25",
"clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3",
"co-stack/fal_sftp": "<0.2.6",
- "cockpit-hq/cockpit": "<2.14",
- "code16/sharp": "<9.20",
+ "cockpit-hq/cockpit": "<=2.14",
+ "code16/sharp": "<9.22.3",
"codeception/codeception": "<3.1.3|>=4,<4.1.22",
"codeigniter/framework": "<3.1.10",
- "codeigniter4/framework": "<4.6.2",
+ "codeigniter4/framework": "<4.7.2",
"codeigniter4/shield": "<1.0.0.0-beta8",
"codiad/codiad": "<=2.8.4",
"codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9",
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
"commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5",
- "composer/composer": "<2.2.27|>=2.3,<2.9.6",
- "concrete5/concrete5": "<9.4.8",
+ "composer/composer": "<2.2.29|>=2.3,<2.10.2",
+ "concrete5/concrete5": "<9.5.2",
"concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
"contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4",
- "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.13.56|>=5,<5.3.38|>=5.4.0.0-RC1-dev,<5.6.1",
+ "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9",
"contao/core": "<3.5.39",
- "contao/core-bundle": "<4.13.57|>=5,<5.3.42|>=5.4,<5.6.5",
+ "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9",
"contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8",
"contao/managed-edition": "<=1.5",
- "coreshop/core-shop": "<4.1.9",
+ "coreshop/core-shop": "<4.1.9|==5",
"corveda/phpsandbox": "<1.3.5",
"cosenary/instagram": "<=2.3",
+ "cotonti/cotonti": "<=1",
"couleurcitron/tarteaucitron-wp": "<0.3",
"cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2",
"craftcms/aws-s3": ">=2.0.2,<=2.2.4",
"craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1",
- "craftcms/cms": "<=4.17.8|>=5,<5.9.15",
- "craftcms/commerce": ">=4,<4.11|>=5,<5.6",
+ "craftcms/cms": "<4.18|>=5,<5.10",
+ "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4",
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
"craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2",
@@ -3975,6 +4203,7 @@
"david-garcia/phpwhois": "<=4.3.1",
"dbrisinajumi/d2files": "<1",
"dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta",
+ "dedoc/scramble": ">=0.13.2,<0.13.22",
"derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3",
"derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4",
"desperado/xml-bundle": "<=0.1.7",
@@ -3996,8 +4225,8 @@
"doctrine/mongodb-odm": "<1.0.2",
"doctrine/mongodb-odm-bundle": "<3.0.1",
"doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4",
- "dolibarr/dolibarr": "<=22.0.4",
- "dompdf/dompdf": "<2.0.4",
+ "dolibarr/dolibarr": "<=23.0.2",
+ "dompdf/dompdf": "<3.1.6",
"doublethreedigital/guest-entries": "<3.1.2",
"dreamfactory/df-core": "<1.0.4",
"drupal-pattern-lab/unified-twig-extensions": "<=0.1",
@@ -4011,7 +4240,7 @@
"drupal/commerce_alphabank_redirect": "<1.0.3",
"drupal/commerce_eurobank_redirect": "<2.1.1",
"drupal/config_split": "<1.10|>=2,<2.0.2",
- "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.4.9|>=10.5,<10.5.6|>=11,<11.1.9|>=11.2,<11.2.8",
+ "drupal/core": ">=6,<6.38|>=7,<7.103|>=8,<10.5.10|>=10.6,<10.6.9|>=11,<11.2.12|>=11.3,<11.3.10",
"drupal/core-recommended": ">=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
"drupal/currency": "<3.5",
"drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.102|>=8,<10.2.11|>=10.3,<10.3.9|>=11,<11.0.8",
@@ -4038,10 +4267,11 @@
"drupal/umami_analytics": "<1.0.1",
"duncanmcclean/guest-entries": "<3.1.2",
"dweeves/magmi": "<=0.7.24",
+ "easycorp/easyadmin-bundle": ">=4,<4.29.10|>=5,<5.0.13",
"ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1",
"ecodev/newsletter": "<=4",
"ectouch/ectouch": "<=2.7.2",
- "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113",
+ "egroupware/egroupware": "<23.1.20260601|>=26.0.20251208,<26.5.20260507",
"elefant/cms": "<2.0.7",
"elgg/elgg": "<3.3.24|>=4,<4.0.5",
"elijaa/phpmemcacheadmin": "<=1.3",
@@ -4053,6 +4283,7 @@
"erusev/parsedown": "<1.7.2",
"ether/logs": "<3.0.4",
"evolutioncms/evolution": "<=3.2.3",
+ "evoweb/sf-register": "<13.2.4|>=14,<14.0.2",
"exceedone/exment": "<4.4.3|>=5,<5.0.3",
"exceedone/laravel-admin": "<2.2.3|==3",
"ezsystems/demobundle": ">=5.4,<5.4.6.1-dev",
@@ -4075,15 +4306,16 @@
"ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15",
"ezyang/htmlpurifier": "<=4.2",
"facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2",
- "facturascripts/facturascripts": "<2025.81",
+ "facturascripts/facturascripts": "<=2026.2",
"fastly/magento2": "<1.2.26",
"feehi/cms": "<=2.1.1",
"feehi/feehicms": "<=2.1.1",
"fenom/fenom": "<=2.12.1",
- "filament/actions": ">=3.2,<3.2.123",
- "filament/filament": ">=4,<4.3.1",
- "filament/infolists": ">=3,<3.2.115",
- "filament/tables": ">=3,<3.2.115|>=4,<4.8.5|>=5,<5.3.5",
+ "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3",
+ "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5",
+ "filament/forms": ">=3,<=3.3.52",
+ "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4",
+ "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4",
"filegator/filegator": "<7.8",
"filp/whoops": "<2.1.13",
"fineuploader/php-traditional-server": "<=1.2.2",
@@ -4098,6 +4330,7 @@
"flarum/nicknames": "<1.8.3",
"flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15",
"flarum/tags": "<=0.1.0.0-beta13",
+ "flightphp/core": "<3.18.1",
"floriangaerber/magnesium": "<0.3.1",
"fluidtypo3/vhs": "<5.1.1",
"fof/byobu": ">=0.3.0.0-beta2,<1.1.7",
@@ -4116,19 +4349,22 @@
"friendsofsymfony1/symfony1": ">=1.1,<1.5.19",
"friendsoftypo3/mediace": ">=7.6.2,<7.6.5",
"friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6",
+ "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1",
"froala/wysiwyg-editor": "<=4.3",
"frosh/adminer-platform": "<2.2.1",
- "froxlor/froxlor": "<2.3.6",
+ "froxlor/froxlor": "<2.3.7",
"frozennode/administrator": "<=5.0.12",
"fuel/core": "<1.8.1",
- "funadmin/funadmin": "<=7.1.0.0-RC4",
+ "funadmin/funadmin": "<=7.1.0.0-RC6",
"gaoming13/wechat-php-sdk": "<=1.10.2",
"genix/cms": "<=1.1.11",
- "georgringer/news": "<1.3.3",
+ "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3",
"geshi/geshi": "<=1.0.9.1",
"getformwork/formwork": "<=2.3.3",
- "getgrav/grav": "<1.11.0.0-beta1",
- "getkirby/cms": "<5.4",
+ "getgrav/grav": "<=2.0.0.0-RC8",
+ "getgrav/grav-plugin-api": "<1.0.0.0-beta15",
+ "getgrav/grav-plugin-form": "<9.1",
+ "getkirby/cms": "<=4.9.3|>=5,<=5.4.3",
"getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1",
"getkirby/panel": "<2.5.14",
"getkirby/starterkit": "<=3.7.0.2",
@@ -4143,11 +4379,12 @@
"gp247/core": "<1.1.24",
"gree/jose": "<2.2.1",
"gregwar/rst": "<1.0.3",
- "grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5",
+ "grumpydictator/firefly-iii": "<=6.6.2",
"gugoan/economizzer": "<=0.9.0.0-beta1",
- "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5",
+ "guzzlehttp/guzzle": "<7.15.1",
+ "guzzlehttp/guzzle-services": "<1.5.4",
"guzzlehttp/oauth-subscriber": "<0.8.1",
- "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5",
+ "guzzlehttp/psr7": "<2.12.3",
"haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2",
"handcraftedinthealps/goodby-csv": "<1.4.3",
"harvesthq/chosen": "<1.8.7",
@@ -4176,6 +4413,7 @@
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4",
"illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40",
"illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15",
+ "illuminate/mail": ">=9,<12.60|>=13,<13.10",
"illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75",
"imdbphp/imdbphp": "<=5.1.1",
"impresscms/impresscms": "<=1.4.5",
@@ -4187,8 +4425,9 @@
"innologi/typo3-appointments": "<2.0.6",
"intelliants/subrion": "<4.2.2",
"inter-mediator/inter-mediator": "==5.5",
+ "intercom/intercom-php": "==5.0.2",
"invoiceninja/invoiceninja": "<5.13.4",
- "ipl/web": "<0.10.1",
+ "ipl/web": "<=0.10.2|>=0.11,<=0.13",
"islandora/crayfish": "<4.1",
"islandora/islandora": ">=2,<2.4.1",
"ivankristianto/phpwhois": "<=4.3",
@@ -4200,6 +4439,7 @@
"jasig/phpcas": "<1.3.3",
"jbartels/wec-map": "<3.0.3",
"jcbrand/converse.js": "<3.3.3",
+ "jleehr/canto-saas-api": "<=2",
"joedolson/my-calendar": "<3.7.7",
"joelbutcher/socialstream": "<5.6|>=6,<6.2",
"johnbillion/query-monitor": "<3.20.4",
@@ -4226,23 +4466,24 @@
"kelvinmo/simplexrd": "<3.1.1",
"kevinpapst/kimai2": "<1.16.7",
"khodakhah/nodcms": "<=3.4.1",
- "kimai/kimai": "<2.54",
+ "kimai/kimai": "<2.59",
"kitodo/presentation": "<3.2.3|>=3.3,<3.3.4",
"klaviyo/magento2-extension": ">=1,<3",
- "knplabs/knp-snappy": "<=1.4.2",
+ "knplabs/knp-snappy": "<=1.7",
"kohana/core": "<3.3.3",
"koillection/koillection": "<1.6.12",
"krayin/laravel-crm": "<=2.2",
"kreait/firebase-php": ">=3.2,<3.8.1",
"kumbiaphp/kumbiapp": "<=1.1.1",
"la-haute-societe/tcpdf": "<6.2.22",
+ "laktak/hjson": "<2.3",
"laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2",
"laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1",
"laminas/laminas-http": "<2.14.2",
"lara-zeus/artemis": ">=1,<=1.0.6",
"lara-zeus/dynamic-dashboard": ">=3,<=3.0.1",
"laravel/fortify": "<1.11.1",
- "laravel/framework": "<10.48.29|>=11,<11.44.1|>=12,<12.1.1",
+ "laravel/framework": "<12.61.1|>=13,<13.12",
"laravel/laravel": ">=5.4,<5.4.22",
"laravel/passport": ">=13,<13.7.1",
"laravel/pulse": "<1.3.1",
@@ -4261,7 +4502,7 @@
"librenms/librenms": "<26.3",
"liftkit/database": "<2.13.2",
"lightsaml/lightsaml": "<1.3.5",
- "limesurvey/limesurvey": "<6.15.4",
+ "limesurvey/limesurvey": "<=7.0.0.0-beta1",
"livehelperchat/livehelperchat": "<=3.91",
"livewire-filemanager/filemanager": "<=1.0.4",
"livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4",
@@ -4284,21 +4525,23 @@
"maikuolan/phpmussel": ">=1,<1.6",
"mainwp/mainwp": "<=4.4.3.3",
"manogi/nova-tiptap": "<=3.2.6",
- "mantisbt/mantisbt": "<2.28.1",
+ "mantisbt/mantisbt": "<=2.28.3",
"marcwillmann/turn": "<0.3.3",
"markhuot/craftql": "<=1.3.7",
"marshmallow/nova-tiptap": "<5.7",
"matomo/matomo": "<1.11",
"matyhtf/framework": "<3.0.6",
- "mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1",
+ "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2",
"mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1",
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
"maximebf/debugbar": "<1.19",
+ "mckenziearts/livewire-markdown-editor": "<1.3",
"mdanter/ecc": "<2",
"mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2",
"mediawiki/cargo": "<3.8.3",
"mediawiki/core": "<1.39.5|==1.40",
"mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2",
+ "mediawiki/maps": "<12.1.3",
"mediawiki/matomo": "<2.4.3",
"mediawiki/semantic-media-wiki": "<4.0.2",
"mehrwert/phpmyadmin": "<3.2",
@@ -4318,6 +4561,8 @@
"miniorange/miniorange-saml": "<1.4.3",
"miraheze/ts-portal": "<=33",
"mittwald/typo3_forum": "<1.2.1",
+ "mix/mix": ">=2,<=2.2.17",
+ "mmc/ceselector": "<3.0.3|>=4,<4.0.2|>=5,<5.0.1|>=6,<6.0.1",
"mobiledetect/mobiledetectlib": "<2.8.32",
"modx/revolution": "<=3.1",
"mojo42/jirafeau": "<4.4",
@@ -4330,6 +4575,7 @@
"movim/moxl": ">=0.8,<=0.10",
"movingbytes/social-network": "<=1.2.1",
"mpdf/mpdf": "<=7.1.7",
+ "mtdowling/jmespath.php": "<2.9.1",
"munkireport/comment": "<4",
"munkireport/managedinstalls": "<2.6",
"munkireport/munki_facts": "<1.5",
@@ -4337,6 +4583,7 @@
"munkireport/softwareupdate": "<1.6",
"mustache/mustache": ">=2,<2.14.1",
"mwdelaney/wp-enable-svg": "<=0.2",
+ "nabeel/phpvms": "<7.0.6",
"namshi/jose": "<2.2",
"nasirkhan/laravel-starter": "<11.11",
"nategood/httpful": "<1",
@@ -4356,11 +4603,11 @@
"nilsteampassnet/teampass": "<3.1.3.1-dev",
"nitsan/ns-backup": "<13.0.1",
"nonfiction/nterchange": "<4.1.1",
- "notrinos/notrinos-erp": "<=0.7",
+ "notrinos/notrinos-erp": "<=1",
"noumo/easyii": "<=0.9",
"novaksolutions/infusionsoft-php-sdk": "<1",
"novosga/novosga": "<=2.2.12",
- "nukeviet/nukeviet": "<4.5.02",
+ "nukeviet/nukeviet": "<4.6.00",
"nyholm/psr7": "<1.6.1",
"nystudio107/craft-seomatic": "<3.4.12",
"nzedb/nzedb": "<0.8",
@@ -4377,7 +4624,7 @@
"open-web-analytics/open-web-analytics": "<1.8.1",
"opencart/opencart": ">=0",
"openid/php-openid": "<2.3",
- "openmage/magento-lts": "<20.17",
+ "openmage/magento-lts": "<=20.17",
"opensolutions/vimbadmin": "<=3.0.15",
"opensource-workshop/connect-cms": "<1.41.1|>=2,<2.41.1",
"orchid/platform": ">=8,<14.43",
@@ -4388,8 +4635,10 @@
"oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3",
"oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3",
"oveleon/contao-cookiebar": "<1.16.3|>=2,<2.1.3",
- "oxid-esales/oxideshop-ce": "<=7.0.5",
+ "oxid-esales/oxideshop-ce": "<4.5|>=6,<6.14.4",
+ "oxid-esales/oxideshop-metapackage-ce": ">=6,<6.5.5",
"oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1",
+ "oxid-esales/smarty-component": "<1.0.1",
"packbackbooks/lti-1-3-php-library": "<5",
"padraic/humbug_get_contents": "<1.1.2",
"pagarme/pagarme-php": "<3",
@@ -4398,6 +4647,7 @@
"paragonie/random_compat": "<2",
"paragonie/sodium_compat": "<1.24|>=2,<2.5",
"passbolt/passbolt_api": "<4.6.2",
+ "paymenter/paymenter": "<=1.5.4",
"paypal/adaptivepayments-sdk-php": "<=3.9.2",
"paypal/invoice-sdk-php": "<=3.9",
"paypal/merchant-sdk-php": "<3.12",
@@ -4410,23 +4660,26 @@
"pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1",
"personnummer/personnummer": "<3.0.2",
"ph7software/ph7builder": "<=17.9.1",
- "phanan/koel": "<5.1.4",
+ "phanan/koel": "<=9.7",
+ "pheditor/pheditor": "<2.0.8",
"phenx/php-svg-lib": "<0.5.2",
"php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5",
"php-mod/curl": "<2.3.2",
- "phpbb/phpbb": "<3.3.11",
+ "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1",
+ "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1",
+ "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1",
"phpems/phpems": ">=6,<=6.1.3",
"phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7",
"phpmailer/phpmailer": "<6.5",
"phpmussel/phpmussel": ">=1,<1.6",
"phpmyadmin/phpmyadmin": "<5.2.2",
- "phpmyfaq/phpmyfaq": "<=4.1",
+ "phpmyfaq/phpmyfaq": "<4.1.4",
"phpoffice/common": "<0.2.9",
"phpoffice/math": "<=0.2",
"phpoffice/phpexcel": "<=1.8.2",
- "phpoffice/phpspreadsheet": "<=1.30.3|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6",
+ "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8",
"phppgadmin/phppgadmin": "<=7.13",
- "phpseclib/phpseclib": "<2.0.53|>=3,<3.0.51",
+ "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53",
"phpservermon/phpservermon": "<3.6",
"phpsysinfo/phpsysinfo": "<3.4.3",
"phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6",
@@ -4435,14 +4688,14 @@
"phpxmlrpc/phpxmlrpc": "<4.9.2",
"phraseanet/phraseanet": "==4.0.3",
"pi/pi": "<=2.5",
- "pimcore/admin-ui-classic-bundle": "<=1.7.15|>=2.0.0.0-RC1-dev,<=2.2.2",
+ "pimcore/admin-ui-classic-bundle": "<1.7.18|>=2.0.0.0-RC1-dev,<=2.3.5",
"pimcore/customer-management-framework-bundle": "<4.2.1",
"pimcore/data-hub": "<1.2.4",
"pimcore/data-importer": "<1.8.9|>=1.9,<1.9.3",
"pimcore/demo": "<10.3",
"pimcore/ecommerce-framework-bundle": "<1.0.10",
"pimcore/perspective-editor": "<1.5.1",
- "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3",
+ "pimcore/pimcore": "<=12.3.8|>=2026.1,<2026.1.3",
"pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1",
"piwik/piwik": "<1.11",
"pixelfed/pixelfed": "<0.12.5",
@@ -4450,25 +4703,27 @@
"pocketmine/bedrock-protocol": "<8.0.2",
"pocketmine/pocketmine-mp": "<5.42.1",
"pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1",
+ "pontedilana/php-weasyprint": "<=2.5.1",
+ "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4",
"pressbooks/pressbooks": "<5.18",
"prestashop/autoupgrade": ">=4,<4.10.1",
"prestashop/blockreassurance": "<=5.1.3",
"prestashop/blockwishlist": ">=2,<2.1.1",
"prestashop/contactform": ">=1.0.1,<4.3",
"prestashop/gamification": "<2.3.2",
- "prestashop/prestashop": "<8.2.5|>=9.0.0.0-alpha1,<9.1",
+ "prestashop/prestashop": "<8.2.6|>=9,<9.1.1",
"prestashop/productcomments": "<5.0.2",
- "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5",
+ "prestashop/ps_checkout": "<5.3",
"prestashop/ps_contactinfo": "<=3.3.2",
"prestashop/ps_emailsubscription": "<2.6.1",
- "prestashop/ps_facetedsearch": "<3.4.1",
+ "prestashop/ps_facetedsearch": "<4.0.4",
"prestashop/ps_linklist": "<3.1",
"privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3",
"processwire/processwire": "<=3.0.255",
- "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7",
- "propel/propel1": ">=1,<=1.7.1",
+ "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8",
+ "propel/propel1": ">=1,<1.7.2",
"psy/psysh": "<=0.11.22|>=0.12,<=0.12.18",
- "pterodactyl/panel": "<1.12.1",
+ "pterodactyl/panel": "<=1.12.4",
"ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2",
"ptrofimov/beanstalk_console": "<1.7.14",
"pubnub/pubnub": "<6.1",
@@ -4488,13 +4743,14 @@
"rap2hpoutre/laravel-log-viewer": "<0.13",
"react/http": ">=0.7,<1.9",
"really-simple-plugins/complianz-gdpr": "<6.4.2",
- "redaxo/source": "<5.21",
+ "redaxo/source": "<5.21.1",
"remdex/livehelperchat": "<4.29",
"renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1",
"reportico-web/reportico": "<=8.1",
"rhukster/dom-sanitizer": "<1.0.10",
"rmccue/requests": ">=1.6,<1.8",
"roadiz/documents": "<2.3.42|>=2.4,<2.5.44|>=2.6,<2.6.28|>=2.7,<2.7.9",
+ "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18",
"robrichards/xmlseclibs": "<3.1.5",
"roots/soil": "<4.1",
"roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev",
@@ -4510,24 +4766,26 @@
"scheb/two-factor-bundle": "<3.26|>=4,<4.11",
"sensiolabs/connect": "<4.2.3",
"serluck/phpwhois": "<=4.2.6",
- "setasign/fpdi": "<2.6.4",
+ "setasign/fpdi": "<2.6.7",
"sfroemken/url_redirect": "<=1.2.1",
"sheng/yiicms": "<1.2.1",
- "shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
- "shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
+ "shopper/cart": "<2.8",
+ "shopper/framework": "<2.8",
+ "shopware/core": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev",
+ "shopware/platform": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev",
"shopware/production": "<=6.3.5.2",
- "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
+ "shopware/shopware": "<=6.3.5.2|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
"shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
"shopxo/shopxo": "<=6.4",
- "showdoc/showdoc": "<2.10.4",
+ "showdoc/showdoc": "<3.8.1",
"shuchkin/simplexlsx": ">=1.0.12,<1.1.13",
"silverstripe-australia/advancedreports": ">=1,<=2",
"silverstripe/admin": "<1.13.19|>=2,<2.1.8",
"silverstripe/assets": "<2.4.5|>=3,<3.1.3",
- "silverstripe/cms": "<4.11.3",
+ "silverstripe/cms": "<6.2.1",
"silverstripe/comments": ">=1.3,<3.1.1",
- "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3",
- "silverstripe/framework": "<5.3.23",
+ "silverstripe/forum": "<0.6.2|>=0.7,<0.7.4",
+ "silverstripe/framework": "<6.2.2",
"silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3",
"silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1",
"silverstripe/recipe-cms": ">=4.5,<4.5.3",
@@ -4537,13 +4795,15 @@
"silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1",
"silverstripe/subsites": ">=2,<2.6.1",
"silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1",
- "silverstripe/userforms": "<3|>=5,<5.4.2",
+ "silverstripe/userforms": "<6.4.9|>=7,<7.0.7|>=7.1,<7.1.1",
+ "silverstripe/versioned": "<3.2.1",
"silverstripe/versioned-admin": ">=1,<1.11.1",
"simogeo/filemanager": "<=2.5",
"simple-updates/phpwhois": "<=1",
- "simplesamlphp/saml2": "<=4.16.15|>=5.0.0.0-alpha1,<=5.0.0.0-alpha19",
- "simplesamlphp/saml2-legacy": "<=4.16.15",
- "simplesamlphp/simplesamlphp": "<1.18.6",
+ "simplesamlphp/saml2": "<=4.20.2|>=5,<5.0.6|>=6,<6.2.1",
+ "simplesamlphp/saml2-legacy": "<=4.20.2",
+ "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1",
+ "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2",
"simplesamlphp/simplesamlphp-module-infocard": "<1.0.1",
"simplesamlphp/simplesamlphp-module-openid": "<1",
"simplesamlphp/simplesamlphp-module-openidprovider": "<0.9",
@@ -4555,19 +4815,23 @@
"sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3",
"sjbr/static-info-tables": "<2.3.1",
"slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1",
- "slim/slim": "<2.6",
+ "slim/slim": "<2.6|>=4.4,<=4.15.1",
"slub/slub-events": "<3.0.3",
"smarty/smarty": "<4.5.3|>=5,<5.1.1",
- "snipe/snipe-it": "<8.3.7",
+ "snipe/snipe-it": "<=8.6.1",
"socalnick/scn-social-auth": "<1.15.2",
"socialiteproviders/steam": "<1.1",
+ "solidinvoice/solidinvoice": "<=2.3.15",
"solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6",
"soosyze/soosyze": "<=2",
"spatie/browsershot": "<5.0.5",
"spatie/image-optimizer": "<1.7.3",
+ "spatie/laravel-medialibrary": "<11.23",
+ "spatie/schema-org": ">=3.23.1,<3.23.2|>=4,<4.0.2",
"spencer14420/sp-php-email-handler": "<1",
"spipu/html2pdf": "<5.2.8",
"spiral/roadrunner": "<2025.1",
+ "spomky-labs/otphp": "<11.4.3",
"spoon/library": "<1.4.1",
"spoonity/tcpdf": "<6.2.22",
"squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1",
@@ -4576,14 +4840,14 @@
"starcitizentools/short-description": ">=4,<4.0.1",
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
"starcitizenwiki/embedvideo": "<=4",
- "statamic/cms": "<5.73.20|>=6,<6.13",
+ "statamic/cms": "<5.74|>=6,<6.20.3",
"stormpath/sdk": "<9.9.99",
- "studio-42/elfinder": "<2.1.67",
+ "studio-42/elfinder": "<=2.1.67",
"studiomitte/friendlycaptcha": "<0.1.4",
"subhh/libconnect": "<7.0.8|>=8,<8.1",
"sukohi/surpass": "<1",
"sulu/form-bundle": ">=2,<2.5.3",
- "sulu/sulu": "<2.6.22|>=3,<3.0.5",
+ "sulu/sulu": "<=2.6.22|>=3,<=3.0.5",
"sumocoders/framework-user-bundle": "<1.4",
"superbig/craft-audit": "<3.0.2",
"svewap/a21glossary": "<=0.4.10",
@@ -4593,50 +4857,65 @@
"sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2",
"sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
"sylius/grid-bundle": "<1.10.1",
+ "sylius/mollie-plugin": "<2.2.8|>=3,<3.2.4|>=3.3,<3.3.1",
"sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2",
"sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
- "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2",
+ "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<2.0.18|>=2.1,<2.1.15|>=2.2,<2.2.6",
+ "symbiote/silverstripe-advancedworkflow": "<6.4.5|>=7,<7.1.3|>=7.2,<7.2.1",
"symbiote/silverstripe-multivaluefield": ">=3,<3.1",
"symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4",
"symbiote/silverstripe-seed": "<6.0.3",
"symbiote/silverstripe-versionedfiles": "<=2.0.3",
"symfont/process": ">=0",
- "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+ "symfony/cache": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+ "symfony/dom-crawler": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4",
"symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1",
"symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4",
- "symfony/http-client": ">=4.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8",
- "symfony/http-foundation": "<5.4.50|>=6,<6.4.29|>=7,<7.3.7",
- "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6",
+ "symfony/html-sanitizer": ">=6.1,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/http-client": ">=4.3,<5.4.53|>=6,<6.4.15|>=7,<7.1.8",
+ "symfony/http-foundation": "<5.4.50|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6|>=7.4,<7.4.12|>=8,<8.0.12",
"symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13",
+ "symfony/json-path": ">=7.3,<7.4.12|>=8,<8.0.12",
+ "symfony/lox24-notifier": ">=7.1,<7.4.12|>=8,<8.0.12",
+ "symfony/mailer": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/mailjet-mailer": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/mailomat-mailer": ">=7.2,<7.4.13|>=8,<8.0.13",
+ "symfony/mailtrap-mailer": ">=7.2,<7.4.12|>=8,<8.0.12",
"symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1",
- "symfony/mime": ">=4.3,<4.3.8",
+ "symfony/mime": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/monolog-bridge": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
- "symfony/polyfill": ">=1,<1.10",
+ "symfony/polyfill": ">=1,<1.10|>=1.17.1,<1.38.1",
+ "symfony/polyfill-intl-idn": ">=1.17.1,<1.38.1",
"symfony/polyfill-php55": ">=1,<1.10",
"symfony/process": "<5.4.51|>=6,<6.4.33|>=7,<7.1.7|>=7.3,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5",
"symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
- "symfony/routing": ">=2,<2.0.19",
- "symfony/runtime": ">=5.3,<5.4.46|>=6,<6.4.14|>=7,<7.1.7",
+ "symfony/routing": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
+ "symfony/runtime": ">=5.3,<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8",
"symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.4.10|>=7,<7.0.10|>=7.1,<7.1.3",
"symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9",
"symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
"symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8",
- "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.4.47|>=6,<6.4.15|>=7,<7.1.8",
+ "symfony/security-http": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
"symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12",
- "symfony/symfony": "<5.4.51|>=6,<6.4.33|>=7,<7.3.11|>=7.4,<7.4.5|>=8,<8.0.5",
+ "symfony/symfony": "<5.4.53|>=6,<6.4.41|>=7,<7.4.13|>=8,<8.0.13",
"symfony/translation": ">=2,<2.0.17",
- "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8",
- "symfony/ux-autocomplete": "<2.11.2",
- "symfony/ux-live-component": "<2.25.1",
+ "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8|>=6.4.24,<6.4.40",
+ "symfony/twilio-notifier": ">=6.4,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
+ "symfony/ux-autocomplete": "<2.36|>=3,<3.1",
+ "symfony/ux-icons": ">=2.17,<2.36.1|>=3,<3.2",
+ "symfony/ux-live-component": "<2.36|>=3,<3.1",
+ "symfony/ux-toolkit": ">=2.32,<2.36.1|>=3,<3.2",
"symfony/ux-twig-component": "<2.25.1",
"symfony/validator": "<5.4.43|>=6,<6.4.11|>=7,<7.1.4",
"symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8",
- "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4",
+ "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4|>=7.2.9,<7.4.12|>=8,<8.0.12",
"symfony/webhook": ">=6.3,<6.3.8",
- "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7|>=2.2.0.0-beta1,<2.2.0.0-beta2",
+ "symfony/yaml": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12",
"symphonycms/symphony-2": "<2.6.4",
"t3/dce": "<0.11.5|>=2.2,<2.6.2",
"t3g/svg-sanitizer": "<1.0.3",
@@ -4647,45 +4926,50 @@
"tecnickcom/tcpdf": "<6.8",
"terminal42/contao-tablelookupwizard": "<3.3.5",
"thelia/backoffice-default-template": ">=2.1,<2.1.2",
- "thelia/thelia": ">=2.1,<2.1.3",
+ "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3",
"theonedemon/phpwhois": "<=4.2.5",
"thinkcmf/thinkcmf": "<6.0.8",
- "thorsten/phpmyfaq": "<4.1.1",
+ "thorsten/phpmyfaq": "<4.1.4",
"tikiwiki/tiki-manager": "<=17.1",
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
- "tinymce/tinymce": "<7.2",
+ "tinymce/tinymce": "<7.9.3|>=8,<8.5.1",
"tinymighty/wiki-seo": "<1.2.2",
"titon/framework": "<9.9.99",
"tltneon/lgsl": "<7",
"tobiasbg/tablepress": "<=2.0.0.0-RC1",
+ "tomasnorre/crawler": "<11.0.13|>=12,<12.0.11",
"topthink/framework": "<6.0.17|>=6.1,<=8.0.4",
"topthink/think": "<=6.1.1",
"topthink/thinkphp": "<=3.2.3|>=6.1.3,<=8.0.4",
"torrentpier/torrentpier": "<=2.8.8",
- "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2",
+ "tpwd/ke_search": "<5.6.2|>=6,<6.6.1|>=7,<7.0.1",
"tribalsystems/zenario": "<=9.7.61188",
"truckersmp/phpwhois": "<=4.3.1",
"ttskch/pagination-service-provider": "<1",
"twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
- "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
- "typicms/core": "<16.1.7",
+ "twig/cssinliner-extra": "<3.26",
+ "twig/intl-extra": "<3.26",
+ "twig/markdown-extra": "<3.26",
+ "twig/twig": "<3.27",
+ "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7",
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
- "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1|==14.2",
+ "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
- "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
+ "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1",
"typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
"typo3/cms-felogin": ">=4.2,<4.2.3",
- "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1",
- "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
+ "typo3/cms-filelist": ">=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
+ "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1|>=8,<8.7.23|>=9,<9.5.4",
+ "typo3/cms-form": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.5",
"typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5",
- "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
+ "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2",
"typo3/cms-lowlevel": ">=11,<=11.5.41",
"typo3/cms-recordlist": ">=11,<11.5.48",
- "typo3/cms-recycler": ">=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
+ "typo3/cms-recycler": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3",
"typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
"typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30",
"typo3/cms-scheduler": ">=11,<=11.5.41",
@@ -4693,7 +4977,7 @@
"typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11",
"typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18",
"typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6",
- "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3",
+ "typo3/html-sanitizer": "<2.3.2",
"typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3",
"typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
"typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5",
@@ -4709,7 +4993,7 @@
"uvdesk/core-framework": "<=1.1.1",
"vanilla/safecurl": "<0.9.2",
"verbb/comments": "<1.5.5",
- "verbb/formie": "<=2.1.43",
+ "verbb/formie": "<3.1.28",
"verbb/image-resizer": "<2.0.9",
"verbb/knock-knock": "<1.2.8",
"verot/class.upload.php": "<=2.1.6",
@@ -4723,16 +5007,20 @@
"wallabag/wallabag": "<2.6.11",
"wanglelecc/laracms": "<=1.0.3",
"wapplersystems/a21glossary": "<=0.4.10",
- "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4",
- "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4",
- "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4",
+ "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1",
+ "web-auth/webauthn-lib": ">=4.5,<5.3.5",
+ "web-auth/webauthn-symfony-bundle": "<5.3.4",
"web-feet/coastercms": "==5.5",
+ "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7",
+ "web-token/jwt-experimental": "<4.1.7",
+ "web-token/jwt-framework": "<4.1.7",
+ "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7",
"web-tp3/wec_map": "<3.0.3",
"webbuilders-group/silverstripe-kapost-bridge": "<0.4",
"webcoast/deferred-image-processing": "<1.0.2",
"webklex/laravel-imap": "<5.3",
"webklex/php-imap": "<5.3",
- "webonyx/graphql-php": "<=15.31.4",
+ "webonyx/graphql-php": "<=15.32.2",
"webpa/webpa": "<3.1.2",
"webreinvent/vaahcms": "<=2.3.1",
"wikibase/wikibase": "<=1.39.3",
@@ -4744,9 +5032,11 @@
"winter/wn-system-module": "<1.2.4",
"wintercms/winter": "<=1.2.3",
"wireui/wireui": "<1.19.3|>=2,<2.1.3",
+ "wnx/laravel-backup-restore": "<=1.9.3",
"woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3",
"wp-cli/wp-cli": ">=0.12,<2.5",
- "wp-graphql/wp-graphql": "<=1.14.5",
+ "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1",
+ "wp-graphql/wp-graphql": "<=2.6",
"wp-premium/gravityforms": "<2.4.21",
"wpanel/wpanel4-cms": "<=4.3.1",
"wpcloud/wp-stateless": "<3.2",
@@ -4757,12 +5047,12 @@
"xpressengine/xpressengine": "<3.0.15",
"yab/quarx": "<2.4.5",
"yansongda/pay": "<=3.7.19",
- "yeswiki/yeswiki": "<=4.6",
+ "yeswiki/yeswiki": "<4.6.6",
"yetiforce/yetiforce-crm": "<6.5",
"yidashi/yii2cmf": "<=2",
"yii2mod/yii2-cms": "<1.9.2",
"yiisoft/yii": "<1.1.31",
- "yiisoft/yii2": "<2.0.52",
+ "yiisoft/yii2": "<2.0.55",
"yiisoft/yii2-authclient": "<2.2.15",
"yiisoft/yii2-bootstrap": "<2.0.4",
"yiisoft/yii2-dev": "<=2.0.45",
@@ -4852,7 +5142,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-28T23:21:55+00:00"
+ "time": "2026-08-01T00:01:24+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -5877,26 +6167,31 @@
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.13.5",
+ "version": "4.0.4",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
- "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
+ "reference": "bbdc3d0532623e21838b7041a4364383a8126f96"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
- "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96",
+ "reference": "bbdc3d0532623e21838b7041a4364383a8126f96",
"shasum": ""
},
"require": {
+ "ext-libxml": "*",
"ext-simplexml": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
- "php": ">=5.4.0"
+ "php": ">=7.2.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31"
+ },
+ "suggest": {
+ "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.",
+ "ext-pcntl": "For parallel processing support via the --parallel CLI option."
},
"bin": [
"bin/phpcbf",
@@ -5921,7 +6216,7 @@
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
}
],
- "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+ "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.",
"homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
"keywords": [
"phpcs",
@@ -5952,7 +6247,7 @@
"type": "thanks_dev"
}
],
- "time": "2025-11-04T16:30:35+00:00"
+ "time": "2026-08-06T02:45:27+00:00"
},
{
"name": "symfony/config",
@@ -6035,16 +6330,16 @@
},
{
"name": "symfony/console",
- "version": "v6.4.36",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5"
+ "reference": "3b643aa587acbc42f967a429af088a56ed8f046d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/9f481cfb580db8bcecc9b2d4c63f3e13df022ad5",
- "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5",
+ "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d",
+ "reference": "3b643aa587acbc42f967a429af088a56ed8f046d",
"shasum": ""
},
"require": {
@@ -6109,7 +6404,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v6.4.36"
+ "source": "https://github.com/symfony/console/tree/v6.4.43"
},
"funding": [
{
@@ -6129,7 +6424,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-27T15:30:51+00:00"
+ "time": "2026-07-26T14:44:19+00:00"
},
{
"name": "symfony/dependency-injection",
@@ -6218,16 +6513,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
@@ -6265,7 +6560,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -6285,20 +6580,20 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T15:52:40+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v6.4.36",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69"
+ "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/fc828863e26ceec86e2513b5e46aa0b149d76b69",
- "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf",
+ "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf",
"shasum": ""
},
"require": {
@@ -6349,7 +6644,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.36"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43"
},
"funding": [
{
@@ -6369,20 +6664,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-30T11:18:01+00:00"
+ "time": "2026-07-21T14:00:19+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"shasum": ""
},
"require": {
@@ -6396,7 +6691,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -6429,7 +6724,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -6440,25 +6735,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v6.4.34",
+ "version": "v6.4.43",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3"
+ "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3",
- "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16",
+ "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16",
"shasum": ""
},
"require": {
@@ -6495,7 +6794,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v6.4.34"
+ "source": "https://github.com/symfony/filesystem/tree/v6.4.43"
},
"funding": [
{
@@ -6515,20 +6814,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-24T17:51:06+00:00"
+ "time": "2026-06-27T10:13:35+00:00"
},
{
"name": "symfony/finder",
- "version": "v6.4.34",
+ "version": "v6.4.42",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896"
+ "reference": "0b73dac42493acbadbba644207a715b254e9b029"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/9590e86be1d1c57bfbb16d0dd040345378c20896",
- "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029",
+ "reference": "0b73dac42493acbadbba644207a715b254e9b029",
"shasum": ""
},
"require": {
@@ -6563,7 +6862,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v6.4.34"
+ "source": "https://github.com/symfony/finder/tree/v6.4.42"
},
"funding": [
{
@@ -6583,7 +6882,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-28T15:16:37+00:00"
+ "time": "2026-06-26T15:18:24+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -6670,16 +6969,16 @@
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.37.0",
+ "version": "v1.41.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
- "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
+ "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": ""
},
"require": {
@@ -6728,7 +7027,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
},
"funding": [
{
@@ -6748,20 +7047,20 @@
"type": "tidelift"
}
],
- "time": "2026-04-26T13:13:48+00:00"
+ "time": "2026-07-28T08:25:59+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.37.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -6813,7 +7112,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -6833,20 +7132,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.38.1",
+ "version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
- "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": ""
},
"require": {
@@ -6898,7 +7197,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
},
"funding": [
{
@@ -6918,20 +7217,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-26T12:51:13+00:00"
+ "time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.37.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
+ "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
"shasum": ""
},
"require": {
@@ -6978,7 +7277,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
},
"funding": [
{
@@ -6998,20 +7297,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-26T12:45:58+00:00"
},
{
"name": "symfony/process",
- "version": "v6.4.33",
+ "version": "v6.4.41",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "c46e854e79b52d07666e43924a20cb6dc546644e"
+ "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e",
- "reference": "c46e854e79b52d07666e43924a20cb6dc546644e",
+ "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7",
+ "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7",
"shasum": ""
},
"require": {
@@ -7043,7 +7342,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v6.4.33"
+ "source": "https://github.com/symfony/process/tree/v6.4.41"
},
"funding": [
{
@@ -7063,20 +7362,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-23T16:02:12+00:00"
+ "time": "2026-05-23T13:47:21+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"shasum": ""
},
"require": {
@@ -7094,7 +7393,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7130,7 +7429,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -7150,26 +7449,27 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-06-16T09:55:08+00:00"
},
{
"name": "symfony/string",
- "version": "v6.4.34",
+ "version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432"
+ "reference": "e394af32256bf9e7bf80849d95e589167c10097b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/2adaf4106f2ef4c67271971bde6d3fe0a6936432",
- "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432",
+ "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b",
+ "reference": "e394af32256bf9e7bf80849d95e589167c10097b",
"shasum": ""
},
"require": {
- "php": ">=8.1",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
},
@@ -7177,10 +7477,11 @@
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
- "symfony/http-client": "^5.4|^6.0|^7.0",
- "symfony/intl": "^6.2|^7.0",
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
- "symfony/var-exporter": "^5.4|^6.0|^7.0"
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -7219,7 +7520,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v6.4.34"
+ "source": "https://github.com/symfony/string/tree/v7.4.15"
},
"funding": [
{
@@ -7239,7 +7540,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-08T20:44:54+00:00"
+ "time": "2026-07-28T07:33:02+00:00"
},
{
"name": "symfony/var-exporter",
@@ -7450,16 +7751,16 @@
},
{
"name": "twig/twig",
- "version": "v3.27.0",
+ "version": "v3.28.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
- "reference": "04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed"
+ "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/twigphp/Twig/zipball/04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed",
- "reference": "04ae1bfe9463c816cf72ca0abe7eae2c77a9a9ed",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
+ "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"shasum": ""
},
"require": {
@@ -7514,7 +7815,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
- "source": "https://github.com/twigphp/Twig/tree/v3.27.0"
+ "source": "https://github.com/twigphp/Twig/tree/v3.28.0"
},
"funding": [
{
@@ -7526,7 +7827,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-27T13:05:51+00:00"
+ "time": "2026-07-03T20:44:34+00:00"
},
{
"name": "vimeo/psalm",
@@ -7707,9 +8008,9 @@
"platform": {
"php": "^8.3"
},
- "platform-dev": [],
+ "platform-dev": {},
"platform-overrides": {
"php": "8.3"
},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/css/header-override.css b/css/header-override.css
index c98282745..2a0ec4ad5 100644
--- a/css/header-override.css
+++ b/css/header-override.css
@@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-License-Identifier: EUPL-1.2
*
* NUCLEAR OPTION: Override ALL theme CSS for header styling
* This file is loaded LAST to ensure it overrides nldesign theme
@@ -19,8 +19,11 @@ html body #header,
body #header,
#body-user #header,
#header {
- background-color: #ffffff !important;
- background-image: none !important;
+ /* The shorthand alone is deliberate: it sets the colour AND resets
+ background-image to none, which is exactly what the two longhands it
+ replaces were doing. Keeping all three tripped
+ declaration-block-no-shorthand-property-overrides, because a shorthand
+ after a longhand silently discards it. */
background: #ffffff !important;
border-bottom: 1px solid #e0e0e0 !important;
}
diff --git a/css/launchpad.css b/css/launchpad.css
index 7f37fc88d..cd0d6de81 100644
--- a/css/launchpad.css
+++ b/css/launchpad.css
@@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-License-Identifier: EUPL-1.2
*
* --launchpad-cell-height is set by `useGridManager.syncCellHeightCssVar()` at
* grid-init time from the JS `CELL_HEIGHT` constant (REQ-GRID-012). The
diff --git a/docs/GOVERNMENT-FEATURES.md b/docs/GOVERNMENT-FEATURES.md
index 6c7bd4ef3..78b4a512b 100644
--- a/docs/GOVERNMENT-FEATURES.md
+++ b/docs/GOVERNMENT-FEATURES.md
@@ -5,7 +5,7 @@
**Product:** LaunchPad
**Categorie:** Dashboard & informatievoorziening
-**Licentie:** AGPL (vrije open source)
+**Licentie:** EUPL-1.2 (vrije open source)
**Leverancier:** Conduction B.V.
**Platform:** Nextcloud (self-hosted / on-premise / cloud)
@@ -57,7 +57,7 @@
| # | Eis | Status | Toelichting |
|---|-----|--------|-------------|
| T-01 | On-premise / self-hosted | Beschikbaar | Nextcloud-app |
-| T-02 | Open source | Beschikbaar | AGPL, GitHub |
+| T-02 | Open source | Beschikbaar | EUPL-1.2, GitHub |
| T-03 | PHP 8.1+ | Beschikbaar | Moderne PHP |
| T-04 | Nextcloud 28-33 compatibel | Beschikbaar | Brede versie-ondersteuning |
| T-05 | Geen externe dependencies | Beschikbaar | Alleen Nextcloud vereist |
diff --git a/docs/Installation/demo-environment.md b/docs/Installation/demo-environment.md
new file mode 100644
index 000000000..e99d1c33e
--- /dev/null
+++ b/docs/Installation/demo-environment.md
@@ -0,0 +1,131 @@
+# Run a local demo
+
+This page gets a working Launchpad running on your own machine in two commands. You end with a dashboard over the apps installed on the instance.
+
+It is a **demo**, not a development environment. Nothing is mounted from a checkout, and that is deliberate — see [What this is not](#what-this-is-not).
+
+## What you need
+
+Docker, with Compose v2.23 or newer. Nothing else — no PHP, no Node, no Nextcloud.
+
+```bash
+docker --version
+docker compose version
+```
+
+If `docker compose version` prints v2.22 or older, upgrade first. The compose file declares its scripts inline via `configs`, and older versions ignore the `content:` field **silently** — which produces an instance with no apps installed and nothing in the logs to explain why.
+
+## Step 1 — get the compose file
+
+```bash
+curl -fsSLO https://raw.githubusercontent.com/ConductionNL/launchpad/development/launchpad-compose.yaml
+```
+
+A single self-contained file. There is nothing else to fetch and nothing to edit.
+
+## Step 2 — start it
+
+```bash
+docker compose -f launchpad-compose.yaml up -d
+```
+
+The first run takes a few minutes: it pulls three images and downloads the application archives. Watch it work if you like:
+
+```bash
+docker compose -f launchpad-compose.yaml logs -f app-installer
+```
+
+You are looking for:
+
+```
+==> installing openregister
+==> installing thematiq
+==> installing integriq
+==> installing launchpad
+==> apps present: integriq launchpad openregister thematiq
+```
+
+Then Nextcloud installs itself and enables the apps **in dependency order**. OpenRegister goes first: it owns the registers and schemas the others declare against, and a leaf app enabled before it finds no register to attach to.
+
+That is done when this returns `"installed":true`:
+
+```bash
+curl -s http://localhost:8605/status.php
+```
+
+## Step 3 — open the demo
+
+| What | Where |
+| --- | --- |
+| **Launchpad** | [http://localhost:8605/apps/launchpad/](http://localhost:8605/apps/launchpad/) |
+| Admin interface | [http://localhost:8605](http://localhost:8605) — `admin` / `admin` |
+
+## What gets installed, and why more than one app
+
+| App | Why |
+| --- | --- |
+| `openregister` | **Required.** Every Connext app declares its registers and schemas against OpenRegister. |
+| `thematiq` | Optional. Government theming. Absent, the UI renders unthemed rather than wrong. |
+| `integriq` | Optional. The connector, for feeding in data from systems you do not control. |
+| `launchpad` | The app this page is about. |
+
+That OpenRegister dependency is **not declared** in `appinfo/info.xml` — no app in the fleet declares an `` dependency — so nothing stops the App Store from installing launchpad without it. It would then load, find no register to attach to, and show you an empty app rather than an error. The compose file encodes the dependency the manifest does not.
+
+## Verifying it actually worked
+
+A page loading is not the same as a page working. Nextcloud serves its shell before the app decides whether it has anything to render, so an app URL returns HTTP 200 even when it resolves to nothing at all. A smoke test that checks for a 200 would call that a success.
+
+Check content instead:
+
+```bash
+# The app answers. Note the credentials: an app page requires a login, so the
+# SAME request without -u returns 401, which is not a broken demo — measured
+# on a booted demo while writing this page.
+curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin -L "http://localhost:8605/apps/launchpad/"
+
+# OpenRegister has registers — an empty list means the configuration
+# was never imported, which is not the same as "nothing configured yet"
+curl -s -u admin:admin "http://localhost:8605/apps/openregister/api/registers" | head -c 300
+```
+
+## Changing the defaults
+
+The port and every version are overridable:
+
+```bash
+DEMO_PORT=9000 \
+LAUNCHPAD_VERSION=1.2.3 \
+docker compose -f launchpad-compose.yaml up -d
+```
+
+Leaving a version empty resolves the newest release for that app, pre-releases included — which is what most Connext apps still ship, so that is the default.
+
+## Tearing it down
+
+```bash
+# Stop, keep the data
+docker compose -f launchpad-compose.yaml down
+
+# Stop and delete everything, including the database
+docker compose -f launchpad-compose.yaml down -v
+```
+
+## What this is not
+
+**It is not a development environment, and it cannot be turned into one by adding a bind mount.**
+
+Nextcloud installs and updates an app by deleting the app directory and extracting a fresh archive over it. Point that at a checkout and an app-store update will delete your working tree — measured on a development machine on 27 August 2026, where `\OC\Updater::upgradeAppStoreApp` fired on a container restart and removed every top-level file from a bind-mounted checkout, including its `.git` directory. Only the subdirectories it lacked permission to unlink survived.
+
+So this compose keeps its apps in a named volume and installs them from release archives. That also happens to be the only thing that works: a release archive is a **complete** app carrying `vendor/` and the built `js/` bundle, while a `git clone` carries neither — and a Nextcloud app with no `vendor/` does not fail loudly. It warns once and keeps loading, so the app appears installed while every service that needs a dependency is quietly absent.
+
+To work *on* these apps rather than *with* them, use the development environment instead.
+
+## Troubleshooting
+
+**`app-installer` exits non-zero.** It could not download an archive. Check the log for the URL it tried; the most common cause is a pinned version with no matching release.
+
+**It stops with `openregister missing; aborting`.** Deliberate. Every other app declares registers against OpenRegister, so a stack without it would start and then fail in a dozen confusing ways instead of one clear one.
+
+**The UI renders unthemed.** Thematiq is not installed or not enabled. Expected, and cosmetic — the theme resolver renders unthemed rather than wrong when it is absent.
+
+**Everything returns 404 or a maintenance page after a restart.** Nextcloud is waiting for an upgrade. Run `docker compose -f launchpad-compose.yaml exec -u www-data nextcloud php occ upgrade`.
diff --git a/docs/architecture.md b/docs/architecture.md
index bc9ba3bde..b08ffab01 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,8 +25,9 @@ persists everything in its own tables via Doctrine mappers.
│ components/WidgetRenderer — legacy-widget bridge │
│ components/WidgetPicker — "add widget" modal │
│ components/WidgetWrapper — per-tile chrome │
-│ components/TileCard / TileEditor / WidgetStyleEditor │
+│ components/TileCard / WidgetStyleEditor │
│ components/admin/AdminSettings — admin console │
+│ modals/ + dialogs/ — every NcModal / NcDialog surface │
└──────────────────────────┬──────────────────────────────────┘
│ OCS JSON via @nextcloud/axios
┌──────────────────────────▼──────────────────────────────────┐
diff --git a/docs/development.md b/docs/development.md
index 73b336435..fc4243d82 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -82,8 +82,13 @@ All PRs to `main`, `beta`, and `development` must pass these **blocking** checks
| PHP Mess Detection | PHPMD | `composer phpmd` |
| PHP Code Metrics | phpmetrics (informational) | `composer phpmetrics` |
| ESLint + Stylelint | ESLint + Stylelint | `npm run lint && npm run stylelint` |
+| Code formatting | Prettier | `npm run format` |
| Branch Policy | GitHub Actions | Automatic |
+> ⚠️ **`npm run format` is a separate CI job** (`Frontend Check (format)`),
+> not part of `npm run lint`. Lint and stylelint can both be green while the
+> format gate is red — it has happened. Run all three.
+
### Running Quality Checks Locally
Before pushing, run all checks locally to catch issues early:
@@ -95,7 +100,21 @@ composer phpmd # Mess detection
composer phpmetrics # Code metrics report
# Frontend checks
-npm run lint && npm run stylelint
+npm run lint && npm run stylelint && npm run format
+```
+
+### Debug logging
+
+`src/` never calls `console.*` directly — everything goes through
+`src/utils/logger.js`, which is the app's single console boundary.
+
+`logger.warn` and `logger.error` always print. `logger.debug` is **silent by
+default**, because the tracing it carries used to run on every dashboard load
+in production. Turn it on from the browser console:
+
+```js
+localStorage.setItem('launchpad:debug', '1') // then reload
+localStorage.removeItem('launchpad:debug') // back to quiet
```
### Auto-fixing
diff --git a/docs/features.json b/docs/features.json
index b21b6b527..5ceee6bf8 100644
--- a/docs/features.json
+++ b/docs/features.json
@@ -1,485 +1,248 @@
[
- {
- "slug": "activity-feed-integration",
- "title": "Activity Feed Integration",
- "summary": "Surface LaunchPad events in Nextcloud's standard Activity feed so every action on a dashboard.",
- "status": "stable",
- "docsUrl": "openspec/specs/activity-feed-integration/spec.md"
- },
- {
- "slug": "admin-roles",
- "title": "Admin Roles",
- "summary": "Admin Roles provides a built-in role system scoped entirely within LaunchPad.",
- "status": "stable",
- "docsUrl": "openspec/specs/admin-roles/spec.md"
- },
- {
- "slug": "admin-settings",
- "title": "Admin Settings",
- "summary": "Admin settings provide Nextcloud administrators with global configuration options for the LaunchPad app.",
- "status": "stable",
- "docsUrl": "openspec/specs/admin-settings/spec.md"
- },
{
"slug": "admin-templates",
- "title": "Admin Templates",
- "summary": "Admin templates allow Nextcloud administrators to create pre-configured dashboards that are automatically distributed to users based on group membership.",
+ "title": "Admin templates",
+ "summary": "You push a curated homepage to a group in minutes.",
"status": "stable",
- "docsUrl": "openspec/specs/admin-templates/spec.md"
+ "docsUrl": "openspec/specs/admin-templates/spec.md",
+ "title_nl": "Beheersjablonen",
+ "summary_nl": "Je rolt een ingerichte startpagina in minuten uit naar een groep."
},
{
- "slug": "background-job-feed-refresh",
- "title": "Background Job Feed Refresh",
- "summary": "Keeps news-widget feeds fresh by running a scheduled background job that fetches, parses, and caches RSS 2.0 and Atom 1.0 feeds referenced by dashboard placements.",
- "status": "stable",
- "docsUrl": "openspec/specs/background-job-feed-refresh/spec.md"
- },
- {
- "slug": "calendar-widget",
- "title": "Calendar Widget",
- "summary": "The calendar widget is a built-in LaunchPad widget type that renders aggregated events from internal Nextcloud calendars and external ICS feeds in a single dashboard tile.",
+ "slug": "permissions",
+ "title": "Permission tiers",
+ "summary": "People personalise their homepage without touching the locked widgets.",
"status": "stable",
- "docsUrl": "openspec/specs/calendar-widget/spec.md"
+ "docsUrl": "openspec/specs/permissions/spec.md",
+ "title_nl": "Rechtenniveaus",
+ "summary_nl": "Mensen passen hun startpagina aan zonder de vergrendelde widgets te raken."
},
{
- "slug": "cli-commands",
- "title": "CLI Commands Suite",
- "summary": "The CLI commands suite establishes a coherent, standardized operator interface for LaunchPad management tasks.",
+ "slug": "admin-roles",
+ "title": "Role delegation",
+ "summary": "You hand a colleague dashboard admin without full Nextcloud rights.",
"status": "stable",
- "docsUrl": "openspec/specs/cli-commands/spec.md"
+ "docsUrl": "openspec/specs/admin-roles/spec.md",
+ "title_nl": "Roldelegatie",
+ "summary_nl": "Je geeft een collega dashboardbeheer zonder volledige Nextcloud-rechten."
},
{
"slug": "conditional-visibility",
- "title": "Conditional Visibility",
- "summary": "Conditional visibility allows widget placements to be shown or hidden based on dynamic rules.",
+ "title": "Conditional visibility",
+ "summary": "You show finance cards to finance and banners only this week.",
"status": "stable",
- "docsUrl": "openspec/specs/conditional-visibility/spec.md"
- },
- {
- "slug": "confluence-html-import",
- "title": "Confluence HTML Export Importer",
- "summary": "Organisations migrating from Atlassian Confluence (or supplementing it with LaunchPad) need a one-shot bulk import that converts existing Confluence page hierarchies into LaunchPad dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/confluence-html-import/spec.md"
- },
- {
- "slug": "container-widget",
- "title": "Container Widget",
- "summary": "The container widget hosts a sub-grid of child widget placements inside a single outer-grid cell.",
- "status": "stable",
- "docsUrl": "openspec/specs/container-widget/spec.md"
- },
- {
- "slug": "dashboard-bulk-operations",
- "title": "Dashboard Bulk Operations",
- "summary": "Dashboard bulk operations expose four batch admin endpoints for large-scale management of LaunchPad dashboards: bulk delete, bulk re-parent, bulk publication-status update, and bulk re-index.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-bulk-operations/spec.md"
- },
- {
- "slug": "dashboard-cascade-events",
- "title": "Dashboard Cascade Events",
- "summary": "When a LaunchPad dashboard is deleted, all dependent data (widget placements, comments, reactions, locks, versions, public shares, metadata values, translations, view analytics, child-tree dashboards) MUST be automatically removed.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-cascade-events/spec.md"
- },
- {
- "slug": "dashboard-deeplinking",
- "title": "Dashboard Deep-Linking",
- "summary": "Each dashboard has a stable, addressable URL based on its slug-chain.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-deeplinking/spec.md"
- },
- {
- "slug": "dashboard-export-import",
- "title": "Dashboard Export & Import",
- "summary": "Dashboard export and import allow LaunchPad administrators to create versioned snapshots of dashboard configurations, widgets, metadata fields, and associated assets.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-export-import/spec.md"
- },
- {
- "slug": "dashboard-icons",
- "title": "Dashboard Icons",
- "summary": "LaunchPad dashboards (and the dashboard-list items in the switcher sidebar and admin UI) display an icon next to their name.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-icons/spec.md"
+ "docsUrl": "openspec/specs/conditional-visibility/spec.md",
+ "title_nl": "Voorwaardelijke zichtbaarheid",
+ "summary_nl": "Je toont finance-kaarten aan finance en banners alleen deze week."
},
{
"slug": "dashboard-kiosk-mode",
- "title": "Dashboard Kiosk Mode",
- "summary": "Turns LaunchPad dashboards into unattended signage by rendering them chrome-less and full-viewport via a kiosk=1 flag or a public playlist token.",
+ "title": "Kiosk and signage mode",
+ "summary": "You rotate dashboards on a lobby screen unattended.",
"status": "stable",
- "docsUrl": "openspec/specs/dashboard-kiosk-mode/spec.md"
- },
- {
- "slug": "dashboard-language-content",
- "title": "Dashboard Language Content",
- "summary": "Per-language content variants for LaunchPad dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-language-content/spec.md"
- },
- {
- "slug": "dashboard-locking",
- "title": "Dashboard Locking",
- "summary": "Dashboard locking provides a concurrent-edit guard for dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-locking/spec.md"
- },
- {
- "slug": "dashboard-metadata-fields",
- "title": "Dashboard Metadata Fields",
- "summary": "Dashboard Metadata Fields allow administrators to define custom, queryable attributes that can be attached to every dashboard in a LaunchPad instance.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-metadata-fields/spec.md"
- },
- {
- "slug": "dashboard-public-share",
- "title": "Dashboard Public Share",
- "summary": "Lets dashboard owners publish read-only public links to their dashboards, optionally protected by a password and an expiry date.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-public-share/spec.md"
+ "docsUrl": "openspec/specs/dashboard-kiosk-mode/spec.md",
+ "title_nl": "Kiosk- en narrowcastmodus",
+ "summary_nl": "Je laat dashboards onbemand rouleren op een lobbyscherm."
},
{
"slug": "dashboard-quota-limits",
- "title": "Dashboard Quota Limits",
- "summary": "Numeric admin-governance quotas for LaunchPad: maximum personal dashboards per user and maximum widget placements per dashboard.",
+ "title": "Dashboard quotas",
+ "summary": "You cap dashboards per user and keep the instance tidy.",
"status": "stable",
- "docsUrl": "openspec/specs/dashboard-quota-limits/spec.md"
- },
- {
- "slug": "dashboard-reactions",
- "title": "Dashboard Reactions",
- "summary": "Dashboard reactions enable lightweight social feedback via emoji on LaunchPad dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-reactions/spec.md"
- },
- {
- "slug": "dashboard-sharing",
- "title": "Dashboard Sharing",
- "summary": "Dashboard sharing lets a dashboard owner grant read or edit access on a personal (type: 'user') dashboard to specific Nextcloud users or groups.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-sharing/spec.md"
+ "docsUrl": "openspec/specs/dashboard-quota-limits/spec.md",
+ "title_nl": "Dashboardquota",
+ "summary_nl": "Je begrenst dashboards per gebruiker en houdt de omgeving netjes."
},
{
"slug": "dashboard-switcher",
- "title": "Dashboard Switcher",
- "summary": "The dashboard switcher is a left-edge slide-in sidebar that lets a user see every dashboard visible to them and switch between them with a single click.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-switcher/spec.md"
- },
- {
- "slug": "dashboard-versioning",
- "title": "Dashboard Versioning",
- "summary": "Enable version history and one-click restoration for LaunchPad dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/dashboard-versioning/spec.md"
- },
- {
- "slug": "dashboard-view-analytics",
- "title": "Dashboard View Analytics",
- "summary": "Aggregate, privacy-preserving view counts per dashboard so LaunchPad administrators can understand which dashboards are actually being used.",
+ "title": "Multiple dashboards",
+ "summary": "You build a homepage per role and switch in one click.",
"status": "stable",
- "docsUrl": "openspec/specs/dashboard-view-analytics/spec.md"
+ "docsUrl": "openspec/specs/dashboard-switcher/spec.md",
+ "title_nl": "Meerdere dashboards",
+ "summary_nl": "Je bouwt een startpagina per rol en wisselt met een klik."
},
{
- "slug": "dashboards",
- "title": "Dashboards",
- "summary": "Dashboards are the core organizational unit in LaunchPad.",
+ "slug": "grid-layout",
+ "title": "Drag-and-drop grid",
+ "summary": "You drag, drop, and resize cards until the layout fits.",
"status": "stable",
- "docsUrl": "openspec/specs/dashboards/spec.md"
+ "docsUrl": "openspec/specs/grid-layout/spec.md",
+ "title_nl": "Sleep-en-neerzet raster",
+ "summary_nl": "Je sleept, plaatst en schaalt kaarten tot de indeling klopt."
},
{
- "slug": "default-widget-bundle",
- "title": "Default Widget Bundle",
- "summary": "Every newly-created personal dashboard ships with a preconfigured set of four widget placements so the user lands on a non-empty grid.",
+ "slug": "nc-dashboard-widget-proxy",
+ "title": "Nextcloud widgets",
+ "summary": "You drop in any Files, Calendar, or Talk widget you already use.",
"status": "stable",
- "docsUrl": "openspec/specs/default-widget-bundle/spec.md"
+ "docsUrl": "openspec/specs/nc-dashboard-widget-proxy/spec.md",
+ "title_nl": "Nextcloud-widgets",
+ "summary_nl": "Je plaatst elke Bestanden-, Agenda- of Talk-widget die je al gebruikt."
},
{
- "slug": "demo-data-showcases",
- "title": "Demo Data Showcases",
- "summary": "The demo-data-showcases capability provides administrators with one-click installation of pre-built, fully populated example dashboards that illustrate different organizational use cases.",
+ "slug": "tiles",
+ "title": "Shortcut tiles",
+ "summary": "You pin your key tools with an icon, colour, and link.",
"status": "stable",
- "docsUrl": "openspec/specs/demo-data-showcases/spec.md"
+ "docsUrl": "openspec/specs/tiles/spec.md",
+ "title_nl": "Snelkoppelingstegels",
+ "summary_nl": "Je pint je belangrijkste tools met een icoon, kleur en link."
},
{
- "slug": "divider-widget",
- "title": "Divider Widget",
- "summary": "The divider widget is a lightweight, configurable visual separator for LaunchPad dashboards.",
+ "slug": "dashboard-public-share",
+ "title": "Public share links",
+ "summary": "You share a read-only dashboard with a single link.",
"status": "stable",
- "docsUrl": "openspec/specs/divider-widget/spec.md"
+ "docsUrl": "openspec/specs/dashboard-public-share/spec.md",
+ "title_nl": "Openbare deellinks",
+ "summary_nl": "Je deelt een alleen-lezen dashboard met een enkele link."
},
{
- "slug": "effective-default-marker",
- "title": "Effective Default Marker",
- "summary": "The dashboard switcher sidebar marks the user's *effective default dashboard*.",
+ "slug": "dashboard-versioning",
+ "title": "Versioning and rollback",
+ "summary": "You roll a dashboard back to last week in one step.",
"status": "stable",
- "docsUrl": "openspec/specs/effective-default-marker/spec.md"
+ "docsUrl": "openspec/specs/dashboard-versioning/spec.md",
+ "title_nl": "Versiebeheer en terugdraaien",
+ "summary_nl": "Je draait een dashboard in een stap terug naar vorige week."
},
{
- "slug": "files-widget",
- "title": "Files Widget",
- "summary": "The files widget is a built-in LaunchPad widget type that lets dashboard authors embed an inline Nextcloud Files browser directly on a dashboard.",
+ "slug": "dashboard-export-import",
+ "title": "Export and import",
+ "summary": "You move a dashboard between environments from the UI or the command line.",
"status": "stable",
- "docsUrl": "openspec/specs/files-widget/spec.md"
+ "docsUrl": "openspec/specs/dashboard-export-import/spec.md",
+ "title_nl": "Exporteren en importeren",
+ "summary_nl": "Je verplaatst een dashboard tussen omgevingen via de interface of de opdrachtregel."
},
{
- "slug": "footer-customization",
- "title": "Footer Customization",
- "summary": "Footer Customization provides per-instance branding, legal disclaimers, and contact information rendered below the dashboard surface.",
+ "slug": "runtime-or-consumption",
+ "title": "Live business data",
+ "summary": "You pull live figures over GraphQL when OpenRegister is present.",
"status": "stable",
- "docsUrl": "openspec/specs/footer-customization/spec.md"
+ "docsUrl": "openspec/specs/runtime-or-consumption/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "Live bedrijfsdata",
+ "summary_nl": "Je haalt live cijfers op via GraphQL wanneer OpenRegister aanwezig is."
},
{
- "slug": "grid-layout",
- "title": "Grid Layout",
- "summary": "The grid layout system powers the drag-and-drop dashboard experience in LaunchPad.",
+ "slug": "kpi-cards",
+ "title": "KPI cards",
+ "summary": "You show counts and charts straight on the dashboard.",
"status": "stable",
- "docsUrl": "openspec/specs/grid-layout/spec.md"
+ "docsUrl": "openspec/specs/default-widget-bundle/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "KPI-kaarten",
+ "summary_nl": "Je toont aantallen en grafieken direct op het dashboard."
},
{
- "slug": "groupfolder-storage-backend",
- "title": "Groupfolder Storage Backend",
- "summary": "Abstracts dashboard content storage behind a unified read/write/delete interface so operators can choose between the default database backend and an optional Nextcloud GroupFolder backend.",
+ "slug": "confluence-html-import",
+ "title": "Confluence import",
+ "summary": "You bring a Confluence page in as dashboard content.",
"status": "stable",
- "docsUrl": "openspec/specs/groupfolder-storage-backend/spec.md"
+ "docsUrl": "openspec/specs/confluence-html-import/spec.md",
+ "title_nl": "Confluence-import",
+ "summary_nl": "Je haalt een Confluence-pagina binnen als dashboardinhoud."
},
{
- "slug": "header-widget",
- "title": "Header Widget",
- "summary": "The header widget is a built-in LaunchPad widget type that drops a full-width banner onto a dashboard with a configurable title, optional subtitle, optional background image (URL or NC file), an optional color overlay, and an optional call-to-action button.",
+ "slug": "prometheus-metrics",
+ "title": "Prometheus metrics",
+ "summary": "You scrape health and usage from a standard metrics endpoint.",
"status": "stable",
- "docsUrl": "openspec/specs/header-widget/spec.md"
+ "docsUrl": "openspec/specs/prometheus-metrics/spec.md",
+ "title_nl": "Prometheus-metrieken",
+ "summary_nl": "Je leest gezondheid en gebruik uit via een standaard metrics-endpoint."
},
{
- "slug": "image-widget",
- "title": "Image Widget",
- "summary": "The image widget is a built-in LaunchPad widget type that lets dashboard authors place a single image.",
+ "slug": "orphaned-data-cleanup",
+ "title": "Data cleanup",
+ "summary": "A background job clears orphaned data on its own.",
"status": "stable",
- "docsUrl": "openspec/specs/image-widget/spec.md"
+ "docsUrl": "openspec/specs/orphaned-data-cleanup/spec.md",
+ "title_nl": "Data-opschoning",
+ "summary_nl": "Een achtergrondtaak ruimt verweesde data vanzelf op."
},
{
- "slug": "infrastructure-helpers",
- "title": "Infrastructure Helpers",
- "summary": "The infrastructure-helpers capability collects small, pure (or nearly-pure) utility classes that are reused across multiple capability boundaries.",
- "status": "stable",
- "docsUrl": "openspec/specs/infrastructure-helpers/spec.md"
+ "slug": "activity-feed-integration",
+ "title": "Activity feed",
+ "summary": "Core dashboard actions land in the Nextcloud activity stream.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/activity-feed-integration/spec.md",
+ "title_nl": "Activiteitenoverzicht",
+ "summary_nl": "Kerndashboardacties komen in de Nextcloud-activiteitenstroom terecht."
},
{
- "slug": "initial-state-contract",
- "title": "Initial State Contract",
- "summary": "The initial-state-contract capability formalises the precise set of keys that PHP pushes via Nextcloud's IInitialState::provideInitialState for each Vue mount in LaunchPad, and the matching provide() calls each entry point emits to expose those keys to the rest of the component tree.",
- "status": "stable",
- "docsUrl": "openspec/specs/initial-state-contract/spec.md"
+ "slug": "nc-unified-search-integration",
+ "title": "Unified search",
+ "summary": "You find dashboards from the Nextcloud search bar.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/nc-unified-search-integration/spec.md",
+ "title_nl": "Geintegreerd zoeken",
+ "summary_nl": "Je vindt dashboards via de Nextcloud-zoekbalk."
},
{
- "slug": "label-widget",
- "title": "Label Widget",
- "summary": "The label widget is a built-in LaunchPad widget type that lets dashboard authors drop a short, single-line, plain-text heading onto a dashboard to title a row of widgets or mark a zone.",
- "status": "stable",
- "docsUrl": "openspec/specs/label-widget/spec.md"
+ "slug": "groupfolder-storage-backend",
+ "title": "GroupFolder storage (optional)",
+ "summary": "You optionally store dashboard content in a shared GroupFolder.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/groupfolder-storage-backend/spec.md",
+ "title_nl": "GroupFolder-opslag (optioneel)",
+ "summary_nl": "Je bewaart dashboardinhoud optioneel in een gedeelde GroupFolder."
},
{
- "slug": "launchpad-adopt-or-abstractions",
- "title": "Launchpad Adopt OR Abstractions",
- "summary": "Keeps LaunchPad installable and runnable without OpenRegister or OpenConnector while letting its widgets consume OR data when present.",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-adopt-or-abstractions/spec.md"
+ "slug": "launchpad-spend-analytics-widget",
+ "title": "Spend analytics card",
+ "summary": "You preview spend from financeq and procest on a card.",
+ "status": "beta",
+ "docsUrl": "openspec/specs/launchpad-spend-analytics-widget/spec.md",
+ "providedBy": "openregister",
+ "title_nl": "Uitgavenanalyse-kaart",
+ "summary_nl": "Je bekijkt uitgaven uit financeq en procest op een kaart."
},
{
- "slug": "launchpad-ai-dashboard-assistant",
- "title": "Launchpad AI Dashboard Assistant",
- "summary": "Add an embedded AI assistant widget (launchpad_ai_assistant) that lets the dashboard viewer ask natural-language questions about their dashboard's data and receive a streamed reply.",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-ai-dashboard-assistant/spec.md"
+ "slug": "launchpad-mobile-remote-access",
+ "title": "Mobile access",
+ "summary": "A responsive homepage that survives on a phone.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-mobile-remote-access/spec.md",
+ "title_nl": "Mobiele toegang",
+ "summary_nl": "Een responsieve startpagina die het op een telefoon volhoudt."
},
{
"slug": "launchpad-compliance-audit-panel",
- "title": "Launchpad Compliance Audit Panel",
- "summary": "Surface the organisation's compliance posture on a launchpad dashboard through one widget (launchpad_compliance_audit).",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-compliance-audit-panel/spec.md"
+ "title": "Audit panel",
+ "summary": "A dedicated compliance and audit-trail view.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-compliance-audit-panel/spec.md",
+ "title_nl": "Auditpaneel",
+ "summary_nl": "Een eigen weergave voor compliance en het auditspoor."
},
{
"slug": "launchpad-enterprise-security-access",
- "title": "Launchpad Enterprise Security Access",
- "summary": "Surface enterprise security and access posture on a launchpad dashboard through a read-only widget (launchpad_security_access).",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-enterprise-security-access/spec.md"
+ "title": "SSO and access posture",
+ "summary": "You surface SAML, TOTP, and WebAuthn status on the dashboard.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-enterprise-security-access/spec.md",
+ "title_nl": "SSO en toegangsstatus",
+ "summary_nl": "Je toont de status van SAML, TOTP en WebAuthn op het dashboard."
},
{
- "slug": "launchpad-file-access-widget",
- "title": "Launchpad File Access Widget",
- "summary": "Surface dossier documents (and arbitrary Nextcloud Files objects) on a launchpad dashboard via a single widget (launchpad_file_access).",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-file-access-widget/spec.md"
+ "slug": "launchpad-ai-dashboard-assistant",
+ "title": "AI assistant",
+ "summary": "You ask for the widget you need and have it placed for you.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-ai-dashboard-assistant/spec.md",
+ "title_nl": "AI-assistent",
+ "summary_nl": "Je vraagt om de widget die je nodig hebt en die wordt voor je geplaatst."
},
{
"slug": "launchpad-meeting-calendar-actions",
- "title": "Launchpad Meeting Calendar Actions",
- "summary": "Surface meeting and agenda actions on a launchpad dashboard via the widget launchpad_meeting_actions.",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-meeting-calendar-actions/spec.md"
- },
- {
- "slug": "launchpad-mobile-remote-access",
- "title": "Launchpad Mobile Remote Access",
- "summary": "Define the **manifest contract** for declaring widget mobile-readiness so the launchpad workspace can render a coherent mobile + remote experience.",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-mobile-remote-access/spec.md"
- },
- {
- "slug": "launchpad-spend-analytics-widget",
- "title": "Launchpad Spend Analytics Widget",
- "summary": "Surface procurement + financial spend analytics on a launchpad dashboard as a single widget (launchpad_spend_analytics).",
- "status": "stable",
- "docsUrl": "openspec/specs/launchpad-spend-analytics-widget/spec.md"
- },
- {
- "slug": "legacy-widget-bridge",
- "title": "Legacy Widget Bridge",
- "summary": "LaunchPad's grid can render widgets from two eras of the Nextcloud widget API: modern widgets that implement IAPIWidget / IAPIWidgetV2 (covered by the [widgets](./widgets/spec.md) capability), and legacy widgets that use the older callback-registration pattern by calling window.OCA.Dashboard.register(appId, callback) at bootstrap.",
- "status": "stable",
- "docsUrl": "openspec/specs/legacy-widget-bridge/spec.md"
- },
- {
- "slug": "link-button-widget",
- "title": "Link-Button Widget",
- "summary": "The link-button widget is a built-in LaunchPad widget type that lets dashboard authors drop a styled, clickable tile onto a dashboard.",
- "status": "stable",
- "docsUrl": "openspec/specs/link-button-widget/spec.md"
- },
- {
- "slug": "links-widget",
- "title": "Links Widget",
- "summary": "Provide a multi-column dashboard widget that renders a curated grid of link cards organised into named sections.",
- "status": "stable",
- "docsUrl": "openspec/specs/links-widget/spec.md"
- },
- {
- "slug": "menu-widget",
- "title": "Menu Widget",
- "summary": "The menu widget is a built-in LaunchPad widget type that renders a hierarchical, in-page navigation tree distinct from the application sidebar.",
- "status": "stable",
- "docsUrl": "openspec/specs/menu-widget/spec.md"
- },
- {
- "slug": "navigation-editor-org",
- "title": "Organization-wide Navigation Editor",
- "summary": "The navigation-editor-org capability provides a robust, admin-curated, group-aware org-wide navigation tree distinct from the personal dashboard list.",
- "status": "stable",
- "docsUrl": "openspec/specs/navigation-editor-org/spec.md"
- },
- {
- "slug": "nc-dashboard-widget-proxy",
- "title": "Nc Dashboard Widget Proxy",
- "summary": "Defines the user-facing surface of the Nextcloud Dashboard widget proxy (nc-widget placement type).",
- "status": "stable",
- "docsUrl": "openspec/specs/nc-dashboard-widget-proxy/spec.md"
- },
- {
- "slug": "nc-unified-search-integration",
- "title": "Nextcloud Unified Search Integration",
- "summary": "Nextcloud's unified search (Ctrl+K / Cmd+K) provides a global discovery mechanism for content across all installed apps.",
- "status": "stable",
- "docsUrl": "openspec/specs/nc-unified-search-integration/spec.md"
- },
- {
- "slug": "news-widget",
- "title": "News Widget",
- "summary": "The news widget aggregates RSS and Atom feed items from one or more configured sources and renders them on a LaunchPad dashboard.",
- "status": "stable",
- "docsUrl": "openspec/specs/news-widget/spec.md"
- },
- {
- "slug": "people-widget",
- "title": "People Widget",
- "summary": "The people-widget capability registers a dashboard widget that displays a discoverable directory of Nextcloud users with customizable layout (card/grid/list), profile field visibility control, group filtering, and birthday tracking.",
- "status": "stable",
- "docsUrl": "openspec/specs/people-widget/spec.md"
- },
- {
- "slug": "permissions",
- "title": "Permission Levels",
- "summary": "Permission levels control what users can do with their dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/permissions/spec.md"
- },
- {
- "slug": "prometheus-metrics",
- "title": "Prometheus Metrics",
- "summary": "Expose application metrics in Prometheus text exposition format at GET /api/metrics for monitoring, alerting, and operational dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/prometheus-metrics/spec.md"
- },
- {
- "slug": "quicklinks-widget",
- "title": "Quicklinks Widget",
- "summary": "The quicklinks widget is a built-in LaunchPad widget type that renders a flat, dense grid of icon-and-label shortcuts inside a single placement.",
- "status": "stable",
- "docsUrl": "openspec/specs/quicklinks-widget/spec.md"
- },
- {
- "slug": "resource-uploads",
- "title": "Resource Uploads",
- "summary": "The resource-uploads capability owns a small mini file API for binary assets that LaunchPad widgets reference directly: dashboard icons, image-widget images, link-button icons, etc.",
- "status": "stable",
- "docsUrl": "openspec/specs/resource-uploads/spec.md"
- },
- {
- "slug": "role-feature-permissions",
- "title": "Role Feature Permissions",
- "summary": "This capability governs which dashboard widgets and features are visible, accessible, and default-seeded for users based on their Nextcloud group (role).",
- "status": "stable",
- "docsUrl": "openspec/specs/role-feature-permissions/spec.md"
- },
- {
- "slug": "runtime-or-consumption",
- "title": "Runtime OR Consumption",
- "summary": "LaunchPad optionally surfaces data from OpenRegister (OR) in certain widgets.",
- "status": "stable",
- "docsUrl": "openspec/specs/runtime-or-consumption/spec.md"
- },
- {
- "slug": "runtime-shell",
- "title": "Runtime Shell",
- "summary": "The runtime-shell capability owns the user-facing workspace page chrome.",
- "status": "stable",
- "docsUrl": "openspec/specs/runtime-shell/spec.md"
- },
- {
- "slug": "setup-wizard",
- "title": "Setup Wizard",
- "summary": "The Setup Wizard is a multi-step first-run configuration flow for freshly installed LaunchPad instances.",
- "status": "stable",
- "docsUrl": "openspec/specs/setup-wizard/spec.md"
- },
- {
- "slug": "text-display-widget",
- "title": "Text-Display Widget",
- "summary": "The text-display widget renders user-authored text content inside a dashboard cell, with limited HTML support for inline formatting (bold, italics, links, line breaks).",
- "status": "stable",
- "docsUrl": "openspec/specs/text-display-widget/spec.md"
- },
- {
- "slug": "tiles",
- "title": "Custom Tiles",
- "summary": "Custom tiles are user-created shortcut cards that provide quick access to Nextcloud apps or external URLs.",
- "status": "stable",
- "docsUrl": "openspec/specs/tiles/spec.md"
- },
- {
- "slug": "video-widget",
- "title": "Video Widget",
- "summary": "Embed video content directly on a LaunchPad dashboard from four source types: YouTube, Vimeo, self-hosted PeerTube instances, and Nextcloud Files.",
- "status": "stable",
- "docsUrl": "openspec/specs/video-widget/spec.md"
- },
- {
- "slug": "widgets",
- "title": "Widgets",
- "summary": "Widgets are the primary content blocks on LaunchPad dashboards.",
- "status": "stable",
- "docsUrl": "openspec/specs/widgets/spec.md"
+ "title": "Meeting actions",
+ "summary": "You act on the day's meetings straight from a card.",
+ "status": "soon",
+ "docsUrl": "openspec/specs/launchpad-meeting-calendar-actions/spec.md",
+ "title_nl": "Vergaderacties",
+ "summary_nl": "Je handelt de vergaderingen van de dag direct vanaf een kaart af."
}
]
diff --git a/docs/features/README.md b/docs/features/README.md
index 0abe675a6..bb247588a 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -25,6 +25,8 @@ LaunchPad maps to the **BI-component** within the GEMMA reference architecture.
| [Admin Templates](./admin-templates.md) | Pre-configured dashboards distributed to users by Nextcloud group membership | [admin-templates.md](./admin-templates.md) |
| [Admin Settings](./admin-settings.md) | Global configuration: allow user dashboards, max dashboards per user, default grid columns | [admin-settings.md](./admin-settings.md) |
| [Conditional Visibility](./conditional-visibility.md) | Show or hide widget placements based on time, date, group membership, or user attributes | [conditional-visibility.md](./conditional-visibility.md) |
+| [Clock & Weather](./clock-weather-widgets.md) | Ambient tiles: a client-side clock (analog/digital, timezone) and a server-fetched, cached weather reading with locale-driven units | [clock-weather-widgets.md](./clock-weather-widgets.md) |
+| [Quick search](./tile-quick-search.md) | Keyboard-driven tile search (`/`, `Ctrl+K`) as a placeable widget, with a configurable no-match fallback | [tile-quick-search.md](./tile-quick-search.md) |
| [Prometheus Metrics](./prometheus-metrics.md) | Monitoring endpoint: dashboard count, widget usage, tile counts, health check | [prometheus-metrics.md](./prometheus-metrics.md) |
## Architecture
diff --git a/docs/features/admin-template-resync.md b/docs/features/admin-template-resync.md
new file mode 100644
index 000000000..73841ddcc
--- /dev/null
+++ b/docs/features/admin-template-resync.md
@@ -0,0 +1,62 @@
+# Re-syncing an admin template
+
+When an admin template is distributed, each targeted user receives an
+**independent personal copy**. That independence is what lets people
+personalise their dashboard — but it also means that, without this feature,
+correcting a template only ever reached *future* first-logins. A functioneel
+beheerder who fixed a wrong link in the Burgerzaken template still had 40
+colleagues looking at the old one.
+
+Re-sync closes that gap: it pushes an updated template out to copies that
+already exist.
+
+## The two strategies
+
+| Strategy | What happens to the template's widgets | What happens to the user's own widgets |
+|----------|----------------------------------------|----------------------------------------|
+| **Merge** (default) | Updated to match the template | **Kept** |
+| **Overwrite** | Replaced wholesale with the template layout | **Removed** |
+
+Use **merge** for routine corrections — a changed link, a new compulsory
+announcement — so nobody loses the shortcuts they added. Use **overwrite**
+only when you genuinely intend to reset a department to the standard layout,
+and tell people first.
+
+Compulsory widgets are reconciled under **both** strategies: a widget the
+template pins cannot be missing from a copy after a re-sync.
+
+## Always dry-run first
+
+The action supports `dryRun`, which reports exactly which copies would change
+and what would happen to each — **without mutating anything**. Run it, read
+it, then run for real. This is the difference between "I think this is safe"
+and "I know what this will do to 40 people's screens."
+
+```http
+POST /apps/launchpad/api/admin/templates/{id}/resync
+{ "strategy": "merge", "dryRun": true }
+```
+
+## What else happens
+
+- The operation is **idempotent** — running it twice produces no further change.
+- Each run writes an **audit record** (who, what, when).
+- Affected users are **notified**.
+- For large target groups the work is handed to a background job rather than
+ blocking the request.
+
+## Permissions
+
+Admin-only, guarded both by the `AuthorizedAdminSetting` attribute and an
+explicit in-body admin assertion.
+
+## Known limitation
+
+Notifications are delivered via Nextcloud's `INotification` — the app's
+existing (and only) notification pattern. The `x-openregister-notifications`
+dialect branch is not wired in; see the archived change's `tasks.md`.
+
+## Related
+
+- [Admin Templates](admin-templates.md) — authoring and distributing templates.
+- [Permission Levels](permissions.md) — what a copy's permission level allows.
diff --git a/docs/features/clock-weather-widgets.md b/docs/features/clock-weather-widgets.md
new file mode 100644
index 000000000..08747f7c4
--- /dev/null
+++ b/docs/features/clock-weather-widgets.md
@@ -0,0 +1,87 @@
+# Clock & Weather widgets
+
+Two lightweight "ambient tile" widgets that give a dashboard a sense of time
+and place. Both are placed like any other widget and configured from the
+widget settings panel; their configuration lives in the placement's
+`widgetContent` JSON.
+
+## Clock
+
+A fully **client-side** widget — it reads the device clock and makes no
+network request and no backend call at all.
+
+| Setting | Values | Notes |
+|---------|--------|-------|
+| Style | `digital`, `analog` | Digital shows a formatted time string; analog draws a clock face. |
+| Hour format | 12-hour, 24-hour | Applies to the digital style and the accessible label. |
+| Timezone | any IANA zone (e.g. `Europe/Amsterdam`) | Converted with `Intl`; defaults to the browser's zone. |
+| Show date | on / off | Date is rendered in the viewer's locale. |
+
+**Accessibility.** The rendered time is always available to screen readers as
+a text string, including for the analog style, so the widget is never a
+purely visual element.
+
+**Typical use.** A kiosk or narrowcasting screen in a public hall, or a
+service-desk dashboard where a shared, unambiguous clock (and, for
+distributed teams, a second tile pinned to another timezone) matters.
+
+## Weather
+
+Shows current conditions for a location. Unlike the clock, this widget needs
+data, so the fetch happens **server-side** and the result is cached — the
+browser never sees a provider URL or API key.
+
+| Setting | Values | Notes |
+|---------|--------|-------|
+| Location | free text | Leave empty to use the viewer's own Nextcloud `weather_status` location. |
+| Units | follow locale (default), metric, imperial | An explicit choice overrides the locale default. |
+
+### How the reading is resolved
+
+1. The widget calls LaunchPad's own endpoint, `GET /api/weather/{placementId}`.
+2. The endpoint checks that the caller may view that placement — an
+ unauthorised caller gets `403` and **no fetch is performed**.
+3. `WeatherService` returns a cached reading when one exists inside the TTL
+ (default 900 s, configurable).
+4. Otherwise it fetches: the viewer's `weather_status` provider when no
+ location is configured, else the configured provider URL.
+5. If the upstream fails but an older reading exists, that reading is served
+ with `stale: true` rather than an error. With no cached reading at all the
+ endpoint returns `502` and the widget renders an error state.
+
+The response contains exactly `location`, `tempValue`, `units`, `condition`,
+`conditionText`, `language`, `fetchedAt`, `stale` — never a credential.
+
+### Locale-driven units and language
+
+Units and language follow the **viewer's** Nextcloud locale by default, so a
+`nl_NL` colleague sees °C and Dutch condition text while an `en_US` colleague
+on the same shared dashboard sees °F. An author-set units override wins over
+the locale default. This is deliberate: hardcoding units or English-only
+condition strings is a long-standing source of complaints about weather
+widgets.
+
+**Accessibility.** The condition is conveyed by an icon **and** a text label,
+never by icon or colour alone.
+
+### Admin setup
+
+Only needed when you are not relying on the viewer's `weather_status`
+location. Both values are stored server-side and never sent to the browser:
+
+| App config key | Meaning |
+|----------------|---------|
+| `weather_provider_url` | Provider endpoint template; supports the placeholders `{location}`, `{apiKey}`, `{units}`, `{lang}`. |
+| `weather_provider_api_key` | Provider API key, substituted into `{apiKey}`. |
+| `weather_cache_ttl_seconds` | Cache TTL; defaults to 900. |
+
+```bash
+occ config:app:set launchpad weather_provider_url --value='https://api.example/weather?q={location}&units={units}&lang={lang}&appid={apiKey}'
+occ config:app:set launchpad weather_provider_api_key --value='…'
+```
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- [Conditional visibility](conditional-visibility.md) — show an ambient tile
+ only during opening hours, or only to one group.
diff --git a/docs/features/conditional-visibility.md b/docs/features/conditional-visibility.md
index 95bb49c0d..063c1f924 100644
--- a/docs/features/conditional-visibility.md
+++ b/docs/features/conditional-visibility.md
@@ -26,6 +26,43 @@ Conditional visibility allows widget placements to be shown or hidden based on d
| POST | `/api/widgets/{id}/rules` | Add rule to placement |
| PUT | `/api/rules/{id}` | Update rule |
| DELETE | `/api/rules/{id}` | Delete rule |
+| POST | `/api/visibility/preview` | Preview a rule set (see below) — read-only, persists nothing |
+
+## Visibility rules & preview
+
+The rules above are edited from the widget's right-click context menu →
+**Visibility rules…**, which opens the `ConditionalVisibilityEditor`. Each
+rule is a row (`VisibilityRuleRow`) where you pick a type (group / time /
+date / attribute), fill in the type-specific fields, and choose whether the
+rule **includes** or **excludes**:
+
+- Rules are grouped under two headings that spell out the engine's logic
+ directly: **"Show when ANY of these match"** (include rules, OR — at
+ least one must match) and **"Hide when ANY of these match"** (exclude
+ rules, AND — any single match hides the widget, overriding the include
+ rules).
+- With no rules at all, the widget is always shown — the editor states this
+ explicitly rather than leaving an empty list ambiguous.
+
+### Preview as audience / date
+
+Before saving, use **Preview as audience / date** to pick a set of groups
+and a moment in time and see the effective visibility for that context —
+"Visible" or "Hidden", plus which rule(s) matched. This includes rows you
+have added or edited but not yet saved, so a mis-scoped rule (e.g. an
+exclude rule that would hide the widget from everyone) can be caught before
+it goes live.
+
+The preview endpoint (`POST /api/visibility/preview`) evaluates the
+supplied rule set through the exact same evaluation pipeline used when the
+dashboard is actually rendered — it cannot diverge from real visibility,
+and it never writes to the database.
+
+See the [`conditional-visibility` engine spec](../../openspec/specs/conditional-visibility/spec.md)
+for the full rule-evaluation semantics (including known limitations such as
+midnight-spanning time windows) and the
+[`conditional-visibility-editor` spec](../../openspec/changes/conditional-visibility-editor/specs/conditional-visibility-editor/spec.md)
+for the editor/preview requirements.
## Screenshot
diff --git a/docs/features/iframe-embed.md b/docs/features/iframe-embed.md
new file mode 100644
index 000000000..71d89f943
--- /dev/null
+++ b/docs/features/iframe-embed.md
@@ -0,0 +1,67 @@
+# Iframe-embed widget
+
+Embed an external page — a status board, a Grafana panel, an internal
+tool — directly on a dashboard, instead of only linking out to it.
+
+## The host allow-list
+
+Embeddable targets are governed by the `iframe_allowed_hosts` app config
+and are **fail-closed**: enforced both when the widget is saved and again
+whenever the dashboard is rendered. An empty (or unset) allow-list denies
+**every** host — it is never interpreted as "allow all".
+
+```bash
+occ config:app:set launchpad iframe_allowed_hosts --value='["status.example.com","intranet.example.nl"]'
+```
+
+Removing a host from the list immediately stops any existing placement
+pointing at it — the widget switches to the "no longer permitted" state
+rather than continuing to render a stale live frame.
+
+## What LaunchPad's CSP contributes — and what it doesn't
+
+Every allow-listed host is added to LaunchPad's own `frame-src`
+Content-Security-Policy directive (via an `AddContentSecurityPolicyEvent`
+listener), so Nextcloud's own CSP never blocks an otherwise-permitted
+embed. This is the **only** side of the framing relationship LaunchPad
+controls.
+
+The **target** site's own `X-Frame-Options: DENY` or
+`Content-Security-Policy: frame-ancestors 'none'` header is a decision made
+by that site's owner and **cannot be overridden** by the embedder — no CSP
+change on LaunchPad's side can force such a target to render in a frame.
+When the widget detects this (no `load` event within a timeout, or a
+`load` event that resolves to an empty same-origin placeholder document),
+it renders a fallback card instead of a silent blank frame: the
+configured title, a plain-language explanation, and an "Open in new tab"
+link. This is a client-side detection, not a proxy or CSP bypass — nothing
+strips or spoofs the target's own headers.
+
+## Sandbox
+
+The iframe always carries a `sandbox` attribute. Authors may toggle
+`allow-scripts`, `allow-same-origin`, `allow-forms`, and `allow-popups`;
+`allow-top-navigation` (and its `-by-user-activation` variant) is never
+offered and is stripped even if present in a saved config, so an embedded
+frame can never navigate the host dashboard page away.
+
+## Configuration
+
+| Setting | Notes |
+|---------|-------|
+| URL | Validated against the admin allow-list, both client-side (fast feedback) and server-side (authoritative) |
+| Title | Required — exposed as the iframe's accessible `title` for screen readers |
+| Height / aspect ratio | Fixed pixel height, or one of `16:9` / `4:3` / `1:1` / `9:16` |
+| Sandbox tokens | `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups` |
+
+## Accessibility
+
+The blocked/failed state is conveyed by an icon **and** a text label,
+never by colour alone, and the "Open in new tab" link is keyboard-focusable
+and announces that it opens in a new tab.
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- [Live-data tile](live-data-tile.md) — the sibling capability this widget's
+ allow-list/CSP approach is modelled on.
diff --git a/docs/features/live-data-tile.md b/docs/features/live-data-tile.md
new file mode 100644
index 000000000..f4251e578
--- /dev/null
+++ b/docs/features/live-data-tile.md
@@ -0,0 +1,82 @@
+# Live-data tile
+
+A tile that shows a **live value** — an open-case count, a queue length, a
+budget figure — instead of being a static shortcut. It polls a source on a
+schedule, formats the value, and can badge it against thresholds.
+
+This closes LaunchPad's biggest functional gap against the wider dashboard
+market: every serious competitor renders live data on tiles.
+
+## Two ways to get the value
+
+### 1. Via OpenConnector (preferred)
+
+When the [OpenConnector](https://github.com/ConductionNL/openconnector) app is
+installed and advertises the `dashboard-http-datasource` capability, pick a
+pre-configured **source** and give a value expression. OpenConnector owns the
+credentials, host allow-listing, rate-limiting and caching; LaunchPad only asks
+for a value.
+
+Use this whenever the upstream needs authentication.
+
+### 2. Direct URL (fallback)
+
+When OpenConnector is not installed, a tile can poll a URL directly — but only
+if its host appears in the administrator's allow-list. This mode is intended
+for unauthenticated internal endpoints.
+
+If OpenConnector is absent the connector mode is hidden in the tile form, and
+any tile already configured for it renders a clear "data source unavailable"
+state rather than failing silently.
+
+## Configuration
+
+| Setting | Notes |
+|---------|-------|
+| Source mode | `connector` (OpenConnector source) or `url` (direct, allow-listed) |
+| Value expression | JSONPath-lite: `$.data.open_count`, `$.items[0].total` |
+| Refresh interval | Seconds; clamped to a 30 s minimum, defaults to 300 s |
+| Formatting | Prefix, suffix, thousands separator |
+| Badge thresholds | Value ranges mapped to ok / warn / alert |
+| Link target | Where the tile navigates when activated |
+
+## What the browser never sees
+
+The widget calls LaunchPad's own endpoint, `GET /api/livetile/{placementId}`,
+and receives only `{value, formatted, badge, fetchedAt, stale}`. The source
+URL, request headers and any credential stay on the server. A caller who may
+not view the placement gets `403` and **no fetch is performed**.
+
+## The host allow-list
+
+Direct-URL mode is governed by the `livetile_allowed_hosts` app config and is
+**fail-closed** — enforced both when the tile is saved and again at every
+fetch. Removing a host from the allow-list therefore immediately stops
+existing tiles pointing at it, rather than leaving them running until someone
+notices.
+
+```bash
+occ config:app:set launchpad livetile_allowed_hosts --value='intranet.example.nl,api.example.nl'
+```
+
+An empty allow-list denies everything.
+
+## Stale values
+
+If an upstream refresh fails, the last known value is served with `stale:
+true` and the tile marks it as possibly out of date. On a service-desk or wall
+display a slightly old number is more useful than an empty tile — but it must
+be visibly flagged, so the staleness is never silent.
+
+## Accessibility
+
+The badge state is conveyed by an icon **and** a text label, never by colour
+alone, and the value carries an accessible label. This matters here because a
+threshold badge is exactly the kind of red/green signal that becomes invisible
+to a colour-blind colleague.
+
+## Related
+
+- [Widgets](widgets.md) — how widgets are discovered and placed.
+- OpenConnector `dashboard-http-datasource` — the governed resolve façade this
+ tile consumes as a leaf.
diff --git a/docs/features/service-health-ping.md b/docs/features/service-health-ping.md
new file mode 100644
index 000000000..a0e6835dd
--- /dev/null
+++ b/docs/features/service-health-ping.md
@@ -0,0 +1,78 @@
+# Service health ping
+
+An optional **online / offline / degraded** status badge on a tile, so a
+municipal IT landing page can answer *"is de zaakapplicatie bereikbaar?"* at a
+glance instead of a static link that gives no signal about whether the
+service behind it is actually up.
+
+A background job periodically pings the tile's configured health URL
+server-side, the result is cached with a short TTL, and the tile renders the
+badge from that cache — viewers never pay the upstream ping latency on page
+load.
+
+## Configuration
+
+Health ping is configured per tile, in the same editor used for the tile's
+title, icon and colours:
+
+| Setting | Notes |
+|---------|-------|
+| Enable health ping | Off by default — no badge, no request, until turned on |
+| Health check URL | Must resolve to a host on the administrator's allow-list |
+| Expected HTTP status | Defaults to any 2xx/3xx when left unset |
+| Check interval | Seconds; clamped to a 15 s minimum, defaults to 60 s |
+
+The config is stored in the placement's existing content JSON — no database
+schema change.
+
+## Classification
+
+- **Online** — the response status matches the expected status within the
+ latency threshold.
+- **Degraded** — the status matches, but the response was slow.
+- **Offline** — the request timed out, the connection failed, or the status
+ did not match. This is a *completed* reading, not a missed one: it is
+ cached and served immediately, exactly like online/degraded.
+
+## What the browser never sees
+
+The badge calls LaunchPad's own endpoint, `GET
+/api/health-ping/{placementId}`, and receives only `{state, checkedAt,
+latencyMs, stale}`. The health URL, request headers and any upstream response
+body stay on the server. A caller who may not view the placement gets `403`
+and **no ping is performed**.
+
+## The host allow-list
+
+Health ping is governed by the `healthping_allowed_hosts` app config and is
+**fail-closed** — enforced both when the tile is saved and again at every
+ping. When a host is refused, no request is ever attempted: the badge falls
+back to the last-known reading (marked stale) rather than showing a false
+"up" state.
+
+```bash
+occ config:app:set launchpad healthping_allowed_hosts --value='intranet.example.nl,api.example.nl'
+```
+
+An empty allow-list denies everything.
+
+## Background refresh
+
+`HealthPingRefreshJob` runs every 15 seconds and refreshes any ping-enabled
+tile whose cached badge is older than its own configured interval, so the
+badge a viewer sees on page load is almost always already warm.
+
+## Accessibility
+
+The badge state is conveyed by an icon **and** a text label — "Online",
+"Degraded", "Offline" — never by colour alone, and the checked-at time plus
+latency are exposed via a keyboard-reachable, screen-reader announced
+tooltip.
+
+## Related
+
+- [Custom Tiles](tiles.md) — where the health-ping toggle lives in the tile
+ editor.
+- [Live-data tile](live-data-tile.md) — the sibling capability this ping
+ reuses the shape of (allow-listed server-side fetch, `ICache`, stale
+ fallback).
diff --git a/docs/features/tile-quick-search.md b/docs/features/tile-quick-search.md
new file mode 100644
index 000000000..f26c4aa4e
--- /dev/null
+++ b/docs/features/tile-quick-search.md
@@ -0,0 +1,81 @@
+# Quick search
+
+A search bar that filters the tiles on the current dashboard as you type,
+and opens the one you pick — without reaching for the mouse.
+
+## It is a widget, not page furniture
+
+Quick search used to be part of the page chrome: LaunchPad rendered a
+search bar above the grid on every dashboard, whether anyone wanted one
+there or not.
+
+It is now the **`search` widget type**. You place it like any other widget,
+which means you choose whether a dashboard has one at all, where it sits,
+and how wide it is.
+
+> **Upgrading?** Existing dashboards do **not** get a search widget
+> automatically, so the bar disappears from them after this upgrade. To get
+> it back, edit the dashboard, choose **Add widget → Search**, and put it
+> where you want it — most people place it full-width across the top row,
+> which is where the old bar was.
+
+## Using it
+
+| Key | Does |
+|---|---|
+| `/` | Focus the search input from anywhere on the page |
+| `Ctrl`+`K` / `Cmd`+`K` | The same, and suppresses the browser's own shortcut |
+| `↑` / `↓` | Move through the matches |
+| `Enter` | Open the selected tile, honouring its configured link target |
+| `Esc` | Clear the query, undim the grid, return focus to the tiles |
+
+Typing narrows the grid by **de-emphasising** non-matching tiles rather
+than removing them, so the layout never reflows underneath you while you
+type. Matches rank prefix first, then mid-string, then subsequence.
+
+If a dashboard carries more than one search widget, the first one takes the
+`/` and `Ctrl`+`K` shortcut — two inputs cannot both hold focus. Remove it
+and the next one takes over. A dashboard with no search widget simply has
+no shortcut.
+
+## Configuration
+
+Two settings on the widget itself:
+
+**Placeholder text** — leave empty for the default, which advertises the
+two shortcuts.
+
+**When nothing matches** — what happens when the query matches no tile:
+
+| Option | Behaviour |
+|---|---|
+| Use the administrator setting | Inherit the instance-wide default (the shipped default) |
+| Show "no results" only | Stay on the dashboard and announce the empty result |
+| Hand off to Nextcloud search | Pass the query to Nextcloud's own unified search |
+| Open a web search | Open a new tab using an `https` URL template containing `{query}` |
+
+The instance-wide default is the `quicksearch_fallback_target` app config,
+set in LaunchPad's admin settings. A widget that inherits it follows
+whatever the administrator configures; a widget with its own choice
+overrides it.
+
+```bash
+occ config:app:set launchpad quicksearch_fallback_target --value='unified-search'
+```
+
+An unset value means "show no-results only" — LaunchPad never navigates a
+user away from their dashboard by default.
+
+## Accessibility
+
+The bar is a WCAG 2.2 AA combobox: `role="search"` with a programmatically
+associated label, `role="listbox"` results, `aria-activedescendant`
+tracking the keyboard selection, and an `aria-live` announcement of the
+match count or the no-match state. The selected match is marked with an
+icon as well as with colour, so the selection is never conveyed by colour
+alone.
+
+## See also
+
+- [Widgets](./widgets.md) — the full widget catalog and how placements persist
+- [Custom Tiles](./tiles.md) — what the searchable labels come from
diff --git a/docs/features/tiles.md b/docs/features/tiles.md
index c226e0651..463415599 100644
--- a/docs/features/tiles.md
+++ b/docs/features/tiles.md
@@ -20,6 +20,47 @@ Custom tiles are user-created shortcut cards that provide quick access to Nextcl
| DELETE | `/api/tiles/{id}` | Delete tile |
| POST | `/api/dashboard/{id}/tile` | Place tile on dashboard |
+## Usage analytics
+
+Tile usage analytics is a strict, downward **extension** of the
+[dashboard view-analytics](../../openspec/specs/dashboard-view-analytics/spec.md)
+capability at the tile/widget-placement grain — it does not introduce
+any new privacy machinery, only a finer-grained aggregate table.
+
+- Aggregate-only counts stored in `oc_launchpad_tile_clicks`, one row
+ per `(placementUuid, clickBucket)` per UTC day. No per-event rows
+ are ever persisted.
+- Unique-actor dedup reuses the SAME salted-daily-hash mechanism
+ (`sha256(userId || dailySalt)`, cached in `ICache` only) and the
+ SAME `SaltRotationJob` as dashboard views — no second salt or
+ rotation job.
+- Reuses the SAME `launchpad.analytics_enabled` (global) and
+ `launchpad.analytics_optout` (per-user) settings. There is no
+ separate tile-analytics opt-out.
+- The existing analytics retention-purge job is extended to also
+ purge `oc_launchpad_tile_clicks` rows older than
+ `launchpad.analytics_retention_days` in the same run — no second
+ purge job.
+- The frontend fires a fire-and-forget `POST /api/tile-click/{id}` on
+ tile activation (click or keyboard Enter), gated by
+ `GET /api/tile-analytics/config` so tracking is suppressed
+ client-side when analytics is disabled or the user opted out.
+
+### API Endpoints
+
+| Method | Endpoint | Auth | Description |
+|--------|----------|------|-------------|
+| POST | `/api/tile-click/{placementId}` | Any authed user | Record a click (always 204; no-op when disabled/opted out) |
+| GET | `/api/tile-analytics/config` | Any authed user | Whether tracking is active for the caller |
+| GET | `/api/admin/analytics/tiles/top` | Admin | Top-N tiles by click count for a period |
+| GET | `/api/admin/analytics/tiles/by-dashboard/{uuid}` | Admin | Per-dashboard tile breakdown |
+| GET | `/api/admin/analytics/tiles/export` | Admin | CSV export |
+
## Screenshot

+
+## Related
+
+- [Service health ping](service-health-ping.md) — optional online / offline /
+ degraded status badge for a tile's linked service.
diff --git a/docs/features/widgets.md b/docs/features/widgets.md
index e208341bf..010d98456 100644
--- a/docs/features/widgets.md
+++ b/docs/features/widgets.md
@@ -3,9 +3,9 @@
Widgets are the primary content blocks on LaunchPad dashboards. LaunchPad combines two widget sources:
- **Nextcloud Dashboard API widgets** — every widget registered by an installed Nextcloud app, exposed via the v1 (`IAPIWidget`) or v2 (`IAPIWidgetV2`) interface, plus the legacy callback-based widgets.
-- **Registry-driven custom widgets** — **25** in-app widget types. 17 are LaunchPad-native (defined in [`src/constants/widgetRegistry.js`](../../src/constants/widgetRegistry.js)); the other 8 are **OpenRegister analytics widgets** contributed by the communal `dashboardWidgetRegistry` in [`@conduction/nextcloud-vue`](https://codeberg.org/Conduction/nextcloud-vue) and overlaid by LaunchPad's registry. Each entry pairs a Vue renderer with an Add Widget sub-form and a `defaultContent` shape.
+- **Registry-driven custom widgets** — **25** in-app widget types. 17 are LaunchPad-native (defined in [`src/constants/widgetRegistry.js`](../../src/constants/widgetRegistry.js)); the other 8 are **OpenRegister analytics widgets** contributed by the communal `dashboardWidgetRegistry` in [`@conduction/nextcloud-vue`](https://github.com/ConductionNL/nextcloud-vue) and overlaid by LaunchPad's registry. Each entry pairs a Vue renderer with an Add Widget sub-form and a `defaultContent` shape.
-This page documents the **registry-driven** catalog. For the full shared catalog (all 25 types with screenshots), see the [Dashboard Widget Catalog](https://codeberg.org/Conduction/nextcloud-vue) in `@conduction/nextcloud-vue` (`docs/components/dashboard-widget-catalog.md`). For app-level Dashboard API widgets, see the host app's documentation.
+This page documents the **registry-driven** catalog. For the full shared catalog (all 25 types with screenshots), see the [Dashboard Widget Catalog](https://github.com/ConductionNL/nextcloud-vue) in `@conduction/nextcloud-vue` (`docs/components/dashboard-widget-catalog.md`). For app-level Dashboard API widgets, see the host app's documentation.
## Widget catalog
@@ -416,7 +416,7 @@ The registry-driven replacement for the deprecated standalone tile-creation flow
# OpenRegister analytics widgets
These 8 types are contributed by the communal `dashboardWidgetRegistry` in
-[`@conduction/nextcloud-vue`](https://codeberg.org/Conduction/nextcloud-vue) and
+[`@conduction/nextcloud-vue`](https://github.com/ConductionNL/nextcloud-vue) and
resolve their data from **OpenRegister at render time** — so they need OpenRegister
installed with data. Their renderers and sub-forms live in `@conduction/nextcloud-vue`
(`CnStatWidget`, `CnChartWidget`, …), not in `src/components/Widgets/`. They share a
diff --git a/docs/intro.md b/docs/intro.md
index 795e521e3..41cecf758 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -1,6 +1,6 @@
---
sidebar_position: 1
-description: Get started with LaunchPad, customizable dashboards for Nextcloud. Compose KPI widgets and live charts on top of your OpenRegister data.
+description: Get started with LaunchPad, drag-and-drop dashboards for Nextcloud with templates, widgets, role-based access, and dashboard sharing.
---
# LaunchPad
@@ -9,10 +9,14 @@ LaunchPad provides an enhanced, customizable dashboard experience for Nextcloud.
## Features
-- Configurable dashboard widgets
-- Personal and shared dashboard layouts
-- Integration with Nextcloud apps
-- KPI cards, charts, and activity feeds
+- Drag-and-drop grid dashboards, personal or shared per group
+- A wide widget library (text, image, link, files, people, news, calendar,
+ video, container, native Nextcloud dashboard widgets, and more)
+- Admin templates with permission levels and compulsory widgets
+- Conditional widget visibility (group, time of day, date)
+- Role-based widget access from Nextcloud group membership
+- Dashboard sharing — per user/group, or a public read-only link
+- Activity feed integration and full-text search
## Getting Started
diff --git a/docs/market/market-position-2026-07-23.md b/docs/market/market-position-2026-07-23.md
new file mode 100644
index 000000000..b286961b0
--- /dev/null
+++ b/docs/market/market-position-2026-07-23.md
@@ -0,0 +1,78 @@
+
+
+# LaunchPad — market position & gap analysis (2026-07-23)
+
+Deep-research snapshot backing the `openspec/changes/*` market-gap wave. Full
+evidence (33 competitors, 11 stakeholders, 17 market insights, 23 external
+sources, 13 journeys, 17 gap features, 1 ecosystem gap) is logged in the
+Spectr intelligence register (`spectr` register, `source_ref =
+lp-research-2026-07-23`).
+
+## Positioning in one line
+
+**LaunchPad is the only governed multi-dashboard builder inside the Nextcloud
+ecosystem** — and the only dashboard product anywhere that pairs
+admin-distributed templates + conditional visibility + kiosk/public-share
+with NL Design System theming, EUPL licensing and on-prem hosting. That is an
+ownable, sovereignty-first position for Dutch gemeenten and MKB.
+
+## The competitive field
+
+| Segment | Players | Threat to LaunchPad |
+|---|---|---|
+| Dutch adaptive workspace | **Workspace 365** (£6.80–£10.20/user/mo) | High — same story, same buyers, but M365-tied |
+| Microsoft incumbent | **Viva Connections** (free with M365) | High — free where the buyer is already on M365 |
+| Intranet SaaS | Happeo, LumApps, Staffbase, Simpplr, Unily, Basaas, Omnia, Powell | Medium — upmarket, quote-based, US/DACH data-residency problems |
+| Self-hosted OSS dashboards | Homarr, gethomepage, Glance, Dashy, Heimdall, Organizr, Flame | Sets the UX bar (live tiles, status pings, search) but none target organisations |
+| BI dashboards | Grafana, Metabase, Superset, Redash | UX benchmark for composition/provisioning, not portals |
+| Nextcloud-native | built-in Dashboard, Analytics (Rello), External Sites, AppOrder, Custom Menu, iFrame Widget | The DIY status quo LaunchPad replaces |
+
+Commercial price corridor is **€6–12/user/month**; LaunchPad (EUPL, free)
+undercuts all of it — a per-org support/hosting proposition differentiates
+against every commercial player while OSS rivals ignore organisations
+entirely.
+
+## Why the moat holds
+
+- The Nextcloud app-store Dashboard category is only micro-widgets + LaunchPad; no competing builder exists.
+- Native Dashboard is single-page, fixed-layout, per-user; admin defaults are `occ`-only, instance-wide, and don't touch existing users. The highest-voted dashboard wishes (admin default per group #25553, resizable/pinned widgets #39562, iframe widget, per-group landing) sit **closed-unimplemented**, and Hub 25/26 shipped **no** dashboard investment — low risk of Nextcloud building this natively near-term.
+- ~40 Dutch gemeenten are moving onto a sovereign Nextcloud cloud — direct pull for a gemeente-ready, NLDS-themed, WCAG-AA portal.
+
+## The gaps we are closing (this change wave)
+
+Ranked by researched demand. Each row is an `openspec/changes/*` change on
+`development`.
+
+| Change | Gap | Priority | Route |
+|---|---|---|---|
+| `live-data-tile-widget` | Static tiles → live data tiles (the #1 functional gap; 12/12 competitors) | must | LaunchPad widget **+ OpenConnector `dashboard-http-datasource` leaf** |
+| `conditional-visibility-editor` | Rules engine has no UI; add editor + preview-as-audience/date | must | LaunchPad (UI over existing engine) |
+| `admin-template-resync` | Template edits never reach already-provisioned copies | must | LaunchPad (extends admin-templates) |
+| `tile-quick-search` | No on-dashboard launcher/search bar (9/9 competitors) | should | LaunchPad (runtime-shell) |
+| `service-health-ping` | No tile up/down status ("is de zaakapplicatie bereikbaar?") | should | LaunchPad widget |
+| `iframe-embed-widget` | CSP-aware external-URL embed (a whole micro-app niche) | should | LaunchPad widget |
+| `tile-usage-analytics` | Per-tile click analytics for the KPI-review flow | should | LaunchPad (extends dashboard-view-analytics) |
+| `clock-weather-widgets` | Ambient clock/weather widgets (startpage staples) | could | LaunchPad widgets |
+
+### Leaf reintegration (cross-app boundary)
+
+`live-data-tile-widget` deliberately does **not** put third-party HTTP,
+credentials or egress control in LaunchPad. That capability lives in
+**OpenConnector** as `dashboard-http-datasource` (a governed, read-only
+"resolve one value from a configured source" façade over the existing
+source/HTTP/auth engines). LaunchPad consumes it as a **leaf** through a
+runtime capability probe — no static OpenConnector imports — and degrades to
+a minimal allow-listed direct GET when OpenConnector is absent, per the
+`runtime-or-consumption` policy.
+
+## Already-specced-but-unbuilt (verify before duplicating)
+
+Several proposed changes already cover adjacent gaps: `public-dashboard-publication`
+and the public-share API (public-share UI), `scheduled-exports`,
+`drill-down-cross-widget-filter` (dashboard variables), `embedded-analytics`
+(iframe/JS-SDK embed + tokens), `keyboard-accessible-widget-repositioning`,
+`map-support`, `launchpad-ai-dashboard-assistant`. The gap wave above is the
+set with **no** existing change.
diff --git a/docs/migration/widget-library-to-ncvue.md b/docs/migration/widget-library-to-ncvue.md
index 85879b05e..04758f62b 100644
--- a/docs/migration/widget-library-to-ncvue.md
+++ b/docs/migration/widget-library-to-ncvue.md
@@ -31,8 +31,8 @@ The nc-vue widget library is **not finished as a public API**, and **not publish
So "port to nc-vue" means: finish, export, document, test, parity-audit, and
**publish** a ~36-component library in the shared fleet lib (consumed by
-OpenRegister / OpenCatalogi / Procest / Pipelinq / LaunchPad), then migrate launchpad
-onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in
+OpenRegister / OpenCatalogi / Procest / Pipelinq / LaunchPad), then migrate
+LaunchPad onto it. That is multi-day and has fleet-wide blast radius — it cannot be done in
one pass, and a half-done state breaks both repos.
## Parity audit — current nc-vue readiness
diff --git a/docs/package-lock.json b/docs/package-lock.json
index a9dccbd25..52539f92b 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -8,7 +8,7 @@
"name": "launchpad-docs",
"version": "0.0.0",
"dependencies": {
- "@conduction/docusaurus-preset": "^3.24.0",
+ "@conduction/docusaurus-preset": "^3.26.0",
"@docusaurus/core": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
@@ -2043,9 +2043,9 @@
}
},
"node_modules/@conduction/docusaurus-preset": {
- "version": "3.24.0",
- "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.24.0.tgz",
- "integrity": "sha512-T6LwvArwaF6QZfnc36zSTiOGEKmCIXaIBjiLAEFR7mcC1EfCUrZI6Dsf1z9BtC971jruTX3cE3+l4NxMo3LgzQ==",
+ "version": "3.26.0",
+ "resolved": "https://registry.npmjs.org/@conduction/docusaurus-preset/-/docusaurus-preset-3.26.0.tgz",
+ "integrity": "sha512-Nh7Ekl0dwKWxrb4y3aRtEl98blkNsr8LOa/ixrrVUGrvL/l7YOaGnlfNWt2pQZvBd7u4jt4E4qHfu4DJDrnUJA==",
"license": "EUPL-1.2",
"bin": {
"validate-ai-baseline": "bin/validate-ai-baseline.mjs"
diff --git a/docs/package.json b/docs/package.json
index dd4719775..740cc640a 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -17,7 +17,7 @@
"ci": "npm ci --legacy-peer-deps && npm run build"
},
"dependencies": {
- "@conduction/docusaurus-preset": "^3.24.0",
+ "@conduction/docusaurus-preset": "^3.26.0",
"@docusaurus/core": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0",
diff --git a/docs/tutorials/user/02-create-dashboard.md b/docs/tutorials/user/02-create-dashboard.md
index 6c55e63ff..60d8dc87e 100644
--- a/docs/tutorials/user/02-create-dashboard.md
+++ b/docs/tutorials/user/02-create-dashboard.md
@@ -10,7 +10,11 @@ Each personal dashboard is an independent canvas — its own layout, its own wid
## Goal
-Create a new personal dashboard, give it a name, optionally add a description and an icon, and land on it ready to add widgets.
+Create a new personal dashboard by forking the one you're on, then rename it and land on it ready to customise.
+
+:::info How "Add dashboard" works
+The **+ Add dashboard** button **forks the dashboard you're currently viewing** into a fresh personal copy — it does *not* open a blank-name modal. The new dashboard is created immediately, named **"My copy of <current name>"**, seeded with a copy of the current dashboard's widgets, and activated. Rename it afterwards via [Dashboard configuration…](10-rename-or-delete.md). This means a new dashboard always starts from a working layout rather than an empty grid.
+:::
## Prerequisites
@@ -18,39 +22,37 @@ Create a new personal dashboard, give it a name, optionally add a description an
## Steps
-### 1. Open the sidebar and click **+ Add dashboard**
+### 1. Open the dashboard you want to base the new one on
-
+The fork copies *this* dashboard's widgets, so start from whichever layout is the best starting point.
-### 2. Fill in the create modal
+### 2. Open the sidebar and click **+ Add dashboard**
-A configuration modal opens with these fields:
+
-- **Name** — required. Used for the sidebar label and the URL slug.
-- **Description** — optional. Shown in admin tooling and inside the configuration modal.
-- **Icon** — optional. Pick from the registered icon set, or paste a URL for a custom icon (see [Dashboard icons capability](../../features/dashboards.md)).
+A new dashboard named **"My copy of <current name>"** is created and activated immediately — no modal, no Save step. You land on it at its own URL.
-
+### 3. Rename it (and set an icon)
-### 3. Click **Save**
+Open the active dashboard's cog menu → **Dashboard configuration…** and edit the **Name**, optional **Description**, and **Icon** (a searchable Material Design Icons picker plus a Custom tab for a URL/upload). See [Rename or delete a dashboard](10-rename-or-delete.md).
-The new dashboard is auto-activated, appears at the top of **MY DASHBOARDS** in the sidebar, and is bootstrapped with the default widget bundle (three tiles + a Files widget). You can now [add more widgets](03-add-widget.md), [reposition them](04-reposition-resize.md), or [pin this as your default](07-set-default.md).
+
-
+You can now [add more widgets](03-add-widget.md), [reposition them](04-reposition-resize.md), or [pin this as your default](07-set-default.md).
## Verification
-- The sidebar shows your new dashboard's name, highlighted as active.
-- The URL bar reads `/apps/launchpad/` — the slug is auto-derived from the name.
-- The grid contains the four default placements (Conduction tile, Sendent tile, Nextcloud tile, Files widget).
+- The sidebar shows the new **"My copy of …"** dashboard, highlighted as active.
+- The URL bar reads `/apps/launchpad/` — the slug is auto-derived from the name.
+- The grid contains a copy of the widgets from the dashboard you forked.
## Common issues
| Symptom | Fix |
|---|---|
| **+ Add dashboard** button is missing | Personal dashboards are disabled by your admin. |
-| Save button is disabled | The Name field is empty — required. |
-| "Slug must be unique among siblings" error | A dashboard with the same auto-derived slug already exists. Pick a different name or set an explicit slug via [Dashboard configuration](10-rename-or-delete.md). |
+| The new dashboard has the wrong widgets | It copied the dashboard you were viewing — fork from a different one, or remove the unwanted widgets. |
+| Two dashboards share a slug | Rename via [Dashboard configuration](10-rename-or-delete.md); the slug re-derives from the new name. |
## Reference
diff --git a/docs/tutorials/user/05-edit-content.md b/docs/tutorials/user/05-edit-content.md
index 04fe81703..4e09f78df 100644
--- a/docs/tutorials/user/05-edit-content.md
+++ b/docs/tutorials/user/05-edit-content.md
@@ -6,16 +6,16 @@ description: Change a widget's content, colours, custom title, or border without
# Edit widget content & style
-Each placement carries two layers of configuration:
+Each placement carries two layers of configuration, both edited from the **same** modal:
- **Content** — type-specific fields (text body, link URL, folder path, …). Changes the widget's payload.
-- **Style** — borders, background colour, custom title override, custom icon override. Cosmetic.
+- **Style / appearance** — show-title toggle, custom title override, background, and custom icon. Cosmetic.
-Both are editable post-add without removing the widget.
+Both are editable post-add without removing the widget. Content and style used to be separate menu entries; they are now one unified **Edit widget** modal with a **Content** area and an **Appearance** section.
## Goal
-Edit a widget you already added — both its content and its visual style.
+Edit a widget you already added — both its content and its appearance.
## Prerequisites
@@ -23,54 +23,51 @@ Edit a widget you already added — both its content and its visual style.
## Steps
-### 1. Right-click the widget
+### 1. Enter edit mode and open the widget's menu
-In edit mode, right-clicking a widget opens a context menu anchored at the cursor:
+Cog menu → **Edit dashboard**. Each placement then shows a **Widget menu** (⋯/cog) button in its top-right corner. Click it:
-
+
Options:
-- **Edit** — opens the per-type configuration form (same as during add).
-- **Style** — opens the cosmetic style editor.
-- **Remove** — see [Remove a widget](06-remove-widget.md).
-- **Cancel** — close the menu.
+- **Edit widget** — opens the unified configuration + appearance form (same modal as during add).
+- **Delete widget** — see [Remove a widget](06-remove-widget.md).
-### 2. Edit content
+### 2. Edit the content
-Pick **Edit**. The same `AddWidgetModal` you used to add the widget reopens, this time pre-filled with the current placement's content. Change fields and **Save**.
+Pick **Edit widget**. The same **Add Widget** modal you used to add it reopens, pre-filled with the current placement's content. Change the type-specific fields at the top (label, URL, folder, colours, …).

-### 3. Edit style
+### 3. Edit the appearance
-Pick **Style** instead. The dedicated `WidgetStyleEditor` opens with these controls:
+Scroll to the **Appearance** section of the same modal:
-- **Custom title** — overrides the widget's default title (leave blank for default).
-- **Custom icon** — registry key, URL, or empty for default.
- **Show title** — toggle the title bar on/off.
-- **Border** — colour and thickness.
-- **Background colour** — solid, transparent, or theme-bound.
+- **Custom title** — overrides the widget's default title (leave blank for default).
+- **Background** — Default, or a custom colour.
+- **Icon** — pick from the Material Design Icons catalogue, the NL Design set (when the `nldesign` app is enabled), or **Upload** your own; leave empty for the default.
-
+
-The style is persisted as a JSON blob in `placement.styleConfig`; it doesn't touch the widget's content.
+The appearance settings persist as a JSON blob in `placement.styleConfig`; they don't touch the widget's content.
### 4. Save
-Both modals close on **Save** and the change is reflected immediately.
+The modal closes on **Save** and the change is reflected immediately.
## Verification
-- Reload the page. Content / style changes are still applied.
-- The widget header reflects any custom title; widget background reflects any custom colour.
+- Reload the page. Content and appearance changes are still applied.
+- The widget header reflects any custom title; the widget background reflects any custom colour.
## Common issues
| Symptom | Fix |
|---|---|
-| **Edit** is disabled | The widget type has no configuration form (renderer-only widgets). Use **Style** for cosmetics, or remove and re-add. |
-| Custom icon doesn't render | The icon string isn't a registered registry key and not a valid URL. See [Dashboard icons capability](../../features/dashboards.md). |
-| Title row gone after toggling **Show title** | Re-open the style editor and toggle it back on, OR set a custom title. |
+| The type-specific fields are absent | The widget type is renderer-only (no configuration form). You can still change the Appearance section, or remove and re-add. |
+| The NL Design icon set is missing from the Icon picker | The `nldesign` app is not enabled on this instance — the pack is hidden by design (MDI + Upload still work). |
+| Title row gone after toggling **Show title** | Re-open Edit and toggle it back on, OR set a custom title. |
## Reference
diff --git a/docs/tutorials/user/06-remove-widget.md b/docs/tutorials/user/06-remove-widget.md
index 1a0fa10d6..987a39089 100644
--- a/docs/tutorials/user/06-remove-widget.md
+++ b/docs/tutorials/user/06-remove-widget.md
@@ -19,20 +19,20 @@ Remove one widget from a dashboard.
## Steps
-### 1. Right-click the widget
+### 1. Open the widget's menu
-In edit mode, right-click anywhere on the widget. The context menu opens at the cursor.
+In edit mode, click the placement's **Widget menu** (⋯/cog) button in its top-right corner.
-
+
-### 2. Click **Remove**
+### 2. Click **Delete widget**
The menu auto-closes and the placement disappears from the grid. The DELETE call fires immediately; there is no undo.

:::caution
-The remove is destructive. If you're unsure, [edit the style](05-edit-content.md) and toggle **Show title** off — it hides the placement without deleting it.
+The delete is destructive. If you're unsure, [edit the appearance](05-edit-content.md) and toggle **Show title** off — it de-emphasises the placement without deleting it.
:::
## Verification
@@ -44,8 +44,8 @@ The remove is destructive. If you're unsure, [edit the style](05-edit-content.md
| Symptom | Fix |
|---|---|
-| **Remove** is greyed out | The widget is `isCompulsory=1` on this dashboard (admin-pinned). Ask your admin to lift it. |
-| Removing throws "permission denied" | Your permission level on the dashboard is `view_only`. |
+| **Delete widget** is greyed out | The widget is `isCompulsory=1` on this dashboard (admin-pinned). Ask your admin to lift it. |
+| Deleting throws "permission denied" | Your permission level on the dashboard is `view_only`. |
## Reference
diff --git a/docs/tutorials/user/11-sharing-dashboards-publicly.md b/docs/tutorials/user/11-sharing-dashboards-publicly.md
index 89ea59711..a635f1af3 100644
--- a/docs/tutorials/user/11-sharing-dashboards-publicly.md
+++ b/docs/tutorials/user/11-sharing-dashboards-publicly.md
@@ -5,15 +5,23 @@ title: Sharing dashboards publicly
# Sharing dashboards publicly
-LaunchPad lets you share a read-only view of any dashboard you own via a
-URL-safe token — no Nextcloud login required.
-
-## Creating a public share
-
-1. Open the dashboard you want to share.
-2. Click **Share** → **Public share** in the dashboard menu.
-3. (Optional) Enter a password and/or an expiry date.
-4. Click **Create share**. A shareable URL is displayed.
+LaunchPad can mint a read-only, URL-safe token for any dashboard you own —
+no Nextcloud login required to view it.
+
+:::warning Feature status — API only for now
+The public-share **HTTP API described below is live and stable**, but the
+in-app UI for creating and managing public links is **not yet shipped**. The
+dashboard **Share** button currently opens the *user & group* sharing tab
+only (see [Bookmark or share a dashboard URL](08-deep-link.md) for logged-in
+sharing). Public links are therefore created via the API (or automation)
+today; the point-and-click **Create public link** control, and the anonymous
+rendered view at `/s/{token}`, are on the roadmap. Until then, treat this page
+as the integrator's reference for the endpoints.
+:::
+
+## Creating a public share (API)
+
+Call the create endpoint on the dashboard's UUID:
### API
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
new file mode 100644
index 000000000..7a73a41bf
--- /dev/null
+++ b/eslint-suppressions.json
@@ -0,0 +1,2 @@
+{
+}
\ No newline at end of file
diff --git a/eslint.config.js b/eslint.config.js
deleted file mode 100644
index b3480510f..000000000
--- a/eslint.config.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const {
- defineConfig,
-} = require('@eslint/config-helpers')
-
-const js = require('@eslint/js')
-
-const {
- FlatCompat,
-} = require('@eslint/eslintrc')
-
-const compat = new FlatCompat({
- baseDirectory: __dirname,
- recommendedConfig: js.configs.recommended,
- allConfig: js.configs.all,
-})
-
-module.exports = defineConfig([{
- extends: compat.extends('@nextcloud'),
-
- settings: {
- 'import/resolver': {
- alias: {
- map: [['@', './src']],
- extensions: ['.js', '.ts', '.vue', '.json'],
- },
- },
- },
-
- rules: {
- 'jsdoc/require-jsdoc': 'off',
- 'vue/first-attribute-linebreak': 'off',
- 'vue/no-unused-components': 'warn',
- '@typescript-eslint/no-explicit-any': 'off',
- 'n/no-missing-import': 'off',
- 'import/namespace': 'off',
- 'import/default': 'off',
- 'import/no-named-as-default': 'off',
- 'import/no-named-as-default-member': 'off',
- 'import/no-unresolved': ['error', { ignore: ['^@conduction/nextcloud-vue'] }],
- 'no-console': 'off',
- 'no-debugger': 'off',
- },
-}, {
- // Test files may import devDependencies (vitest, @vue/test-utils, etc.)
- // without violating `n/no-unpublished-import`.
- files: [
- 'src/**/__tests__/**/*.{js,ts}',
- 'src/**/*.test.js',
- 'src/**/*.spec.js',
- 'src/__tests__/**/*.js',
- 'tests/**/*.{js,ts}',
- ],
- rules: {
- 'n/no-unpublished-import': 'off',
- },
-}])
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 000000000..9bad6e2d4
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,236 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2026 Conduction B.V.
+//
+// eslint 10 + @nextcloud/eslint-config 9 — the same stack Nextcloud's own apps
+// run (nextcloud/forms is the reference). Flat config, ESM.
+//
+// This file is the fleet's canonical shape. Copy it verbatim into an app; the
+// only parts that should ever differ are the last two blocks (app-specific
+// globals and file-scoped exemptions).
+//
+// WHY `.mjs` AND NOT `"type": "module"` IN package.json
+// -----------------------------------------------------
+// `@nextcloud/eslint-config@9` is `"type": "module"`, so the config importing it
+// must be ESM. forms achieves that by making the whole package ESM; these apps
+// cannot — `webpack.config.js`, `vitest.config.js` and the `tests/**` CLI
+// checkers are CommonJS and would stop parsing. Naming the config `.mjs` scopes
+// the module system to the one file that needs it.
+//
+// 🔴 NODE 22 IS REQUIRED, NOT PREFERRED
+// -------------------------------------
+// `@nextcloud/eslint-config@9` declares `engines.node: ^22.14 || ^24 || >=26`
+// and imports `findPackageJSON` from `node:module`, an API that first exists in
+// 22.14. On Node 20 eslint dies before linting a single file with
+// `SyntaxError: … does not provide an export named 'findPackageJSON'`, and npm
+// reports the mismatch only as an EBADENGINE warning it continues past.
+//
+// 🔴 THE PEER DEPENDENCIES ARE LOAD-BEARING
+// -----------------------------------------
+// `vue-eslint-parser` is NOT a dependency of `@nextcloud/eslint-config` — it is
+// a peer of the `eslint-plugin-vue@10` it bundles, so the APP must supply it,
+// at `^10`. If a stale `eslint-plugin-vue@^9` / `vue-eslint-parser@^9` is left
+// in devDependencies it hoists over the bundled copy, `vue/base/setup-for-vue`
+// then supplies NO parser, and `typescript-eslint/base` — which also claims
+// `**/*.vue` — parses every SFC as TypeScript. Every `.vue` file fails with
+// `Parsing error: Expression expected`, and because eslint reports a parse
+// failure as ONE finding and lints nothing else in that file, the whole Vue
+// layer goes unchecked while the problem count looks small.
+// `@typescript-eslint/parser` must likewise be resolvable from the top level:
+// `vue-eslint-parser` requires it by name for ` bodies entirely (content + tags).
- $stripped = preg_replace(
- pattern: '##is',
- replacement: '',
- subject: $html
- );
-
- if ($stripped === null) {
- return '';
- }
-
- // Replace any tag with a sanitised version or empty string. The
- // callback inspects each match, validates the tag name, drops
- // disallowed attributes, and force-tags external elements
- // with rel/target.
- $result = preg_replace_callback(
- pattern: '#<(/?)\s*([a-zA-Z0-9]+)\b([^>]*)>#s',
- callback: function (array $matches): string {
- $closing = ($matches[1] === '/');
- $tag = strtolower(string: $matches[2]);
- $attrs = $matches[3];
-
- if (in_array(needle: $tag, haystack: self::ALLOWED_TAGS, strict: true) === false) {
- return '';
- }
-
- if ($closing === true) {
- return ''.$tag.'>';
- }
-
- $allowedAttrs = (self::ALLOWED_ATTRIBUTES[$tag] ?? []);
- $kept = [];
- if ($allowedAttrs !== [] && trim(string: $attrs) !== '') {
- if (preg_match_all(
- pattern: '#([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#',
- subject: $attrs,
- matches: $found,
- flags: PREG_SET_ORDER
- ) > 0
- ) {
- foreach ($found as $attr) {
- $name = strtolower(string: $attr[1]);
- if (in_array(needle: $name, haystack: $allowedAttrs, strict: true) === false) {
- continue;
- }
-
- $value = ($attr[3] ?? ($attr[4] ?? ($attr[5] ?? '')));
- // Reject data: / javascript: schemes on URL
- // attributes (href / src). The allow-list
- // already restricted us to those names so a
- // scheme check is enough.
- if (preg_match(
- pattern: '#^\s*(javascript|data|vbscript):#i',
- subject: $value
- ) === 1
- ) {
- continue;
- }
-
- // Strip any control char or newline; keep
- // simple value escaping for HTML context.
- $cleanValue = htmlspecialchars(
- string: $value,
- flags: (ENT_QUOTES | ENT_HTML5),
- encoding: 'UTF-8'
- );
- $kept[$name] = $cleanValue;
- }//end foreach
- }//end if
- }//end if
-
- $rendered = '<'.$tag;
- foreach ($kept as $name => $value) {
- $rendered .= ' '.$name.'="'.$value.'"';
- }
-
- // External elements get rel + target automatically
- // (REQ-FTR-002 external-link scenario).
- if ($tag === 'a'
- && isset($kept['href']) === true
- && preg_match(
- pattern: '#^https?://#i',
- subject: $kept['href']
- ) === 1
- ) {
- $rendered .= ' rel="noopener noreferrer" target="_blank"';
- }
-
- $closingTag = '>';
- if (in_array(needle: $tag, haystack: ['br', 'img'], strict: true) === true) {
- $closingTag = ' />';
- }
-
- $rendered .= $closingTag;
-
- return $rendered;
- },
- subject: $stripped
- );
-
- if ($result === null) {
- return '';
- }
-
- return $result;
- }//end sanitiseHtml()
-
- /**
- * Validate a structured-mode config payload against the documented
- * schema (REQ-FTR-003). Throws on extra keys, malformed `links`,
- * or an unknown `layoutMode`.
- *
- * @param array $config The structured config.
- *
- * @return void
- *
- * @throws InvalidArgumentException On schema mismatch.
- *
- * @spec openspec/specs/footer-customization/spec.md
- */
- public function validateStructuredConfig(array $config): void
- {
- foreach (array_keys(array: $config) as $key) {
- if (in_array(needle: $key, haystack: self::STRUCTURED_CONFIG_KEYS, strict: true) === false) {
- throw new InvalidArgumentException(
- message: 'footerConfig contains unknown key: '.(string) $key
- );
- }
- }
-
- if (array_key_exists(key: 'layoutMode', array: $config) === true) {
- $mode = $config['layoutMode'];
- if (is_string($mode) === false
- || in_array(needle: $mode, haystack: self::LAYOUT_MODES, strict: true) === false
- ) {
- throw new InvalidArgumentException(
- message: 'footerConfig.layoutMode must be one of: '.implode(separator: ', ', array: self::LAYOUT_MODES)
- );
- }
- }
-
- if (array_key_exists(key: 'links', array: $config) === true) {
- $links = $config['links'];
- if (is_array($links) === false) {
- throw new InvalidArgumentException(
- message: 'footerConfig.links must be an array'
- );
- }
-
- foreach ($links as $entry) {
- if (is_array($entry) === false
- || isset($entry['label']) === false
- || isset($entry['url']) === false
- || is_string($entry['label']) === false
- || is_string($entry['url']) === false
- ) {
- throw new InvalidArgumentException(
- message: 'footerConfig.links entries must be {label, url} string pairs'
- );
- }
- }
- }//end if
- }//end validateStructuredConfig()
-
- /**
- * Resolve the effective footer payload for a single dashboard
- * (REQ-FTR-004, REQ-FTR-006). Returns NULL when no footer should
- * render, or an associative array suitable for serialising as
- * the dashboard API response's `effectiveFooter` field.
- *
- * Resolution order (matches DashboardService.resolveFooterForDashboard
- * tasks 6.5):
- * - dashboard mode = `hidden` → NULL.
- * - dashboard mode = `custom` → render the dashboard HTML.
- * - dashboard mode = `inherit` → check global `footerEnabled`;
- * if false → NULL; otherwise render the global footer HTML or
- * structured config.
- *
- * @param Dashboard $dashboard The dashboard whose footer to resolve.
- *
- * @return array|null Effective footer payload keyed
- * by `mode`, `html`, `config`,
- * `backgroundColor`, `textColor`.
- *
- * @spec openspec/specs/footer-customization/spec.md
- */
- public function resolveFooterForDashboard(Dashboard $dashboard): ?array
- {
- $rawMode = $dashboard->getDashboardFooterMode();
- $mode = $rawMode;
- if ($rawMode === '') {
- $mode = Dashboard::FOOTER_MODE_INHERIT;
- }
-
- if ($mode === Dashboard::FOOTER_MODE_HIDDEN) {
- return null;
- }
-
- $globals = $this->getGlobalSettings();
-
- if ($mode === Dashboard::FOOTER_MODE_CUSTOM) {
- $html = $dashboard->getDashboardFooterHtml();
- if ($html === null || trim(string: $html) === '') {
- return null;
- }
-
- return [
- 'mode' => Dashboard::FOOTER_MODE_CUSTOM,
- 'html' => $html,
- 'config' => null,
- 'backgroundColor' => $globals['footerBackgroundColor'],
- 'textColor' => $globals['footerTextColor'],
- ];
- }
-
- // Inherit branch — must consult the global toggle.
- if ($globals['footerEnabled'] !== true) {
- return null;
- }
-
- $html = $globals['footerHtml'];
- $htmlValue = null;
- if (is_string($html) === true && $html !== '') {
- $htmlValue = $html;
- } else if (is_array($html) === true && $html !== []) {
- // Language-variant map — the frontend selects the locale.
- $htmlValue = $html;
- }
-
- $config = $globals['footerConfig'];
- $configValue = null;
- if (is_array($config) === true && $config !== []) {
- $configValue = $config;
- }
-
- if ($htmlValue === null && $configValue === null) {
- // Footer enabled but nothing configured — skip rendering.
- return null;
- }
-
- return [
- 'mode' => 'global',
- 'html' => $htmlValue,
- 'config' => $configValue,
- 'backgroundColor' => $globals['footerBackgroundColor'],
- 'textColor' => $globals['footerTextColor'],
- ];
- }//end resolveFooterForDashboard()
-
- /**
- * Validate that a value is a `#rgb` / `#rrggbb` hex colour string
- * (REQ-FTR-009). Throws on anything else.
- *
- * @param mixed $value The value to validate.
- * @param string $fieldName The field name for the error message.
- *
- * @return void
- *
- * @throws InvalidArgumentException When the value is not a hex string.
- */
- private function assertHexColour(mixed $value, string $fieldName): void
- {
- if (is_string($value) === false
- || preg_match(pattern: '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', subject: $value) !== 1
- ) {
- throw new InvalidArgumentException(
- message: $fieldName.' must be a hex colour string (e.g. #1a1a1a)'
- );
- }
- }//end assertHexColour()
+class FooterService {
+ /**
+ * Maximum allowed footer HTML length (REQ-FTR-002 — 8 KB cap).
+ * Inputs that exceed this MUST be rejected with HTTP 413 by the
+ * controller — `sanitiseHtml()` throws
+ * {@see \InvalidArgumentException} so the caller can map it.
+ *
+ * @var integer
+ */
+ public const MAX_HTML_BYTES = 8192;
+
+ /**
+ * Tag allow-list for the footer HTML (REQ-FTR-005, design D4).
+ * Mirrors the text-display widget's allow-list — a single
+ * canonical definition that both surfaces SHOULD reference.
+ *
+ * @var array
+ */
+ public const ALLOWED_TAGS = [
+ 'a',
+ 'p',
+ 'strong',
+ 'em',
+ 'br',
+ 'ul',
+ 'ol',
+ 'li',
+ 'img',
+ ];
+
+ /**
+ * Per-tag attribute allow-list (REQ-FTR-005). Tags not listed in
+ * {@see FooterService::ALLOWED_TAGS} are stripped wholesale; tags
+ * listed but missing from this map keep no attributes.
+ *
+ * @var array>
+ */
+ public const ALLOWED_ATTRIBUTES = [
+ 'a' => ['href'],
+ 'img' => ['src'],
+ ];
+
+ /**
+ * Allowed structured-config top-level keys (REQ-FTR-003). Schema
+ * payloads with extra keys MUST be rejected with HTTP 400 — the
+ * service throws {@see \InvalidArgumentException}.
+ *
+ * @var array
+ */
+ public const STRUCTURED_CONFIG_KEYS = [
+ 'logoUrl',
+ 'organisation',
+ 'address',
+ 'links',
+ 'legal',
+ 'copyrightYear',
+ 'layoutMode',
+ ];
+
+ /**
+ * Allowed structured layout modes (REQ-FTR-003).
+ *
+ * @var array
+ */
+ public const LAYOUT_MODES = ['columns', 'inline'];
+
+ /**
+ * Constructor.
+ *
+ * @param AdminSettingMapper $settingMapper Persisted-settings mapper.
+ */
+ public function __construct(
+ private readonly AdminSettingMapper $settingMapper,
+ ) {
+ }//end __construct()
+
+ /**
+ * Read the five footer settings into a camelCase response payload.
+ *
+ * Defaults:
+ * - `footerEnabled` — false (REQ-FTR-001 default-off scenario).
+ * - `footerHtml` — empty string.
+ * - `footerConfig` — empty stdClass-like array (`[]`).
+ * - `footerBackgroundColor` / `footerTextColor` — null (theme
+ * fallback).
+ *
+ * @return array Settings keyed by camelCase
+ * (`footerEnabled`, `footerHtml`,
+ * `footerConfig`,
+ * `footerBackgroundColor`,
+ * `footerTextColor`).
+ *
+ * @spec openspec/specs/footer-customization/spec.md
+ */
+ public function getGlobalSettings(): array {
+ $enabled = (bool)$this->settingMapper->getValue(
+ key: AdminSetting::KEY_FOOTER_ENABLED,
+ default: false
+ );
+
+ $html = $this->settingMapper->getValue(
+ key: AdminSetting::KEY_FOOTER_HTML,
+ default: ''
+ );
+
+ if (is_string($html) === false && is_array($html) === false) {
+ $html = '';
+ }
+
+ $config = $this->settingMapper->getValue(
+ key: AdminSetting::KEY_FOOTER_CONFIG,
+ default: []
+ );
+
+ if (is_array($config) === false) {
+ $config = [];
+ }
+
+ $backgroundColor = $this->settingMapper->getValue(
+ key: AdminSetting::KEY_FOOTER_BACKGROUND_COLOR,
+ default: null
+ );
+
+ if ($backgroundColor !== null && is_string($backgroundColor) === false) {
+ $backgroundColor = null;
+ }
+
+ $textColor = $this->settingMapper->getValue(
+ key: AdminSetting::KEY_FOOTER_TEXT_COLOR,
+ default: null
+ );
+
+ if ($textColor !== null && is_string($textColor) === false) {
+ $textColor = null;
+ }
+
+ return [
+ 'footerEnabled' => $enabled,
+ 'footerHtml' => $html,
+ 'footerConfig' => $config,
+ 'footerBackgroundColor' => $backgroundColor,
+ 'footerTextColor' => $textColor,
+ ];
+ }//end getGlobalSettings()
+
+ /**
+ * Patch one or more global footer settings (REQ-FTR-001..003,
+ * REQ-FTR-009, REQ-FTR-010).
+ *
+ * Only keys present in `$patch` are updated; untouched keys
+ * retain their previous values. Each value is validated before
+ * persistence:
+ * - `footerEnabled` — coerced to bool.
+ * - `footerHtml` — sanitised via {@see FooterService::sanitiseHtml()}
+ * (throws on > 8 KB input).
+ * - `footerConfig` — validated via {@see FooterService::validateStructuredConfig()}.
+ * - `footerBackgroundColor` / `footerTextColor` — validated via
+ * {@see FooterService::assertHexColour()} (or NULL to clear).
+ *
+ * @param array $patch Partial payload from the
+ * HTTP body.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When validation fails. The
+ * controller maps the message to
+ * HTTP 400 / 413 as appropriate.
+ *
+ * @spec openspec/specs/footer-customization/spec.md
+ */
+ public function updateGlobalSettings(array $patch): void {
+ if (array_key_exists(key: 'footerEnabled', array: $patch) === true) {
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_FOOTER_ENABLED,
+ value: (bool)$patch['footerEnabled']
+ );
+ }
+
+ if (array_key_exists(key: 'footerHtml', array: $patch) === true) {
+ $raw = $patch['footerHtml'];
+ $sanitised = '';
+ if ($raw !== null && is_array($raw) === false && is_string($raw) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerHtml must be a string, NULL, or a variant map'
+ );
+ }
+
+ if ($raw === null) {
+ $sanitised = '';
+ }
+
+ if (is_array($raw) === true) {
+ // Language-tagged variant map (REQ-FTR-007). Sanitise
+ // each variant independently.
+ $sanitised = [];
+ foreach ($raw as $locale => $variant) {
+ if (is_string($locale) === false || is_string($variant) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerHtml language variants must be string→string'
+ );
+ }
+
+ $sanitised[$locale] = $this->sanitiseHtml(html: $variant);
+ }
+ }
+
+ if (is_string($raw) === true) {
+ $sanitised = $this->sanitiseHtml(html: $raw);
+ }//end if
+
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_FOOTER_HTML,
+ value: $sanitised
+ );
+ }//end if
+
+ if (array_key_exists(key: 'footerConfig', array: $patch) === true) {
+ $rawConfig = $patch['footerConfig'];
+ if ($rawConfig === null) {
+ $rawConfig = [];
+ }
+
+ if (is_array($rawConfig) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig must be a JSON object'
+ );
+ }
+
+ $this->validateStructuredConfig(config: $rawConfig);
+
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_FOOTER_CONFIG,
+ value: $rawConfig
+ );
+ }
+
+ if (array_key_exists(key: 'footerBackgroundColor', array: $patch) === true) {
+ $colour = $patch['footerBackgroundColor'];
+ if ($colour !== null) {
+ $this->assertHexColour(value: $colour, fieldName: 'footerBackgroundColor');
+ }
+
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_FOOTER_BACKGROUND_COLOR,
+ value: $colour
+ );
+ }
+
+ if (array_key_exists(key: 'footerTextColor', array: $patch) === true) {
+ $colour = $patch['footerTextColor'];
+ if ($colour !== null) {
+ $this->assertHexColour(value: $colour, fieldName: 'footerTextColor');
+ }
+
+ $this->settingMapper->setSetting(
+ key: AdminSetting::KEY_FOOTER_TEXT_COLOR,
+ value: $colour
+ );
+ }
+ }//end updateGlobalSettings()
+
+ /**
+ * Sanitise raw footer HTML against the allow-list (REQ-FTR-002,
+ * REQ-FTR-005). Strips disallowed tags + attributes, normalises
+ * external links with `rel="noopener noreferrer"` and
+ * `target="_blank"`, and rejects oversized payloads.
+ *
+ * Implementation note: a tiny regex-based sanitiser is sufficient
+ * here because the allow-list is closed and small. The DOM-based
+ * fallback used by the text-display widget is overkill for the
+ * footer's narrow surface — any payload > 8 KB is rejected before
+ * processing so worst-case complexity is bounded.
+ *
+ * @param string $html The raw HTML input.
+ *
+ * @return string The sanitised HTML (always safe to render).
+ *
+ * @throws InvalidArgumentException When the input exceeds 8 KB.
+ *
+ * @spec openspec/specs/footer-customization/spec.md
+ */
+ public function sanitiseHtml(string $html): string {
+ if (strlen(string: $html) > self::MAX_HTML_BYTES) {
+ throw new InvalidArgumentException(
+ message: 'footerHtml exceeds 8 KB limit'
+ );
+ }
+
+ if ($html === '') {
+ return '';
+ }
+
+ // Strip bodies entirely (content + tags).
+ $stripped = preg_replace(
+ pattern: '##is',
+ replacement: '',
+ subject: $html
+ );
+
+ if ($stripped === null) {
+ return '';
+ }
+
+ // Replace any tag with a sanitised version or empty string. The
+ // callback inspects each match, validates the tag name, drops
+ // disallowed attributes, and force-tags external elements
+ // with rel/target.
+ $result = preg_replace_callback(
+ pattern: '#<(/?)\s*([a-zA-Z0-9]+)\b([^>]*)>#s',
+ callback: fn (array $matches): string => $this->sanitiseTag(matches: $matches),
+ subject: $stripped
+ );
+
+ if ($result === null) {
+ return '';
+ }
+
+ return $result;
+ }//end sanitiseHtml()
+
+ /**
+ * Rebuild one matched tag in its sanitised form (REQ-FTR-002).
+ *
+ * A tag outside {@see self::ALLOWED_TAGS} collapses to the empty
+ * string. A closing tag is re-emitted bare — it carries no attributes
+ * to sanitise. An opening tag is rebuilt from scratch out of the
+ * surviving attributes, so nothing from the raw source can leak
+ * through.
+ *
+ * @param array $matches The regex match: [full, slash, tag, attrs].
+ *
+ * @return string The sanitised tag, or '' when the tag is disallowed.
+ */
+ private function sanitiseTag(array $matches): string {
+ $closing = ($matches[1] === '/');
+ $tag = strtolower(string: $matches[2]);
+ $attrs = $matches[3];
+
+ if (in_array(needle: $tag, haystack: self::ALLOWED_TAGS, strict: true) === false) {
+ return '';
+ }
+
+ if ($closing === true) {
+ return '' . $tag . '>';
+ }
+
+ $kept = $this->sanitiseTagAttributes(tag: $tag, attrs: $attrs);
+
+ $rendered = '<' . $tag;
+ foreach ($kept as $name => $value) {
+ $rendered .= ' ' . $name . '="' . $value . '"';
+ }
+
+ // External elements get rel + target automatically
+ // (REQ-FTR-002 external-link scenario).
+ if ($tag === 'a'
+ && isset($kept['href']) === true
+ && preg_match(
+ pattern: '#^https?://#i',
+ subject: $kept['href']
+ ) === 1
+ ) {
+ $rendered .= ' rel="noopener noreferrer" target="_blank"';
+ }
+
+ $closingTag = '>';
+ if (in_array(needle: $tag, haystack: ['br', 'img'], strict: true) === true) {
+ $closingTag = ' />';
+ }
+
+ $rendered .= $closingTag;
+
+ return $rendered;
+ }//end sanitiseTag()
+
+ /**
+ * Extract the attributes of one opening tag that survive the
+ * allow-list (REQ-FTR-005).
+ *
+ * An attribute is kept only when its name is allow-listed for this
+ * tag AND its value does not carry a `javascript:` / `data:` /
+ * `vbscript:` scheme. Surviving values are HTML-escaped before they
+ * are handed back, so the caller can interpolate them directly.
+ *
+ * @param string $tag The lower-cased tag name.
+ * @param string $attrs The raw attribute blob from the source tag.
+ *
+ * @return array The escaped name => value pairs to keep.
+ */
+ private function sanitiseTagAttributes(string $tag, string $attrs): array {
+ $allowedAttrs = (self::ALLOWED_ATTRIBUTES[$tag] ?? []);
+ $kept = [];
+ if ($allowedAttrs === [] || trim(string: $attrs) === '') {
+ return $kept;
+ }
+
+ if (preg_match_all(
+ pattern: '#([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#',
+ subject: $attrs,
+ matches: $found,
+ flags: PREG_SET_ORDER
+ ) > 0
+ ) {
+ foreach ($found as $attr) {
+ $name = strtolower(string: $attr[1]);
+ if (in_array(needle: $name, haystack: $allowedAttrs, strict: true) === false) {
+ continue;
+ }
+
+ $value = ($attr[3] ?? ($attr[4] ?? ($attr[5] ?? '')));
+ // Reject data: / javascript: schemes on URL
+ // attributes (href / src). The allow-list
+ // already restricted us to those names so a
+ // scheme check is enough.
+ if (preg_match(
+ pattern: '#^\s*(javascript|data|vbscript):#i',
+ subject: $value
+ ) === 1
+ ) {
+ continue;
+ }
+
+ // Strip any control char or newline; keep
+ // simple value escaping for HTML context.
+ $cleanValue = htmlspecialchars(
+ string: $value,
+ flags: (ENT_QUOTES | ENT_HTML5),
+ encoding: 'UTF-8'
+ );
+ $kept[$name] = $cleanValue;
+ }//end foreach
+ }//end if
+
+ return $kept;
+ }//end sanitiseTagAttributes()
+
+ /**
+ * Validate a structured-mode config payload against the documented
+ * schema (REQ-FTR-003). Throws on extra keys, malformed `links`,
+ * or an unknown `layoutMode`.
+ *
+ * @param array $config The structured config.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On schema mismatch.
+ *
+ * @spec openspec/specs/footer-customization/spec.md
+ */
+ public function validateStructuredConfig(array $config): void {
+ foreach (array_keys(array: $config) as $key) {
+ if (in_array(needle: $key, haystack: self::STRUCTURED_CONFIG_KEYS, strict: true) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig contains unknown key: ' . (string)$key
+ );
+ }
+ }
+
+ if (array_key_exists(key: 'layoutMode', array: $config) === true) {
+ $mode = $config['layoutMode'];
+ if (is_string($mode) === false
+ || in_array(needle: $mode, haystack: self::LAYOUT_MODES, strict: true) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.layoutMode must be one of: ' . implode(separator: ', ', array: self::LAYOUT_MODES)
+ );
+ }
+ }
+
+ if (array_key_exists(key: 'links', array: $config) === true) {
+ $links = $config['links'];
+ if (is_array($links) === false) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.links must be an array'
+ );
+ }
+
+ foreach ($links as $entry) {
+ if (is_array($entry) === false
+ || isset($entry['label']) === false
+ || isset($entry['url']) === false
+ || is_string($entry['label']) === false
+ || is_string($entry['url']) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'footerConfig.links entries must be {label, url} string pairs'
+ );
+ }
+ }
+ }//end if
+ }//end validateStructuredConfig()
+
+ /**
+ * Resolve the effective footer payload for a single dashboard
+ * (REQ-FTR-004, REQ-FTR-006). Returns NULL when no footer should
+ * render, or an associative array suitable for serialising as
+ * the dashboard API response's `effectiveFooter` field.
+ *
+ * Resolution order (matches DashboardService.resolveFooterForDashboard
+ * tasks 6.5):
+ * - dashboard mode = `hidden` → NULL.
+ * - dashboard mode = `custom` → render the dashboard HTML.
+ * - dashboard mode = `inherit` → check global `footerEnabled`;
+ * if false → NULL; otherwise render the global footer HTML or
+ * structured config.
+ *
+ * @param Dashboard $dashboard The dashboard whose footer to resolve.
+ *
+ * @return array|null Effective footer payload keyed
+ * by `mode`, `html`, `config`,
+ * `backgroundColor`, `textColor`.
+ *
+ * @spec openspec/specs/footer-customization/spec.md
+ */
+ public function resolveFooterForDashboard(Dashboard $dashboard): ?array {
+ $rawMode = $dashboard->getDashboardFooterMode();
+ $mode = $rawMode;
+ if ($rawMode === '') {
+ $mode = Dashboard::FOOTER_MODE_INHERIT;
+ }
+
+ if ($mode === Dashboard::FOOTER_MODE_HIDDEN) {
+ return null;
+ }
+
+ $globals = $this->getGlobalSettings();
+
+ if ($mode === Dashboard::FOOTER_MODE_CUSTOM) {
+ $html = $dashboard->getDashboardFooterHtml();
+ if ($html === null || trim(string: $html) === '') {
+ return null;
+ }
+
+ return [
+ 'mode' => Dashboard::FOOTER_MODE_CUSTOM,
+ 'html' => $html,
+ 'config' => null,
+ 'backgroundColor' => $globals['footerBackgroundColor'],
+ 'textColor' => $globals['footerTextColor'],
+ ];
+ }
+
+ // Inherit branch — must consult the global toggle.
+ if ($globals['footerEnabled'] !== true) {
+ return null;
+ }
+
+ $html = $globals['footerHtml'];
+ $htmlValue = null;
+ if (is_string($html) === true && $html !== '') {
+ $htmlValue = $html;
+ } elseif (is_array($html) === true && $html !== []) {
+ // Language-variant map — the frontend selects the locale.
+ $htmlValue = $html;
+ }
+
+ $config = $globals['footerConfig'];
+ $configValue = null;
+ if (is_array($config) === true && $config !== []) {
+ $configValue = $config;
+ }
+
+ if ($htmlValue === null && $configValue === null) {
+ // Footer enabled but nothing configured — skip rendering.
+ return null;
+ }
+
+ return [
+ 'mode' => 'global',
+ 'html' => $htmlValue,
+ 'config' => $configValue,
+ 'backgroundColor' => $globals['footerBackgroundColor'],
+ 'textColor' => $globals['footerTextColor'],
+ ];
+ }//end resolveFooterForDashboard()
+
+ /**
+ * Validate that a value is a `#rgb` / `#rrggbb` hex colour string
+ * (REQ-FTR-009). Throws on anything else.
+ *
+ * @param mixed $value The value to validate.
+ * @param string $fieldName The field name for the error message.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the value is not a hex string.
+ */
+ private function assertHexColour(mixed $value, string $fieldName): void {
+ if (is_string($value) === false
+ || preg_match(pattern: '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', subject: $value) !== 1
+ ) {
+ throw new InvalidArgumentException(
+ message: $fieldName . ' must be a hex colour string (e.g. #1a1a1a)'
+ );
+ }
+ }//end assertHexColour()
}//end class
diff --git a/lib/Service/HealthPingService.php b/lib/Service/HealthPingService.php
new file mode 100644
index 000000000..e38acb1a5
--- /dev/null
+++ b/lib/Service/HealthPingService.php
@@ -0,0 +1,651 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Service;
+
+use DateTime;
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Db\WidgetPlacement;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCP\Http\Client\IClientService;
+use OCP\IAppConfig;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Service for pinging, classifying, and caching tile health-badge state.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Ping, classify and cache is
+ * one cohesive responsibility, and most of the complexity is the
+ * classification table — a wide but flat mapping of transport outcomes and
+ * status codes onto badge states. Splitting it would separate the table from
+ * the code that is the reason it exists.
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+class HealthPingService {
+
+ /**
+ * Default cache TTL / ping interval in seconds when a tile has no (or
+ * an invalid) `pingInterval` configured (REQ-HPING-001 "Interval
+ * bounds").
+ *
+ * @var integer
+ */
+ public const DEFAULT_INTERVAL_SECONDS = 60;
+
+ /**
+ * Minimum permitted ping interval in seconds — any configured value
+ * below this is clamped up (REQ-HPING-001).
+ *
+ * @var integer
+ */
+ public const MIN_INTERVAL_SECONDS = 15;
+
+ /**
+ * Default HTTP status the target is expected to return when the tile
+ * author has not set an explicit `expectedStatus` — any 2xx/3xx is
+ * accepted.
+ *
+ * @var integer
+ */
+ public const DEFAULT_EXPECTED_STATUS_RANGE_LOW = 200;
+
+ /**
+ * Highest HTTP status still treated as "up" when a placement declares no explicit range.
+ *
+ * @var integer
+ */
+ public const DEFAULT_EXPECTED_STATUS_RANGE_HIGH = 399;
+
+ /**
+ * HTTP connect timeout in seconds for the health-check request.
+ *
+ * @var integer
+ */
+ public const CONNECT_TIMEOUT = 5;
+
+ /**
+ * HTTP total request timeout in seconds for the health-check request.
+ *
+ * @var integer
+ */
+ public const REQUEST_TIMEOUT = 10;
+
+ /**
+ * IAppConfig key — JSON array of hostnames permitted as a ping
+ * target. FAIL-CLOSED: empty or missing means NO host is permitted.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_ALLOWED_HOSTS = 'healthping_allowed_hosts';
+
+ /**
+ * IAppConfig key — the latency threshold (milliseconds) above which
+ * an otherwise-matching response is classified `degraded` rather than
+ * `online` (REQ-HPING-002 "Degraded when slow").
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_LATENCY_THRESHOLD_MS = 'healthping_latency_threshold_ms';
+
+ /**
+ * Default latency threshold in milliseconds.
+ *
+ * @var integer
+ */
+ public const DEFAULT_LATENCY_THRESHOLD_MS = 2000;
+
+ /**
+ * Badge states, in priority order — the only three values REQ-HPING-002
+ * and REQ-HPING-004 recognise.
+ *
+ * @var array
+ */
+ public const BADGE_STATES = ['online', 'degraded', 'offline'];
+
+ /**
+ * Lazily resolved {@see ICache} backing the per-placement badge cache.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param IClientService $clientService HTTP client factory for the health-check request.
+ * @param ICacheFactory $cacheFactory Backing factory for the distributed badge cache.
+ * @param IAppConfig $appConfig Admin config: allow-listed hosts, latency threshold.
+ * @param WidgetPlacementMapper $placementMapper Resolves placements by id / enumerates all placements.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ private readonly IClientService $clientService,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly IAppConfig $appConfig,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Resolve the health badge for one placement (REQ-HPING-003 "Endpoint
+ * serves the cached badge"). Serves a fresh cache hit without
+ * re-pinging; otherwise attempts a fresh ping and caches the result.
+ * When the ping is refused by the allow-list (fail-closed) or the
+ * placement carries no ping config, falls back to the last-known
+ * cached badge marked `stale`, or a neutral "never pinged" shape.
+ * Never throws.
+ *
+ * @param integer $placementId The widget placement id.
+ *
+ * @return array `{state, checkedAt, latencyMs, stale}` or `{error: string}`.
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ public function resolveForPlacement(int $placementId): array {
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (Throwable $exception) {
+ return ['error' => 'placement_not_found'];
+ }
+
+ $config = $this->readPlacementConfig(placement: $placement);
+ if (($config['healthPingEnabled'] ?? false) !== true) {
+ return ['error' => 'not_configured'];
+ }
+
+ return $this->resolveForConfig(placementId: $placementId, config: $config);
+ }//end resolveForPlacement()
+
+ /**
+ * Core resolve/cache logic shared by {@see self::resolveForPlacement()}
+ * and {@see self::refreshDuePlacements()}.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param array $config The placement's health-ping config.
+ *
+ * @return array `{state, checkedAt, latencyMs, stale}`.
+ */
+ private function resolveForConfig(int $placementId, array $config): array {
+ $interval = $this->clampInterval(seconds: (int)($config['pingInterval'] ?? 0));
+ $cacheKey = $this->buildCacheKey(placementId: $placementId);
+ $cache = $this->getCache();
+ $cached = $this->readCache(cache: $cache, cacheKey: $cacheKey);
+
+ if ($cached !== null) {
+ $age = (time() - (int)($cached['checkedAtTs'] ?? 0));
+ if ($age >= 0 && $age < $interval) {
+ return $this->publicShape(reading: $cached, stale: false);
+ }
+ }
+
+ $fresh = $this->attemptPing(config: $config);
+
+ if ($fresh !== null) {
+ $fresh['checkedAtTs'] = time();
+ if ($cache !== null) {
+ $cache->set(key: $cacheKey, value: json_encode($fresh), ttl: $interval);
+ }
+
+ return $this->publicShape(reading: $fresh, stale: false);
+ }
+
+ if ($cached !== null) {
+ // Allow-list refused the ping — REQ-HPING-002 "no ping was
+ // performed rather than a false 'up' state" / REQ-HPING-003
+ // "Stale fallback on refresh failure": degrade gracefully to
+ // the last-known reading rather than fabricate a new one.
+ return $this->publicShape(reading: $cached, stale: true);
+ }
+
+ return [
+ 'state' => null,
+ 'checkedAt' => null,
+ 'latencyMs' => null,
+ 'stale' => true,
+ ];
+ }//end resolveForConfig()
+
+ /**
+ * Refresh every due, ping-enabled placement in the instance
+ * (REQ-HPING-003 "Background refresh of due entries"). Skips
+ * placements whose host is not allow-listed (the fail-closed ping
+ * attempt naturally refuses and leaves the cache untouched) and
+ * isolates each placement's failure so one broken tile never blocks
+ * the rest. Never throws.
+ *
+ * @return integer The number of placements actually refreshed.
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ public function refreshDuePlacements(): int {
+ $refreshed = 0;
+
+ try {
+ $placements = $this->placementMapper->findAll();
+ } catch (Throwable $exception) {
+ $this->logger->warning(
+ message: 'HealthPingService: could not enumerate placements for refresh',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return 0;
+ }
+
+ foreach ($placements as $placement) {
+ try {
+ $config = $this->readPlacementConfig(placement: $placement);
+ if (($config['healthPingEnabled'] ?? false) !== true) {
+ continue;
+ }
+
+ if ($this->isDue(placementId: $placement->getId(), config: $config) === false) {
+ continue;
+ }
+
+ $result = $this->resolveForConfig(placementId: $placement->getId(), config: $config);
+ if ($result['stale'] === false) {
+ // Only a completed ping (fresh reading) counts as an
+ // actual refresh — an allow-list refusal falls back to
+ // `stale: true` without ever contacting the host.
+ $refreshed++;
+ }
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'HealthPingService: refresh failed for one placement, continuing',
+ context: [
+ 'app' => Application::APP_ID,
+ 'placementId' => $placement->getId(),
+ 'exception' => $exception->getMessage(),
+ ]
+ );
+ }//end try
+ }//end foreach
+
+ return $refreshed;
+ }//end refreshDuePlacements()
+
+ /**
+ * Validate a candidate health-ping config at save time
+ * (REQ-HPING-001 "Host not on the allow-list is rejected at save").
+ * FAIL-CLOSED: a `healthUrl` whose host is not on the (possibly
+ * empty) allow-list is always rejected. Returns no errors when
+ * `healthPingEnabled` is not `true` — an author may save an untouched
+ * (disabled) ping block freely.
+ *
+ * @param array $config The candidate `{healthPingEnabled, healthUrl, expectedStatus, pingInterval}` config.
+ *
+ * @return string[] Validation error codes; empty when the config is valid.
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ public function validateConfig(array $config): array {
+ if (($config['healthPingEnabled'] ?? false) !== true) {
+ return [];
+ }
+
+ $errors = [];
+ $url = trim(string: (string)($config['healthUrl'] ?? ''));
+
+ if ($url === '') {
+ $errors[] = 'health_url_required';
+ return $errors;
+ }
+
+ if ($this->hasValidScheme(url: $url) === false) {
+ $errors[] = 'invalid_url';
+ return $errors;
+ }
+
+ if ($this->isHostAllowed(url: $url) === false) {
+ // FAIL-CLOSED (REQ-HPING-001 "rejected at save time").
+ $errors[] = 'host_not_allowed';
+ }
+
+ return $errors;
+ }//end validateConfig()
+
+ /**
+ * Clamp a configured ping interval: values `<= 0` (unset) default to
+ * {@see self::DEFAULT_INTERVAL_SECONDS}; any positive value below
+ * {@see self::MIN_INTERVAL_SECONDS} is raised to that minimum
+ * (REQ-HPING-001 "Interval bounds").
+ *
+ * @param integer $seconds The raw configured interval, or `0`/negative when unset.
+ *
+ * @return integer The clamped interval in seconds.
+ *
+ * @spec openspec/specs/service-health-ping/spec.md
+ */
+ public function clampInterval(int $seconds): int {
+ if ($seconds <= 0) {
+ return self::DEFAULT_INTERVAL_SECONDS;
+ }
+
+ return max($seconds, self::MIN_INTERVAL_SECONDS);
+ }//end clampInterval()
+
+ /**
+ * Whether a cached badge for one placement is due for refresh — no
+ * cached entry, or the cached entry is older than the placement's
+ * configured (clamped) interval.
+ *
+ * @param integer $placementId The widget placement id.
+ * @param array $config The placement's health-ping config.
+ *
+ * @return boolean
+ */
+ private function isDue(int $placementId, array $config): bool {
+ $interval = $this->clampInterval(seconds: (int)($config['pingInterval'] ?? 0));
+ $cached = $this->readCache(cache: $this->getCache(), cacheKey: $this->buildCacheKey(placementId: $placementId));
+ if ($cached === null) {
+ return true;
+ }
+
+ $age = (time() - (int)($cached['checkedAtTs'] ?? 0));
+ return ($age < 0 || $age >= $interval);
+ }//end isDue()
+
+ /**
+ * Attempt the allow-listed, server-side health request and classify
+ * the outcome (REQ-HPING-002). Returns `null` — WITHOUT ever opening a
+ * connection — when the host is not on the allow-list (fail-closed);
+ * every other outcome (success, wrong status, transport failure) is a
+ * definitive, cacheable classification.
+ *
+ * @param array $config The placement's health-ping config.
+ *
+ * @return array|null `{state, latencyMs}` or `null` when refused.
+ */
+ private function attemptPing(array $config): ?array {
+ $url = trim(string: (string)($config['healthUrl'] ?? ''));
+ if ($url === '' || $this->hasValidScheme(url: $url) === false) {
+ return null;
+ }
+
+ if ($this->isHostAllowed(url: $url) === false) {
+ $this->logger->warning(
+ message: 'HealthPingService: host not on healthping_allowed_hosts, refusing ping (fail-closed)',
+ context: ['app' => Application::APP_ID]
+ );
+ return null;
+ }
+
+ $expectedStatus = (int)($config['expectedStatus'] ?? 0);
+ $threshold = $this->latencyThreshold();
+ $startedAt = microtime(as_float: true);
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'connect_timeout' => self::CONNECT_TIMEOUT,
+ 'timeout' => self::REQUEST_TIMEOUT,
+ 'http_errors' => false,
+ // No auto-redirect — a 3xx to an unexpected host would
+ // bypass the allow-list check above.
+ 'allow_redirects' => false,
+ ]
+ );
+ } catch (Throwable $exception) {
+ // REQ-HPING-002 "Offline on failure" — a connection failure or
+ // timeout IS a definitive reading, not a missed attempt.
+ $latencyMs = (int)round((microtime(as_float: true) - $startedAt) * 1000);
+ $this->logger->info(
+ message: 'HealthPingService: ping transport failure, classifying offline',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return ['state' => 'offline', 'latencyMs' => $latencyMs];
+ }//end try
+
+ $latencyMs = (int)round((microtime(as_float: true) - $startedAt) * 1000);
+ $status = (int)$response->getStatusCode();
+
+ if ($this->matchesExpectedStatus(status: $status, expectedStatus: $expectedStatus) === false) {
+ return ['state' => 'offline', 'latencyMs' => $latencyMs];
+ }
+
+ if ($latencyMs > $threshold) {
+ return ['state' => 'degraded', 'latencyMs' => $latencyMs];
+ }
+
+ return ['state' => 'online', 'latencyMs' => $latencyMs];
+ }//end attemptPing()
+
+ /**
+ * Whether an HTTP status code satisfies the tile's expected status.
+ * With no explicit `expectedStatus` configured (`<= 0`), any status in
+ * the default 200-399 "reachable" range is accepted; otherwise an
+ * EXACT match is required.
+ *
+ * @param integer $status The observed HTTP status code.
+ * @param integer $expectedStatus The configured expected status, or `0`/negative when unset.
+ *
+ * @return boolean
+ */
+ private function matchesExpectedStatus(int $status, int $expectedStatus): bool {
+ if ($expectedStatus <= 0) {
+ return ($status >= self::DEFAULT_EXPECTED_STATUS_RANGE_LOW && $status <= self::DEFAULT_EXPECTED_STATUS_RANGE_HIGH);
+ }
+
+ return $status === $expectedStatus;
+ }//end matchesExpectedStatus()
+
+ /**
+ * Resolve the configured latency-degraded threshold in milliseconds,
+ * clamped to a sane positive minimum.
+ *
+ * @return integer
+ */
+ private function latencyThreshold(): int {
+ $threshold = $this->appConfig->getValueInt(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_LATENCY_THRESHOLD_MS,
+ default: self::DEFAULT_LATENCY_THRESHOLD_MS
+ );
+
+ if ($threshold > 0) {
+ return $threshold;
+ }
+
+ return self::DEFAULT_LATENCY_THRESHOLD_MS;
+ }//end latencyThreshold()
+
+ /**
+ * Validate a URL's scheme is `http` or `https`.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean
+ */
+ private function hasValidScheme(string $url): bool {
+ $scheme = strtolower(string: (string)parse_url(url: $url, component: PHP_URL_SCHEME));
+ return in_array(needle: $scheme, haystack: ['http', 'https'], strict: true);
+ }//end hasValidScheme()
+
+ /**
+ * Check a URL's host against `healthping_allowed_hosts`. FAIL-CLOSED:
+ * an empty, missing, or unparseable allow-list permits NO host — the
+ * admin must explicitly opt hosts in.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean True only when the host is explicitly allow-listed.
+ */
+ private function isHostAllowed(string $url): bool {
+ $host = parse_url(url: $url, component: PHP_URL_HOST);
+ if (is_string(value: $host) === false || $host === '') {
+ return false;
+ }
+
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_ALLOWED_HOSTS,
+ default: ''
+ );
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false || $decoded === []) {
+ // FAIL-CLOSED — no configured list means no host is allowed.
+ return false;
+ }
+
+ $needle = strtolower(string: $host);
+ foreach ($decoded as $allowed) {
+ if (is_string(value: $allowed) === true && strtolower(string: $allowed) === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isHostAllowed()
+
+ /**
+ * Read a placement's health-ping config from its `content` JSON blob
+ * (no schema change — REQ-HPING-001).
+ *
+ * @param WidgetPlacement $placement The placement entity.
+ *
+ * @return array
+ */
+ private function readPlacementConfig(WidgetPlacement $placement): array {
+ // No is_array() probe and no [] fallback: getContentArray() is declared
+ // `: array` and already returns [] when the column is null, so the check
+ // was always true (PHPStan 2: function.alreadyNarrowedType) and the
+ // fallback unreachable.
+ return $placement->getContentArray();
+ }//end readPlacementConfig()
+
+ /**
+ * Shape an internal reading (which also carries the internal
+ * `checkedAtTs` unix timestamp) into the public response contract
+ * (REQ-HPING-003): `{state, checkedAt, latencyMs, stale}`. NEVER
+ * includes the health URL, request headers, or upstream response
+ * body.
+ *
+ * @param array $reading The internal reading.
+ * @param boolean $stale Whether this is a stale (cache-expired-but-served) reading.
+ *
+ * @return array
+ */
+ private function publicShape(array $reading, bool $stale): array {
+ $checkedAtTs = (int)($reading['checkedAtTs'] ?? time());
+
+ $latencyMs = null;
+ if (isset($reading['latencyMs']) === true) {
+ $latencyMs = (int)$reading['latencyMs'];
+ }
+
+ return [
+ 'state' => $reading['state'] ?? null,
+ 'checkedAt' => (new DateTime('@' . $checkedAtTs))->format(format: DATE_ATOM),
+ 'latencyMs' => $latencyMs,
+ 'stale' => $stale,
+ ];
+ }//end publicShape()
+
+ /**
+ * Build the badge cache key — one entry per placement.
+ *
+ * @param integer $placementId The widget placement id.
+ *
+ * @return string
+ */
+ private function buildCacheKey(int $placementId): string {
+ return 'badge_' . $placementId;
+ }//end buildCacheKey()
+
+ /**
+ * Read + JSON-decode a cache entry. Returns `null` on a miss or a
+ * corrupt entry.
+ *
+ * @param ICache|null $cache The cache instance, or `null` when the cache
+ * subsystem is unavailable.
+ * @param string $cacheKey The cache key.
+ *
+ * @return array|null
+ */
+ private function readCache(?ICache $cache, string $cacheKey): ?array {
+ if ($cache === null) {
+ return null;
+ }
+
+ $raw = $cache->get(key: $cacheKey);
+ if (is_string(value: $raw) === false) {
+ return null;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === true) {
+ return $decoded;
+ }
+
+ return null;
+ }//end readCache()
+
+ /**
+ * Lazily resolve the distributed cache. Returns `null` when the cache
+ * subsystem is unavailable (e.g. unit tests with a stub factory).
+ *
+ * @return ICache|null
+ */
+ private function getCache(): ?ICache {
+ if ($this->cache !== null) {
+ return $this->cache;
+ }
+
+ try {
+ $this->cache = $this->cacheFactory->createDistributed(prefix: 'launchpad_healthping_');
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'HealthPingService: cache subsystem unavailable, falling back to direct ping',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ $this->cache = null;
+ }
+
+ return $this->cache;
+ }//end getCache()
+}//end class
diff --git a/lib/Service/IframeService.php b/lib/Service/IframeService.php
new file mode 100644
index 000000000..e29f25f14
--- /dev/null
+++ b/lib/Service/IframeService.php
@@ -0,0 +1,370 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Service;
+
+use OCA\LaunchPad\AppInfo\Application;
+use OCP\Http\Client\IClientService;
+use OCP\IAppConfig;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Allow-list validation + CSP-source-of-truth for the `iframe` widget.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+class IframeService {
+
+ /**
+ * IAppConfig key — JSON array of hostnames permitted as embed targets.
+ * FAIL-CLOSED: empty or missing means NO host is permitted
+ * (REQ-IFRAME-002).
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_ALLOWED_HOSTS = 'iframe_allowed_hosts';
+
+ /**
+ * Sandbox token(s) an author can never grant — the frame must not be
+ * able to navigate the host page (REQ-IFRAME-004 "the sandbox MUST
+ * NEVER include allow-top-navigation"). Matched as a prefix so both
+ * `allow-top-navigation` and `allow-top-navigation-by-user-activation`
+ * are blocked.
+ *
+ * @var string
+ */
+ private const FORBIDDEN_SANDBOX_PREFIX = 'allow-top-navigation';
+
+ /**
+ * The sandbox tokens an author may toggle. Anything outside this set
+ * (including any `allow-top-navigation*` variant) is stripped, never
+ * merely flagged, so a malformed/tampered payload can't slip a
+ * forbidden token through (REQ-IFRAME-004).
+ *
+ * @var array
+ */
+ private const PERMITTED_SANDBOX_TOKENS = [
+ 'allow-scripts',
+ 'allow-same-origin',
+ 'allow-forms',
+ 'allow-popups',
+ 'allow-popups-to-escape-sandbox',
+ 'allow-presentation',
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param IAppConfig $appConfig Admin config: allow-listed hosts.
+ * @param IClientService $clientService HTTP client used for the server-side framability probe.
+ * @param LoggerInterface $logger Logger for probe failures.
+ */
+ public function __construct(
+ private readonly IAppConfig $appConfig,
+ private readonly IClientService $clientService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Server-side check of whether a URL can actually be framed
+ * (REQ-IFRAME-003 "graceful degradation"). The browser cannot reliably
+ * distinguish an `X-Frame-Options: DENY` / `frame-ancestors 'none'`
+ * refusal from a normal cross-origin embed — both leave the iframe's
+ * `contentDocument` null — so the client cannot detect the block on its
+ * own. This performs a server-side request and inspects the target's
+ * framing headers, letting the widget render the fallback card up front
+ * instead of a permanently blank frame.
+ *
+ * FAIL-CLOSED on the allow-list: a host not on `iframe_allowed_hosts` is
+ * never fetched and reports `framable: false`. Network/parse failures
+ * report `framable: false` with the corresponding reason — a target we
+ * cannot verify is treated as un-framable rather than gambling on a
+ * blank frame.
+ *
+ * @param string $url The candidate embed URL.
+ *
+ * @return array{framable: bool, reason: string} Whether the URL may be framed and why.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ public function checkFramable(string $url): array {
+ if ($this->isHostAllowed(url: $url) === false) {
+ return ['framable' => false, 'reason' => 'host_not_allowed'];
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'timeout' => 8,
+ 'connect_timeout' => 5,
+ 'allow_redirects' => ['max' => 3],
+ // Never surface upstream bodies; we only need the headers.
+ 'headers' => ['Accept' => 'text/html'],
+ ]
+ );
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'IframeService: framable check could not reach target, treating as un-framable',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return ['framable' => false, 'reason' => 'unreachable'];
+ }
+
+ $xfo = strtolower(string: trim(string: (string)$response->getHeader('X-Frame-Options')));
+ if ($this->xfoRefusesFraming(xfo: $xfo) === true) {
+ return ['framable' => false, 'reason' => 'x_frame_options'];
+ }
+
+ $csp = strtolower(string: (string)$response->getHeader('Content-Security-Policy'));
+ if ($this->cspRefusesFraming(csp: $csp) === true) {
+ return ['framable' => false, 'reason' => 'frame_ancestors'];
+ }
+
+ return ['framable' => true, 'reason' => 'ok'];
+ }//end checkFramable()
+
+ /**
+ * Whether an `X-Frame-Options` header value refuses our frame.
+ *
+ * Any XFO value refuses: `DENY` outright, and `SAMEORIGIN` /`ALLOW-FROM`
+ * too, because the embedding LaunchPad page is always a different origin
+ * than the target.
+ *
+ * @param string $xfo The lower-cased, trimmed header value (`''` if absent).
+ *
+ * @return bool True when the header refuses framing.
+ */
+ private function xfoRefusesFraming(string $xfo): bool {
+ if ($xfo === '') {
+ return false;
+ }
+
+ return str_contains(haystack: $xfo, needle: 'deny') === true
+ || str_contains(haystack: $xfo, needle: 'sameorigin') === true
+ || str_contains(haystack: $xfo, needle: 'allow-from') === true;
+ }//end xfoRefusesFraming()
+
+ /**
+ * Whether a `Content-Security-Policy` header's `frame-ancestors`
+ * directive refuses our frame.
+ *
+ * `frame-ancestors 'none'` refuses all framing. A `'self'` or a host-list
+ * that does not name our origin also refuses us; since LaunchPad embeds
+ * arbitrary third parties, anything other than a wildcard is treated as a
+ * refusal (fail-closed).
+ *
+ * @param string $csp The lower-cased header value (`''` if absent).
+ *
+ * @return bool True when the directive refuses framing.
+ */
+ private function cspRefusesFraming(string $csp): bool {
+ $matches = [];
+ if ($csp === '' || preg_match(pattern: '/frame-ancestors\s+([^;]*)/', subject: $csp, matches: $matches) !== 1) {
+ return false;
+ }
+
+ $directive = trim(string: $matches[1]);
+ if ($directive !== '' && $directive !== '*' && str_contains(haystack: $directive, needle: 'http') === false) {
+ return true;
+ }
+
+ return str_contains(haystack: $directive, needle: "'none'") === true;
+ }//end cspRefusesFraming()
+
+ /**
+ * Validate a candidate iframe placement config at save time
+ * (REQ-IFRAME-002 "rejected at save time"). FAIL-CLOSED: a URL whose
+ * host is not on the (possibly empty) allow-list is always rejected.
+ *
+ * @param array $config The candidate `{url, title, height, aspect, sandbox}` config.
+ *
+ * @return string[] Validation error codes; empty when the config is valid.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ public function validateConfig(array $config): array {
+ $errors = [];
+ $url = trim(string: (string)($config['url'] ?? ''));
+
+ if ($url === '') {
+ $errors[] = 'url_required';
+ } elseif ($this->hasValidScheme(url: $url) === false) {
+ $errors[] = 'invalid_url';
+ } elseif ($this->isHostAllowed(url: $url) === false) {
+ // FAIL-CLOSED — never "allow all" on an empty/missing list.
+ $errors[] = 'host_not_allowed';
+ }
+
+ if (trim(string: (string)($config['title'] ?? '')) === '') {
+ // REQ-IFRAME-004 "Accessible frame title" — an iframe with no
+ // title cannot expose one to screen readers.
+ $errors[] = 'title_required';
+ }
+
+ $sandbox = $config['sandbox'] ?? [];
+ if (is_array(value: $sandbox) === true && $this->containsForbiddenSandboxToken(tokens: $sandbox) === true) {
+ $errors[] = 'forbidden_sandbox_token';
+ }
+
+ return $errors;
+ }//end validateConfig()
+
+ /**
+ * Whether a candidate URL's host is currently allow-listed
+ * (REQ-IFRAME-002 / used at render time to refuse a placement whose
+ * host was later removed from the list).
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean True only when the host is explicitly allow-listed.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ public function isHostAllowed(string $url): bool {
+ $host = parse_url(url: $url, component: PHP_URL_HOST);
+ if (is_string(value: $host) === false || $host === '') {
+ return false;
+ }
+
+ $needle = strtolower(string: $host);
+ foreach ($this->getAllowedHosts() as $allowed) {
+ if (strtolower(string: $allowed) === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isHostAllowed()
+
+ /**
+ * The full admin-configured allow-list, decoded and normalised. Empty
+ * (never null) when the config key is missing/blank/malformed —
+ * FAIL-CLOSED (REQ-IFRAME-002, REQ-IFRAME-003 "Non-allow-listed hosts
+ * are never added").
+ *
+ * @return string[] Lower-case-safe hostnames (original casing preserved), deduplicated.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ public function getAllowedHosts(): array {
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_ALLOWED_HOSTS,
+ default: ''
+ );
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false || $decoded === []) {
+ return [];
+ }
+
+ $hosts = [];
+ foreach ($decoded as $host) {
+ if (is_string(value: $host) === true && trim(string: $host) !== '') {
+ $hosts[] = trim(string: $host);
+ }
+ }
+
+ return array_values(array: array_unique(array: $hosts));
+ }//end getAllowedHosts()
+
+ /**
+ * Strip any forbidden token (defence-in-depth — the config form never
+ * offers `allow-top-navigation*`, but a save request is not required
+ * to have gone through the form) and any token outside the permitted
+ * set from a candidate sandbox token list.
+ *
+ * @param mixed $tokens The candidate sandbox token list.
+ *
+ * @return string[] The sanitised token list.
+ *
+ * @spec openspec/specs/iframe-embed-widget/spec.md
+ */
+ public function sanitiseSandboxTokens(mixed $tokens): array {
+ if (is_array(value: $tokens) === false) {
+ return [];
+ }
+
+ $clean = [];
+ foreach ($tokens as $token) {
+ if (is_string(value: $token) === false) {
+ continue;
+ }
+
+ if (str_starts_with(haystack: $token, needle: self::FORBIDDEN_SANDBOX_PREFIX) === true) {
+ continue;
+ }
+
+ if (in_array(needle: $token, haystack: self::PERMITTED_SANDBOX_TOKENS, strict: true) === true) {
+ $clean[] = $token;
+ }
+ }
+
+ return array_values(array: array_unique(array: $clean));
+ }//end sanitiseSandboxTokens()
+
+ /**
+ * Whether a candidate sandbox token list contains a forbidden token
+ * (REQ-IFRAME-004).
+ *
+ * @param array $tokens The candidate sandbox token list.
+ *
+ * @return boolean
+ */
+ private function containsForbiddenSandboxToken(array $tokens): bool {
+ foreach ($tokens as $token) {
+ if (is_string(value: $token) === true
+ && str_starts_with(haystack: $token, needle: self::FORBIDDEN_SANDBOX_PREFIX) === true
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end containsForbiddenSandboxToken()
+
+ /**
+ * Validate a URL's scheme is `http` or `https`.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean
+ */
+ private function hasValidScheme(string $url): bool {
+ $scheme = strtolower(string: (string)parse_url(url: $url, component: PHP_URL_SCHEME));
+ return in_array(needle: $scheme, haystack: ['http', 'https'], strict: true);
+ }//end hasValidScheme()
+}//end class
diff --git a/lib/Service/ImageMimeValidator.php b/lib/Service/ImageMimeValidator.php
index bf3442ff7..61071a69d 100644
--- a/lib/Service/ImageMimeValidator.php
+++ b/lib/Service/ImageMimeValidator.php
@@ -21,8 +21,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -37,82 +37,80 @@
*
* @spec openspec/specs/resource-uploads/spec.md
*/
-class ImageMimeValidator
-{
- /**
- * Map normalised declared types to expected detected MIMEs.
- *
- * `getimagesizefromstring` returns these mime strings for valid
- * images of each type. `jpeg` and `jpg` both produce `image/jpeg`.
- *
- * @var array
- */
- private const RASTER_MIME_MAP = [
- 'jpeg' => 'image/jpeg',
- 'jpg' => 'image/jpeg',
- 'png' => 'image/png',
- 'gif' => 'image/gif',
- 'webp' => 'image/webp',
- ];
+class ImageMimeValidator {
+ /**
+ * Map normalised declared types to expected detected MIMEs.
+ *
+ * `getimagesizefromstring` returns these mime strings for valid
+ * images of each type. `jpeg` and `jpg` both produce `image/jpeg`.
+ *
+ * @var array
+ */
+ private const RASTER_MIME_MAP = [
+ 'jpeg' => 'image/jpeg',
+ 'jpg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ ];
- /**
- * Validate that the declared raster type matches the detected MIME.
- *
- * SVG is skipped (delegated to SvgSanitiser in a sibling change).
- * Caller MUST enforce the size cap before invoking this method to
- * avoid loading oversize blobs into the image library.
- *
- * @param string $declaredType Normalised, lowercase declared type
- * (e.g. 'png', 'jpg', 'svg').
- * @param string $bytes Decoded image bytes.
- *
- * @return void
- *
- * @throws CorruptImageException When the bytes cannot be decoded.
- * @throws MimeMismatchException When the detected MIME differs from
- * the declared type.
- *
- * @spec openspec/specs/resource-uploads/spec.md
- */
- public function validate(string $declaredType, string $bytes): void
- {
- // SVG validation is the sanitiser's job, not this validator's.
- if ($declaredType === 'svg') {
- return;
- }
+ /**
+ * Validate that the declared raster type matches the detected MIME.
+ *
+ * SVG is skipped (delegated to SvgSanitiser in a sibling change).
+ * Caller MUST enforce the size cap before invoking this method to
+ * avoid loading oversize blobs into the image library.
+ *
+ * @param string $declaredType Normalised, lowercase declared type
+ * (e.g. 'png', 'jpg', 'svg').
+ * @param string $bytes Decoded image bytes.
+ *
+ * @return void
+ *
+ * @throws CorruptImageException When the bytes cannot be decoded.
+ * @throws MimeMismatchException When the detected MIME differs from
+ * the declared type.
+ *
+ * @spec openspec/specs/resource-uploads/spec.md
+ */
+ public function validate(string $declaredType, string $bytes): void {
+ // SVG validation is the sanitiser's job, not this validator's.
+ if ($declaredType === 'svg') {
+ return;
+ }
- if (isset(self::RASTER_MIME_MAP[$declaredType]) === false) {
- // Should never happen — caller already vetted the declared
- // type — but be defensive against future callers.
- throw new MimeMismatchException();
- }
+ if (isset(self::RASTER_MIME_MAP[$declaredType]) === false) {
+ // Should never happen — caller already vetted the declared
+ // type — but be defensive against future callers.
+ throw new MimeMismatchException();
+ }
- $expectedMime = self::RASTER_MIME_MAP[$declaredType];
+ $expectedMime = self::RASTER_MIME_MAP[$declaredType];
- // `getimagesizefromstring` returns false for non-images. We
- // suppress the warning via a local error handler instead of
- // the `@` operator (banned by phpmd's ErrorControlOperator
- // rule). The handler is restored before any branch returns.
- $previous = set_error_handler(
- callback: static function (): bool {
- return true;
- }
- );
+ // `getimagesizefromstring` returns false for non-images. We
+ // suppress the warning via a local error handler instead of
+ // the `@` operator (banned by phpmd's ErrorControlOperator
+ // rule). The handler is restored before any branch returns.
+ $previous = set_error_handler(
+ callback: static function (): bool {
+ return true;
+ }
+ );
- try {
- $info = getimagesizefromstring(string: $bytes);
- } finally {
- restore_error_handler();
- unset($previous);
- }
+ try {
+ $info = getimagesizefromstring(string: $bytes);
+ } finally {
+ restore_error_handler();
+ unset($previous);
+ }
- if ($info === false) {
- throw new CorruptImageException();
- }
+ if ($info === false) {
+ throw new CorruptImageException();
+ }
- $detectedMime = (string) $info['mime'];
- if ($detectedMime !== $expectedMime) {
- throw new MimeMismatchException();
- }
- }//end validate()
+ $detectedMime = (string)$info['mime'];
+ if ($detectedMime !== $expectedMime) {
+ throw new MimeMismatchException();
+ }
+ }//end validate()
}//end class
diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php
index 9e168ce87..1e2eb3255 100644
--- a/lib/Service/ImportService.php
+++ b/lib/Service/ImportService.php
@@ -17,8 +17,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2024 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -33,7 +33,6 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
-use RuntimeException;
use Throwable;
use ZipArchive;
@@ -43,597 +42,667 @@
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
* Validation + remap + transactional restore is intentionally cohesive.
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
+ * An importer must reject every malformed archive shape it can be
+ * handed. The branches are per-field validation guards over untrusted
+ * ZIP input, each a flat early return, so the count reflects the number
+ * of things checked rather than nested control flow.
* @SuppressWarnings(PHPMD.NPathComplexity)
+ * Same guards seen multiplicatively: independent optional fields
+ * combine into many acyclic paths without adding nesting depth.
*/
-class ImportService
-{
- /**
- * Maximum manifest schema version this service can read.
- *
- * @var integer
- */
- public const SCHEMA_VERSION = ExportService::SCHEMA_VERSION;
-
- /**
- * Public marker for a UUID-collision result so the controller can
- * map it to HTTP 409.
- *
- * @var string
- */
- public const ERR_UUID_COLLISION = 'uuidCollision';
-
- /**
- * Public marker for a metadata-field type mismatch.
- *
- * @var string
- */
- public const ERR_FIELD_TYPE_MISMATCH = 'metadataFieldTypeMismatch';
-
- /**
- * Public marker for an invalid dashboard payload.
- *
- * @var string
- */
- public const ERR_INVALID_DASHBOARD = 'invalidDashboard';
-
- /**
- * Constructor.
- *
- * @param DashboardMapper $dashboardMapper Dashboard data mapper.
- * @param WidgetPlacementMapper $placementMapper Widget placement mapper.
- * @param IDBConnection $db Database connection.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- private readonly DashboardMapper $dashboardMapper,
- private readonly WidgetPlacementMapper $placementMapper,
- private readonly IDBConnection $db,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Import a ZIP archive.
- *
- * @param string $zipPath Path to the uploaded ZIP file.
- * @param bool $preserveUuids When true, fail on UUID collision.
- * @param string $currentUserId The importing user's UID.
- *
- * @return array{importedDashboardCount:int, skippedDashboardCount:int,
- * errors:array>,
- * manifest:array,
- * status:string}
- *
- * @throws InvalidArgumentException When the archive is invalid or the
- * schema version is unsupported.
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- public function import(
- string $zipPath,
- bool $preserveUuids,
- string $currentUserId
- ): array {
- if (file_exists(filename: $zipPath) === false) {
- throw new InvalidArgumentException(
- message: 'Uploaded file is not a valid ZIP archive'
- );
- }
-
- $zip = new ZipArchive();
- if ($zip->open(filename: $zipPath, flags: ZipArchive::RDONLY) !== true) {
- throw new InvalidArgumentException(
- message: 'Uploaded file is not a valid ZIP archive'
- );
- }
-
- try {
- $manifest = $this->validateZipStructure(zip: $zip);
- $dashboards = $this->readDashboards(zip: $zip);
- $collisions = $this->detectUuidCollisions(dashboards: $dashboards);
-
- if ($preserveUuids === true && $collisions !== []) {
- return [
- 'status' => self::ERR_UUID_COLLISION,
- 'manifest' => $manifest,
- 'importedDashboardCount' => 0,
- 'skippedDashboardCount' => 0,
- 'errors' => $collisions,
- ];
- }
-
- $remapped = $this->remapUuids(
- dashboards: $dashboards,
- preserveUuids: $preserveUuids
- );
-
- return [
- 'status' => 'ok',
- 'manifest' => $manifest,
- ] + $this->importDashboardBatch(
- dashboards: $remapped,
- currentUserId: $currentUserId,
- preserveUuids: $preserveUuids
- );
- } finally {
- $zip->close();
- }//end try
- }//end import()
-
- /**
- * Validate the manifest and return its decoded payload.
- *
- * @param ZipArchive $zip The opened archive.
- *
- * @return array The decoded manifest.
- *
- * @throws InvalidArgumentException When the manifest is missing or invalid.
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- public function validateZipStructure(ZipArchive $zip): array
- {
- $raw = $zip->getFromName(name: 'manifest.json');
- if ($raw === false) {
- throw new InvalidArgumentException(
- message: 'manifest.json not found in archive'
- );
- }
-
- $decoded = json_decode(json: $raw, associative: true);
- if (is_array($decoded) === false) {
- throw new InvalidArgumentException(
- message: 'manifest.json is not valid JSON'
- );
- }
-
- $required = ['schemaVersion', 'scope'];
- $missing = [];
- foreach ($required as $field) {
- if (array_key_exists(key: $field, array: $decoded) === false) {
- $missing[] = $field;
- }
- }
-
- if ($missing !== []) {
- throw new InvalidArgumentException(
- message: 'Manifest missing required field(s): '.implode(separator: ', ', array: $missing)
- );
- }
-
- $version = $decoded['schemaVersion'];
- if (is_int($version) === false || $version !== self::SCHEMA_VERSION) {
- $head = 'Unsupported manifest schema version: '.(string) $version.'.';
- $tail = ' Only version '.(string) self::SCHEMA_VERSION.' is supported.';
- throw new InvalidArgumentException(message: ($head.$tail));
- }
-
- return $decoded;
- }//end validateZipStructure()
-
- /**
- * Re-map UUIDs across dashboards when `preserveUuids=false`.
- *
- * @param array> $dashboards The dashboards.
- * @param bool $preserveUuids Preserve flag.
- *
- * @return array> The (possibly remapped) dashboards.
- *
- * @spec openspec/specs/dashboard-export-import/spec.md
- */
- public function remapUuids(array $dashboards, bool $preserveUuids): array
- {
- if ($preserveUuids === true) {
- return $dashboards;
- }
-
- $uuidMap = [];
- foreach ($dashboards as $dashboard) {
- $original = (string) ($dashboard['uuid'] ?? '');
- if ($original === '') {
- continue;
- }
-
- $uuidMap[$original] = $this->generateUuidV4();
- }
-
- $result = [];
- foreach ($dashboards as $dashboard) {
- $original = (string) ($dashboard['uuid'] ?? '');
- if ($original !== '' && isset($uuidMap[$original]) === true) {
- $dashboard['uuid'] = $uuidMap[$original];
- }
-
- $parent = $dashboard['parentUuid'] ?? null;
- if (is_string($parent) === true && isset($uuidMap[$parent]) === true) {
- $dashboard['parentUuid'] = $uuidMap[$parent];
- }
-
- $result[] = $dashboard;
- }
-
- return $result;
- }//end remapUuids()
-
- /**
- * Persist a batch of remapped dashboards.
- *
- * Each dashboard runs in its own DB transaction so a single bad
- * record cannot poison the batch (REQ-EXIM-011).
- *
- * @param array> $dashboards Decoded dashboards.
- * @param string $currentUserId Importing UID.
- * @param bool $preserveUuids Preserve flag.
- *
- * @return array{importedDashboardCount:int, skippedDashboardCount:int,
- * errors:array>}
- */
- private function importDashboardBatch(
- array $dashboards,
- string $currentUserId,
- bool $preserveUuids
- ): array {
- $imported = 0;
- $skipped = 0;
- $errors = [];
-
- foreach ($dashboards as $payload) {
- $uuid = (string) ($payload['uuid'] ?? '');
- $missing = $this->validateDashboardPayload(payload: $payload);
- if ($missing !== null) {
- $skipped++;
- $errors[] = [
- 'type' => self::ERR_INVALID_DASHBOARD,
- 'uuid' => $uuid,
- 'message' => 'Missing required field: '.$missing,
- ];
- continue;
- }
-
- $this->db->beginTransaction();
- try {
- $dashboard = $this->buildEntity(
- payload: $payload,
- currentUserId: $currentUserId,
- preserveUuids: $preserveUuids
- );
-
- $persisted = $this->dashboardMapper->insert(entity: $dashboard);
-
- $widgets = $payload['widgets'] ?? [];
- if (is_array($widgets) === true) {
- foreach ($widgets as $widgetPayload) {
- if (is_array($widgetPayload) === false) {
- continue;
- }
-
- $placement = $this->buildPlacement(
- dashboardId: (int) $persisted->getId(),
- payload: $widgetPayload
- );
- $this->placementMapper->insert(entity: $placement);
- }
- }
-
- $this->db->commit();
- $imported++;
- } catch (Throwable $e) {
- $this->db->rollBack();
- $skipped++;
- $errors[] = [
- 'type' => self::ERR_INVALID_DASHBOARD,
- 'uuid' => $uuid,
- 'message' => 'Failed to import dashboard: '.$e->getMessage(),
- ];
- $this->logger->warning(
- message: 'Skipped dashboard during import',
- context: ['uuid' => $uuid, 'exception' => $e]
- );
- }//end try
- }//end foreach
-
- return [
- 'importedDashboardCount' => $imported,
- 'skippedDashboardCount' => $skipped,
- 'errors' => $errors,
- ];
- }//end importDashboardBatch()
-
- /**
- * Read decoded dashboards from the archive.
- *
- * @param ZipArchive $zip The open archive.
- *
- * @return array>
- */
- private function readDashboards(ZipArchive $zip): array
- {
- $dashboards = [];
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex(index: $i);
- if ($this->isDashboardEntry(name: $name) === false) {
- continue;
- }
-
- $raw = $zip->getFromIndex(index: $i);
- if ($raw === false) {
- continue;
- }
-
- $decoded = json_decode(json: $raw, associative: true);
- if (is_array($decoded) === false) {
- $dashboards[] = [
- '__corrupt__' => true,
- '__entry__' => $name,
- ];
- continue;
- }
-
- $dashboards[] = $decoded;
- }//end for
-
- return $dashboards;
- }//end readDashboards()
-
- /**
- * Detect UUID collisions against the live database.
- *
- * @param array> $dashboards Decoded dashboards.
- *
- * @return array> Collisions formatted for the response.
- */
- private function detectUuidCollisions(array $dashboards): array
- {
- $collisions = [];
- foreach ($dashboards as $dashboard) {
- $uuid = (string) ($dashboard['uuid'] ?? '');
- if ($uuid === '') {
- continue;
- }
-
- try {
- $this->dashboardMapper->findByUuid(uuid: $uuid);
- $msg = 'Dashboard with UUID '.$uuid.' already exists. Use preserveUuids=false to assign new UUIDs.';
- $collisions[] = [
- 'type' => self::ERR_UUID_COLLISION,
- 'dashboard' => $uuid,
- 'message' => $msg,
- ];
- } catch (DoesNotExistException) {
- // No collision — happy path.
- continue;
- }
- }
-
- return $collisions;
- }//end detectUuidCollisions()
-
- /**
- * Determine whether a ZIP entry is a per-dashboard JSON file.
- *
- * Rejects entries with directory-traversal segments to satisfy the
- * REQ-EXIM-005 / non-functional security requirement.
- *
- * @param string $name The ZIP entry name.
- *
- * @return bool True when the entry is a dashboard JSON file.
- */
- private function isDashboardEntry(string $name): bool
- {
- if (str_starts_with(haystack: $name, needle: 'dashboards/') === false) {
- return false;
- }
-
- if (str_ends_with(haystack: $name, needle: '.json') === false) {
- return false;
- }
-
- if (str_contains(haystack: $name, needle: '..') === true) {
- return false;
- }
-
- return true;
- }//end isDashboardEntry()
-
- /**
- * Validate that a dashboard payload has the minimum required fields.
- *
- * @param array $payload The decoded payload.
- *
- * @return string|null The first missing field name, or NULL when valid.
- */
- private function validateDashboardPayload(array $payload): ?string
- {
- if (isset($payload['__corrupt__']) === true) {
- return 'corrupt JSON payload';
- }
-
- foreach (['uuid', 'name', 'widgets'] as $required) {
- if (array_key_exists(key: $required, array: $payload) === false) {
- return $required;
- }
- }
-
- return null;
- }//end validateDashboardPayload()
-
- /**
- * Hydrate a Dashboard entity from a payload.
- *
- * @param array $payload The dashboard payload.
- * @param string $currentUserId The importing user.
- * @param bool $preserveUuids Preserve the source UUID.
- *
- * @return Dashboard The new entity (not yet persisted).
- *
- * @SuppressWarnings(PHPMD.CyclomaticComplexity)
- * Field-by-field guards are clearer than a map-driven setter.
- * @SuppressWarnings(PHPMD.NPathComplexity)
- */
- private function buildEntity(
- array $payload,
- string $currentUserId,
- bool $preserveUuids
- ): Dashboard {
- $dashboard = new Dashboard();
-
- $uuid = (string) $payload['uuid'];
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setUuid($uuid);
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setName((string) $payload['name']);
-
- if (array_key_exists(key: 'description', array: $payload) === true) {
- $description = null;
- if ($payload['description'] !== null) {
- $description = (string) $payload['description'];
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setDescription($description);
- }
-
- if (array_key_exists(key: 'icon', array: $payload) === true) {
- $icon = null;
- if ($payload['icon'] !== null) {
- $icon = (string) $payload['icon'];
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setIcon($icon);
- }
-
- $type = (string) ($payload['type'] ?? Dashboard::TYPE_USER);
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setType($type);
-
- // Imported personal dashboards are owned by the current user
- // unless we are preserving identity for a same-instance restore.
- $userId = (string) ($payload['userId'] ?? $currentUserId);
- if ($preserveUuids === false && $type === Dashboard::TYPE_USER) {
- $userId = $currentUserId;
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setUserId($userId);
-
- if (array_key_exists(key: 'gridColumns', array: $payload) === true) {
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setGridColumns((int) $payload['gridColumns']);
- }
-
- if (array_key_exists(key: 'permissionLevel', array: $payload) === true) {
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setPermissionLevel((string) $payload['permissionLevel']);
- }
-
- if (array_key_exists(key: 'targetGroups', array: $payload) === true
- && is_array($payload['targetGroups']) === true
- ) {
- $dashboard->setTargetGroupsArray(groups: $payload['targetGroups']);
- }
-
- if (array_key_exists(key: 'parentUuid', array: $payload) === true) {
- $parent = null;
- if ($payload['parentUuid'] !== null) {
- $parent = (string) $payload['parentUuid'];
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setParentUuid($parent);
- }
-
- if (array_key_exists(key: 'slug', array: $payload) === true) {
- $slug = null;
- if ($payload['slug'] !== null) {
- $slug = (string) $payload['slug'];
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setSlug($slug);
- }
-
- if (array_key_exists(key: 'sortOrder', array: $payload) === true) {
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setSortOrder((int) $payload['sortOrder']);
- }
-
- if (array_key_exists(key: 'publicationStatus', array: $payload) === true) {
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setPublicationStatus((string) $payload['publicationStatus']);
- }
-
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setIsActive(0);
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $dashboard->setIsDefault(0);
-
- return $dashboard;
- }//end buildEntity()
-
- /**
- * Hydrate a WidgetPlacement entity from a payload.
- *
- * @param int $dashboardId The freshly-inserted dashboard ID.
- * @param array $payload The widget payload.
- *
- * @return WidgetPlacement The placement entity (not yet persisted).
- */
- private function buildPlacement(
- int $dashboardId,
- array $payload
- ): WidgetPlacement {
- $placement = new WidgetPlacement();
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setDashboardId($dashboardId);
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setWidgetId((string) ($payload['widgetId'] ?? ''));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setGridX((int) ($payload['gridX'] ?? 0));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setGridY((int) ($payload['gridY'] ?? 0));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setGridWidth((int) ($payload['gridWidth'] ?? 4));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setGridHeight((int) ($payload['gridHeight'] ?? 4));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setIsVisible((int) ($payload['isVisible'] ?? 1));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setShowTitle((int) ($payload['showTitle'] ?? 1));
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setSortOrder((int) ($payload['sortOrder'] ?? 0));
-
- if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) {
- $placement->setStyleConfigArray(config: $payload['styleConfig']);
- }
-
- if (isset($payload['customTitle']) === true) {
- // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $placement->setCustomTitle((string) $payload['customTitle']);
- }
-
- return $placement;
- }//end buildPlacement()
-
- /**
- * Generate a v4 UUID for re-mapped imports.
- *
- * @return string The UUID.
- */
- private function generateUuidV4(): string
- {
- $bytes = random_bytes(length: 16);
- $bytes[6] = chr(codepoint: ord(character: $bytes[6]) & 0x0f | 0x40);
- $bytes[8] = chr(codepoint: ord(character: $bytes[8]) & 0x3f | 0x80);
- $hex = bin2hex(string: $bytes);
- return sprintf(
- '%s-%s-%s-%s-%s',
- substr(string: $hex, offset: 0, length: 8),
- substr(string: $hex, offset: 8, length: 4),
- substr(string: $hex, offset: 12, length: 4),
- substr(string: $hex, offset: 16, length: 4),
- substr(string: $hex, offset: 20, length: 12),
- );
- }//end generateUuidV4()
+class ImportService {
+ /**
+ * Maximum manifest schema version this service can read.
+ *
+ * @var integer
+ */
+ public const SCHEMA_VERSION = ExportService::SCHEMA_VERSION;
+
+ /**
+ * Public marker for a UUID-collision result so the controller can
+ * map it to HTTP 409.
+ *
+ * @var string
+ */
+ public const ERR_UUID_COLLISION = 'uuidCollision';
+
+ /**
+ * Public marker for a metadata-field type mismatch.
+ *
+ * @var string
+ */
+ public const ERR_FIELD_TYPE_MISMATCH = 'metadataFieldTypeMismatch';
+
+ /**
+ * Public marker for an invalid dashboard payload.
+ *
+ * @var string
+ */
+ public const ERR_INVALID_DASHBOARD = 'invalidDashboard';
+
+ /**
+ * Constructor.
+ *
+ * @param DashboardMapper $dashboardMapper Dashboard data mapper.
+ * @param WidgetPlacementMapper $placementMapper Widget placement mapper.
+ * @param IDBConnection $db Database connection.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ private readonly DashboardMapper $dashboardMapper,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly IDBConnection $db,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Import a ZIP archive.
+ *
+ * @param string $zipPath Path to the uploaded ZIP file.
+ * @param bool $preserveUuids When true, fail on UUID collision.
+ * @param string $currentUserId The importing user's UID.
+ *
+ * @return array{importedDashboardCount:int, skippedDashboardCount:int,
+ * errors:array>,
+ * manifest:array,
+ * status:string}
+ *
+ * @throws InvalidArgumentException When the archive is invalid or the
+ * schema version is unsupported.
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ public function import(
+ string $zipPath,
+ bool $preserveUuids,
+ string $currentUserId,
+ ): array {
+ if (file_exists(filename: $zipPath) === false) {
+ throw new InvalidArgumentException(
+ message: 'Uploaded file is not a valid ZIP archive'
+ );
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open(filename: $zipPath, flags: ZipArchive::RDONLY) !== true) {
+ throw new InvalidArgumentException(
+ message: 'Uploaded file is not a valid ZIP archive'
+ );
+ }
+
+ try {
+ $manifest = $this->validateZipStructure(zip: $zip);
+ $dashboards = $this->readDashboards(zip: $zip);
+ $collisions = $this->detectUuidCollisions(dashboards: $dashboards);
+
+ if ($preserveUuids === true && $collisions !== []) {
+ return [
+ 'status' => self::ERR_UUID_COLLISION,
+ 'manifest' => $manifest,
+ 'importedDashboardCount' => 0,
+ 'skippedDashboardCount' => 0,
+ 'errors' => $collisions,
+ ];
+ }
+
+ $remapped = $this->remapUuids(
+ dashboards: $dashboards,
+ preserveUuids: $preserveUuids
+ );
+
+ return [
+ 'status' => 'ok',
+ 'manifest' => $manifest,
+ ] + $this->importDashboardBatch(
+ dashboards: $remapped,
+ currentUserId: $currentUserId,
+ preserveUuids: $preserveUuids
+ );
+ } finally {
+ $zip->close();
+ }//end try
+ }//end import()
+
+ /**
+ * Validate the manifest and return its decoded payload.
+ *
+ * @param ZipArchive $zip The opened archive.
+ *
+ * @return array The decoded manifest.
+ *
+ * @throws InvalidArgumentException When the manifest is missing or invalid.
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ public function validateZipStructure(ZipArchive $zip): array {
+ $raw = $zip->getFromName(name: 'manifest.json');
+ if ($raw === false) {
+ throw new InvalidArgumentException(
+ message: 'manifest.json not found in archive'
+ );
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array($decoded) === false) {
+ throw new InvalidArgumentException(
+ message: 'manifest.json is not valid JSON'
+ );
+ }
+
+ $required = ['schemaVersion', 'scope'];
+ $missing = [];
+ foreach ($required as $field) {
+ if (array_key_exists(key: $field, array: $decoded) === false) {
+ $missing[] = $field;
+ }
+ }
+
+ if ($missing !== []) {
+ throw new InvalidArgumentException(
+ message: 'Manifest missing required field(s): ' . implode(separator: ', ', array: $missing)
+ );
+ }
+
+ $version = $decoded['schemaVersion'];
+ if (is_int($version) === false || $version !== self::SCHEMA_VERSION) {
+ $head = 'Unsupported manifest schema version: ' . (string)$version . '.';
+ $tail = ' Only version ' . (string)self::SCHEMA_VERSION . ' is supported.';
+ throw new InvalidArgumentException(message: ($head . $tail));
+ }
+
+ return $decoded;
+ }//end validateZipStructure()
+
+ /**
+ * Re-map UUIDs across dashboards when `preserveUuids=false`.
+ *
+ * @param array> $dashboards The dashboards.
+ * @param bool $preserveUuids Preserve flag.
+ *
+ * @return array> The (possibly remapped) dashboards.
+ *
+ * @spec openspec/specs/dashboard-export-import/spec.md
+ */
+ public function remapUuids(array $dashboards, bool $preserveUuids): array {
+ if ($preserveUuids === true) {
+ return $dashboards;
+ }
+
+ $uuidMap = [];
+ foreach ($dashboards as $dashboard) {
+ $original = (string)($dashboard['uuid'] ?? '');
+ if ($original === '') {
+ continue;
+ }
+
+ $uuidMap[$original] = $this->generateUuidV4();
+ }
+
+ $result = [];
+ foreach ($dashboards as $dashboard) {
+ $original = (string)($dashboard['uuid'] ?? '');
+ if ($original !== '' && isset($uuidMap[$original]) === true) {
+ $dashboard['uuid'] = $uuidMap[$original];
+ }
+
+ $parent = $dashboard['parentUuid'] ?? null;
+ if (is_string($parent) === true && isset($uuidMap[$parent]) === true) {
+ $dashboard['parentUuid'] = $uuidMap[$parent];
+ }
+
+ $result[] = $dashboard;
+ }
+
+ return $result;
+ }//end remapUuids()
+
+ /**
+ * Persist a batch of remapped dashboards.
+ *
+ * Each dashboard runs in its own DB transaction so a single bad
+ * record cannot poison the batch (REQ-EXIM-011).
+ *
+ * @param array> $dashboards Decoded dashboards.
+ * @param string $currentUserId Importing UID.
+ * @param bool $preserveUuids Preserve flag.
+ *
+ * @return array{importedDashboardCount:int, skippedDashboardCount:int,
+ * errors:array>}
+ */
+ private function importDashboardBatch(
+ array $dashboards,
+ string $currentUserId,
+ bool $preserveUuids,
+ ): array {
+ $imported = 0;
+ $skipped = 0;
+ $errors = [];
+
+ foreach ($dashboards as $payload) {
+ $uuid = (string)($payload['uuid'] ?? '');
+ $missing = $this->validateDashboardPayload(payload: $payload);
+ if ($missing !== null) {
+ $skipped++;
+ $errors[] = [
+ 'type' => self::ERR_INVALID_DASHBOARD,
+ 'uuid' => $uuid,
+ 'message' => 'Missing required field: ' . $missing,
+ ];
+ continue;
+ }
+
+ $this->db->beginTransaction();
+ try {
+ $dashboard = $this->buildEntity(
+ payload: $payload,
+ currentUserId: $currentUserId,
+ preserveUuids: $preserveUuids
+ );
+
+ $persisted = $this->dashboardMapper->insert(entity: $dashboard);
+
+ $widgets = $payload['widgets'] ?? [];
+ if (is_array($widgets) === true) {
+ foreach ($widgets as $widgetPayload) {
+ if (is_array($widgetPayload) === false) {
+ continue;
+ }
+
+ $placement = $this->buildPlacement(
+ dashboardId: (int)$persisted->getId(),
+ payload: $widgetPayload
+ );
+ $this->placementMapper->insert(entity: $placement);
+ }
+ }
+
+ $this->db->commit();
+ $imported++;
+ } catch (Throwable $e) {
+ $this->db->rollBack();
+ $skipped++;
+ $errors[] = [
+ 'type' => self::ERR_INVALID_DASHBOARD,
+ 'uuid' => $uuid,
+ 'message' => 'Failed to import dashboard: ' . $e->getMessage(),
+ ];
+ $this->logger->warning(
+ message: 'Skipped dashboard during import',
+ context: ['uuid' => $uuid, 'exception' => $e]
+ );
+ }//end try
+ }//end foreach
+
+ return [
+ 'importedDashboardCount' => $imported,
+ 'skippedDashboardCount' => $skipped,
+ 'errors' => $errors,
+ ];
+ }//end importDashboardBatch()
+
+ /**
+ * Read decoded dashboards from the archive.
+ *
+ * @param ZipArchive $zip The open archive.
+ *
+ * @return array>
+ */
+ private function readDashboards(ZipArchive $zip): array {
+ $dashboards = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = (string)$zip->getNameIndex(index: $i);
+ if ($this->isDashboardEntry(name: $name) === false) {
+ continue;
+ }
+
+ $raw = $zip->getFromIndex(index: $i);
+ if ($raw === false) {
+ continue;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array($decoded) === false) {
+ $dashboards[] = [
+ '__corrupt__' => true,
+ '__entry__' => $name,
+ ];
+ continue;
+ }
+
+ $dashboards[] = $decoded;
+ }//end for
+
+ return $dashboards;
+ }//end readDashboards()
+
+ /**
+ * Detect UUID collisions against the live database.
+ *
+ * @param array> $dashboards Decoded dashboards.
+ *
+ * @return array> Collisions formatted for the response.
+ */
+ private function detectUuidCollisions(array $dashboards): array {
+ $collisions = [];
+ foreach ($dashboards as $dashboard) {
+ $uuid = (string)($dashboard['uuid'] ?? '');
+ if ($uuid === '') {
+ continue;
+ }
+
+ try {
+ $this->dashboardMapper->findByUuid(uuid: $uuid);
+ $msg = 'Dashboard with UUID ' . $uuid . ' already exists. Use preserveUuids=false to assign new UUIDs.';
+ $collisions[] = [
+ 'type' => self::ERR_UUID_COLLISION,
+ 'dashboard' => $uuid,
+ 'message' => $msg,
+ ];
+ } catch (DoesNotExistException) {
+ // No collision — happy path.
+ continue;
+ }
+ }
+
+ return $collisions;
+ }//end detectUuidCollisions()
+
+ /**
+ * Determine whether a ZIP entry is a per-dashboard JSON file.
+ *
+ * Rejects entries with directory-traversal segments to satisfy the
+ * REQ-EXIM-005 / non-functional security requirement.
+ *
+ * @param string $name The ZIP entry name.
+ *
+ * @return bool True when the entry is a dashboard JSON file.
+ */
+ private function isDashboardEntry(string $name): bool {
+ if (str_starts_with(haystack: $name, needle: 'dashboards/') === false) {
+ return false;
+ }
+
+ if (str_ends_with(haystack: $name, needle: '.json') === false) {
+ return false;
+ }
+
+ if (str_contains(haystack: $name, needle: '..') === true) {
+ return false;
+ }
+
+ return true;
+ }//end isDashboardEntry()
+
+ /**
+ * Validate that a dashboard payload has the minimum required fields.
+ *
+ * @param array $payload The decoded payload.
+ *
+ * @return string|null The first missing field name, or NULL when valid.
+ */
+ private function validateDashboardPayload(array $payload): ?string {
+ if (isset($payload['__corrupt__']) === true) {
+ return 'corrupt JSON payload';
+ }
+
+ foreach (['uuid', 'name', 'widgets'] as $required) {
+ if (array_key_exists(key: $required, array: $payload) === false) {
+ return $required;
+ }
+ }
+
+ return null;
+ }//end validateDashboardPayload()
+
+ /**
+ * Hydrate a Dashboard entity from a payload.
+ *
+ * @param array $payload The dashboard payload.
+ * @param string $currentUserId The importing user.
+ * @param bool $preserveUuids Preserve the source UUID.
+ *
+ * @return Dashboard The new entity (not yet persisted).
+ */
+ private function buildEntity(
+ array $payload,
+ string $currentUserId,
+ bool $preserveUuids,
+ ): Dashboard {
+ $dashboard = new Dashboard();
+
+ $this->applyEntityIdentity(dashboard: $dashboard, payload: $payload);
+ $this->applyEntityOwnership(
+ dashboard: $dashboard,
+ payload: $payload,
+ currentUserId: $currentUserId,
+ preserveUuids: $preserveUuids
+ );
+ $this->applyEntityLayout(dashboard: $dashboard, payload: $payload);
+ $this->applyEntityPlacement(dashboard: $dashboard, payload: $payload);
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIsActive(0);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIsDefault(0);
+
+ return $dashboard;
+ }//end buildEntity()
+
+ /**
+ * Hydrate the dashboard's identity fields — uuid, name, description, icon.
+ *
+ * `uuid` and `name` are mandatory (already checked by
+ * {@see self::validateDashboardPayload()}); the other two are
+ * optional and accept an explicit null.
+ *
+ * @param Dashboard $dashboard The entity being hydrated.
+ * @param array $payload The dashboard payload.
+ *
+ * @return void
+ */
+ private function applyEntityIdentity(
+ Dashboard $dashboard,
+ array $payload,
+ ): void {
+ $uuid = (string)$payload['uuid'];
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setUuid($uuid);
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setName((string)$payload['name']);
+
+ if (array_key_exists(key: 'description', array: $payload) === true) {
+ $description = null;
+ if ($payload['description'] !== null) {
+ $description = (string)$payload['description'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setDescription($description);
+ }
+
+ if (array_key_exists(key: 'icon', array: $payload) === true) {
+ $icon = null;
+ if ($payload['icon'] !== null) {
+ $icon = (string)$payload['icon'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setIcon($icon);
+ }
+ }//end applyEntityIdentity()
+
+ /**
+ * Hydrate the dashboard's type and owning user.
+ *
+ * Imported personal dashboards are owned by the current user unless
+ * we are preserving identity for a same-instance restore.
+ *
+ * @param Dashboard $dashboard The entity being hydrated.
+ * @param array $payload The dashboard payload.
+ * @param string $currentUserId The importing user.
+ * @param bool $preserveUuids Preserve the source identity.
+ *
+ * @return void
+ */
+ private function applyEntityOwnership(
+ Dashboard $dashboard,
+ array $payload,
+ string $currentUserId,
+ bool $preserveUuids,
+ ): void {
+ $type = (string)($payload['type'] ?? Dashboard::TYPE_USER);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setType($type);
+
+ // Imported personal dashboards are owned by the current user
+ // unless we are preserving identity for a same-instance restore.
+ $userId = (string)($payload['userId'] ?? $currentUserId);
+ if ($preserveUuids === false && $type === Dashboard::TYPE_USER) {
+ $userId = $currentUserId;
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setUserId($userId);
+ }//end applyEntityOwnership()
+
+ /**
+ * Hydrate the grid and access-control fields.
+ *
+ * `targetGroups` is only applied when it really is an array — a
+ * malformed export must not blow up the whole import.
+ *
+ * @param Dashboard $dashboard The entity being hydrated.
+ * @param array $payload The dashboard payload.
+ *
+ * @return void
+ */
+ private function applyEntityLayout(
+ Dashboard $dashboard,
+ array $payload,
+ ): void {
+ if (array_key_exists(key: 'gridColumns', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setGridColumns((int)$payload['gridColumns']);
+ }
+
+ if (array_key_exists(key: 'permissionLevel', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setPermissionLevel((string)$payload['permissionLevel']);
+ }
+
+ if (array_key_exists(key: 'targetGroups', array: $payload) === true
+ && is_array($payload['targetGroups']) === true
+ ) {
+ $dashboard->setTargetGroupsArray(groups: $payload['targetGroups']);
+ }
+ }//end applyEntityLayout()
+
+ /**
+ * Hydrate the tree-placement and publication fields.
+ *
+ * @param Dashboard $dashboard The entity being hydrated.
+ * @param array $payload The dashboard payload.
+ *
+ * @return void
+ */
+ private function applyEntityPlacement(
+ Dashboard $dashboard,
+ array $payload,
+ ): void {
+ if (array_key_exists(key: 'parentUuid', array: $payload) === true) {
+ $parent = null;
+ if ($payload['parentUuid'] !== null) {
+ $parent = (string)$payload['parentUuid'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setParentUuid($parent);
+ }
+
+ if (array_key_exists(key: 'slug', array: $payload) === true) {
+ $slug = null;
+ if ($payload['slug'] !== null) {
+ $slug = (string)$payload['slug'];
+ }
+
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setSlug($slug);
+ }
+
+ if (array_key_exists(key: 'sortOrder', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setSortOrder((int)$payload['sortOrder']);
+ }
+
+ if (array_key_exists(key: 'publicationStatus', array: $payload) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $dashboard->setPublicationStatus((string)$payload['publicationStatus']);
+ }
+ }//end applyEntityPlacement()
+
+ /**
+ * Hydrate a WidgetPlacement entity from a payload.
+ *
+ * @param int $dashboardId The freshly-inserted dashboard ID.
+ * @param array $payload The widget payload.
+ *
+ * @return WidgetPlacement The placement entity (not yet persisted).
+ */
+ private function buildPlacement(
+ int $dashboardId,
+ array $payload,
+ ): WidgetPlacement {
+ $placement = new WidgetPlacement();
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setDashboardId($dashboardId);
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setWidgetId((string)($payload['widgetId'] ?? ''));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridX((int)($payload['gridX'] ?? 0));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridY((int)($payload['gridY'] ?? 0));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridWidth((int)($payload['gridWidth'] ?? 4));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setGridHeight((int)($payload['gridHeight'] ?? 4));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setIsVisible((int)($payload['isVisible'] ?? 1));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setShowTitle((int)($payload['showTitle'] ?? 1));
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setSortOrder((int)($payload['sortOrder'] ?? 0));
+
+ if (isset($payload['styleConfig']) === true && is_array($payload['styleConfig']) === true) {
+ $placement->setStyleConfigArray(config: $payload['styleConfig']);
+ }
+
+ if (isset($payload['customTitle']) === true) {
+ // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $placement->setCustomTitle((string)$payload['customTitle']);
+ }
+
+ return $placement;
+ }//end buildPlacement()
+
+ /**
+ * Generate a v4 UUID for re-mapped imports.
+ *
+ * @return string The UUID.
+ */
+ private function generateUuidV4(): string {
+ $bytes = random_bytes(length: 16);
+ $bytes[6] = chr(codepoint: ord(character: $bytes[6]) & 0x0f | 0x40);
+ $bytes[8] = chr(codepoint: ord(character: $bytes[8]) & 0x3f | 0x80);
+ $hex = bin2hex(string: $bytes);
+ return sprintf(
+ '%s-%s-%s-%s-%s',
+ substr(string: $hex, offset: 0, length: 8),
+ substr(string: $hex, offset: 8, length: 4),
+ substr(string: $hex, offset: 12, length: 4),
+ substr(string: $hex, offset: 16, length: 4),
+ substr(string: $hex, offset: 20, length: 12),
+ );
+ }//end generateUuidV4()
}//end class
diff --git a/lib/Service/InitialState/Page.php b/lib/Service/InitialState/Page.php
index d84b662b9..e6a205925 100644
--- a/lib/Service/InitialState/Page.php
+++ b/lib/Service/InitialState/Page.php
@@ -20,8 +20,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -31,8 +31,7 @@
/**
* Page identifier for the initial-state contract (REQ-INIT-001).
*/
-enum Page: string
-{
- case WORKSPACE = 'workspace';
- case ADMIN = 'admin';
+enum Page: string {
+ case WORKSPACE = 'workspace';
+ case ADMIN = 'admin';
}//end enum
diff --git a/lib/Service/InitialStateBuilder.php b/lib/Service/InitialStateBuilder.php
index d4f672588..b849806c1 100644
--- a/lib/Service/InitialStateBuilder.php
+++ b/lib/Service/InitialStateBuilder.php
@@ -51,8 +51,8 @@
*
* @link https://conduction.nl/openspec/initial-state-contract REQ-INIT-002
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -65,355 +65,357 @@
/**
* Typed builder for the per-page initial-state payload (REQ-INIT-001, REQ-INIT-002).
- *
- * @SuppressWarnings(PHPMD.TooManyPublicMethods) Twelve typed setters mirror the contract.
- * @SuppressWarnings(PHPMD.TooManyMethods) Same.
*/
-class InitialStateBuilder
-{
- /**
- * Schema version stamped onto every payload under `_schemaVersion`.
- *
- * Bump in the same commit that changes any per-page key set. The JS
- * reader in `src/utils/loadInitialState.js` MUST keep its constant in
- * lockstep — version drift surfaces as a console warning at runtime
- * (REQ-INIT-002).
- *
- * @var integer
- */
- public const INITIAL_STATE_SCHEMA_VERSION = 2;
+class InitialStateBuilder {
+ /**
+ * Schema version stamped onto every payload under `_schemaVersion`.
+ *
+ * Bump in the same commit that changes any per-page key set. The JS
+ * reader in `src/utils/loadInitialState.js` MUST keep its constant in
+ * lockstep — version drift surfaces as a console warning at runtime
+ * (REQ-INIT-002).
+ *
+ * @var integer
+ */
+ public const INITIAL_STATE_SCHEMA_VERSION = 2;
+
+ /**
+ * Reserved payload key carrying the schema version.
+ *
+ * @var string
+ */
+ public const KEY_SCHEMA_VERSION = '_schemaVersion';
- /**
- * Reserved payload key carrying the schema version.
- *
- * @var string
- */
- public const KEY_SCHEMA_VERSION = '_schemaVersion';
+ /**
+ * Required key set per page. Keys MUST exactly match the spec's Data
+ * Model — adding or removing a key is a spec change (REQ-INIT-002).
+ *
+ * @var array>
+ */
+ private const REQUIRED_KEYS = [
+ Page::WORKSPACE->value => [
+ 'widgets',
+ 'layout',
+ 'primaryGroup',
+ 'primaryGroupName',
+ 'isAdmin',
+ 'activeDashboardId',
+ 'dashboardSource',
+ 'groupDashboards',
+ 'userDashboards',
+ 'allowUserDashboards',
+ // REQ-RFP-010: list of widget IDs the caller is permitted to see.
+ // null = no restriction configured (backwards-compat).
+ 'allowedWidgets',
+ ],
+ Page::ADMIN->value => [
+ 'allGroups',
+ 'configuredGroups',
+ 'widgets',
+ 'allowUserDashboards',
+ 'linkCreateFileExtensions',
+ ],
+ ];
- /**
- * Required key set per page. Keys MUST exactly match the spec's Data
- * Model — adding or removing a key is a spec change (REQ-INIT-002).
- *
- * @var array>
- */
- private const REQUIRED_KEYS = [
- Page::WORKSPACE->value => [
- 'widgets',
- 'layout',
- 'primaryGroup',
- 'primaryGroupName',
- 'isAdmin',
- 'activeDashboardId',
- 'dashboardSource',
- 'groupDashboards',
- 'userDashboards',
- 'allowUserDashboards',
- // REQ-RFP-010: list of widget IDs the caller is permitted to see.
- // null = no restriction configured (backwards-compat).
- 'allowedWidgets',
- ],
- Page::ADMIN->value => [
- 'allGroups',
- 'configuredGroups',
- 'widgets',
- 'allowUserDashboards',
- 'linkCreateFileExtensions',
- ],
- ];
+ /**
+ * Buffered key/value pairs awaiting apply().
+ *
+ * @var array
+ */
+ private array $values = [];
- /**
- * Buffered key/value pairs awaiting apply().
- *
- * @var array
- */
- private array $values = [];
+ /**
+ * Constructor.
+ *
+ * @param IInitialState $initialState The Nextcloud initial-state service.
+ * @param Page $page Destination page.
+ */
+ public function __construct(
+ private readonly IInitialState $initialState,
+ private readonly Page $page,
+ ) {
+ }//end __construct()
- /**
- * Constructor.
- *
- * @param IInitialState $initialState The Nextcloud initial-state service.
- * @param Page $page Destination page.
- */
- public function __construct(
- private readonly IInitialState $initialState,
- private readonly Page $page,
- ) {
- }//end __construct()
+ /**
+ * Set the dashboard widgets list (workspace + admin).
+ *
+ * @param array $widgets Widget descriptors `[{id, title, iconClass, iconUrl, url}, ...]`.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setWidgets(array $widgets): self {
+ $this->values['widgets'] = $widgets;
+ return $this;
+ }//end setWidgets()
- /**
- * Set the dashboard widgets list (workspace + admin).
- *
- * @param array $widgets Widget descriptors `[{id, title, iconClass, iconUrl, url}, ...]`.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setWidgets(array $widgets): self
- {
- $this->values['widgets'] = $widgets;
- return $this;
- }//end setWidgets()
+ /**
+ * Set the active-dashboard layout (workspace).
+ *
+ * @param array $layout WidgetPlacement rows for the active dashboard.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setLayout(array $layout): self {
+ $this->values['layout'] = $layout;
+ return $this;
+ }//end setLayout()
- /**
- * Set the active-dashboard layout (workspace).
- *
- * @param array $layout WidgetPlacement rows for the active dashboard.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setLayout(array $layout): self
- {
- $this->values['layout'] = $layout;
- return $this;
- }//end setLayout()
+ /**
+ * Set the primary group id (workspace).
+ *
+ * @param string $primaryGroup Resolved primary group id (e.g. 'default').
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setPrimaryGroup(string $primaryGroup): self {
+ $this->values['primaryGroup'] = $primaryGroup;
+ return $this;
+ }//end setPrimaryGroup()
- /**
- * Set the primary group id (workspace).
- *
- * @param string $primaryGroup Resolved primary group id (e.g. 'default').
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setPrimaryGroup(string $primaryGroup): self
- {
- $this->values['primaryGroup'] = $primaryGroup;
- return $this;
- }//end setPrimaryGroup()
+ /**
+ * Set the primary group display name (workspace).
+ *
+ * @param string $primaryGroupName Human-readable primary group name.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setPrimaryGroupName(string $primaryGroupName): self {
+ $this->values['primaryGroupName'] = $primaryGroupName;
+ return $this;
+ }//end setPrimaryGroupName()
- /**
- * Set the primary group display name (workspace).
- *
- * @param string $primaryGroupName Human-readable primary group name.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setPrimaryGroupName(string $primaryGroupName): self
- {
- $this->values['primaryGroupName'] = $primaryGroupName;
- return $this;
- }//end setPrimaryGroupName()
+ /**
+ * Set the is-admin flag (workspace).
+ *
+ * @param bool $isAdmin True when the user belongs to the admin group.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setIsAdmin(bool $isAdmin): self {
+ $this->values['isAdmin'] = $isAdmin;
+ return $this;
+ }//end setIsAdmin()
- /**
- * Set the is-admin flag (workspace).
- *
- * @param bool $isAdmin True when the user belongs to the admin group.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setIsAdmin(bool $isAdmin): self
- {
- $this->values['isAdmin'] = $isAdmin;
- return $this;
- }//end setIsAdmin()
+ /**
+ * Set the active dashboard id (workspace).
+ *
+ * @param string $activeDashboardId Id of the currently active dashboard.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setActiveDashboardId(string $activeDashboardId): self {
+ $this->values['activeDashboardId'] = $activeDashboardId;
+ return $this;
+ }//end setActiveDashboardId()
- /**
- * Set the active dashboard id (workspace).
- *
- * @param string $activeDashboardId Id of the currently active dashboard.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setActiveDashboardId(string $activeDashboardId): self
- {
- $this->values['activeDashboardId'] = $activeDashboardId;
- return $this;
- }//end setActiveDashboardId()
+ /**
+ * Set the dashboard source (workspace).
+ *
+ * Valid values: 'user' | 'group' | 'default'. Drives canEdit on the
+ * runtime shell (REQ-RTS-006).
+ *
+ * @param string $dashboardSource One of 'user', 'group', 'default'.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setDashboardSource(string $dashboardSource): self {
+ $this->values['dashboardSource'] = $dashboardSource;
+ return $this;
+ }//end setDashboardSource()
- /**
- * Set the dashboard source (workspace).
- *
- * Valid values: 'user' | 'group' | 'default'. Drives canEdit on the
- * runtime shell (REQ-RTS-006).
- *
- * @param string $dashboardSource One of 'user', 'group', 'default'.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setDashboardSource(string $dashboardSource): self
- {
- $this->values['dashboardSource'] = $dashboardSource;
- return $this;
- }//end setDashboardSource()
+ /**
+ * Set the visible group dashboards (workspace).
+ *
+ * @param array $groupDashboards List of group-scope dashboards visible to the user.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setGroupDashboards(array $groupDashboards): self {
+ $this->values['groupDashboards'] = $groupDashboards;
+ return $this;
+ }//end setGroupDashboards()
- /**
- * Set the visible group dashboards (workspace).
- *
- * @param array $groupDashboards List of group-scope dashboards visible to the user.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setGroupDashboards(array $groupDashboards): self
- {
- $this->values['groupDashboards'] = $groupDashboards;
- return $this;
- }//end setGroupDashboards()
+ /**
+ * Set the user (personal) dashboards (workspace).
+ *
+ * @param array $userDashboards List of personal dashboards owned by the user.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setUserDashboards(array $userDashboards): self {
+ $this->values['userDashboards'] = $userDashboards;
+ return $this;
+ }//end setUserDashboards()
- /**
- * Set the user (personal) dashboards (workspace).
- *
- * @param array $userDashboards List of personal dashboards owned by the user.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setUserDashboards(array $userDashboards): self
- {
- $this->values['userDashboards'] = $userDashboards;
- return $this;
- }//end setUserDashboards()
+ /**
+ * Set the allow-user-dashboards flag (workspace + admin).
+ *
+ * @param bool $allowUserDashboards Current value of the admin flag.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setAllowUserDashboards(bool $allowUserDashboards): self {
+ $this->values['allowUserDashboards'] = $allowUserDashboards;
+ return $this;
+ }//end setAllowUserDashboards()
- /**
- * Set the allow-user-dashboards flag (workspace + admin).
- *
- * @param bool $allowUserDashboards Current value of the admin flag.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setAllowUserDashboards(bool $allowUserDashboards): self
- {
- $this->values['allowUserDashboards'] = $allowUserDashboards;
- return $this;
- }//end setAllowUserDashboards()
+ /**
+ * Set the list of widget IDs the caller is permitted to see (workspace).
+ * `null` means no restriction is configured — legacy behaviour where the
+ * full widget catalogue is shown (REQ-RFP-009 / REQ-RFP-010).
+ *
+ * @param array|null $allowedWidgets List of widget IDs, or null.
+ *
+ * @return self
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setAllowedWidgets(?array $allowedWidgets): self {
+ $this->values['allowedWidgets'] = $allowedWidgets;
+ return $this;
+ }//end setAllowedWidgets()
- /**
- * Set the list of widget IDs the caller is permitted to see (workspace).
- * `null` means no restriction is configured — legacy behaviour where the
- * full widget catalogue is shown (REQ-RFP-009 / REQ-RFP-010).
- *
- * @param array|null $allowedWidgets List of widget IDs, or null.
- *
- * @return self
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setAllowedWidgets(?array $allowedWidgets): self
- {
- $this->values['allowedWidgets'] = $allowedWidgets;
- return $this;
- }//end setAllowedWidgets()
+ /**
+ * Set the canonical slug-chain path for the active dashboard
+ * (workspace).
+ *
+ * Read by the frontend on mount to bring `window.location.pathname`
+ * in line with whichever dashboard was actually rendered — handles
+ * the renamed-parent / stale-bookmark cases by replacing the URL
+ * in-place via `history.replaceState`. Empty string when no
+ * dashboard is active (the page renders the empty state).
+ *
+ * @param string $deepLinkPath Canonical path or '' when none.
+ *
+ * @return self
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setDeepLinkPath(string $deepLinkPath): self {
+ $this->values['deepLinkPath'] = $deepLinkPath;
+ return $this;
+ }//end setDeepLinkPath()
- /**
- * Set the canonical slug-chain path for the active dashboard
- * (workspace).
- *
- * Read by the frontend on mount to bring `window.location.pathname`
- * in line with whichever dashboard was actually rendered — handles
- * the renamed-parent / stale-bookmark cases by replacing the URL
- * in-place via `history.replaceState`. Empty string when no
- * dashboard is active (the page renders the empty state).
- *
- * @param string $deepLinkPath Canonical path or '' when none.
- *
- * @return self
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setDeepLinkPath(string $deepLinkPath): self
- {
- $this->values['deepLinkPath'] = $deepLinkPath;
- return $this;
- }//end setDeepLinkPath()
+ /**
+ * Set the admin-configured quick-search no-match fallback target
+ * (workspace). `'none'` | `'unified-search'` | a validated `https`
+ * URL template containing `{query}`.
+ *
+ * Optional key — mirrors {@see self::setDeepLinkPath()}'s pattern:
+ * NOT in {@see self::REQUIRED_KEYS}, so an older deploy that hasn't
+ * called this setter yet still passes {@see self::apply()}'s
+ * required-key check, and the JS reader's `'none'` default keeps the
+ * frontend typed either way (tile-quick-search REQ-QSEARCH-004).
+ *
+ * @param string $fallbackTarget The current fallback-target setting value.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/tile-quick-search/spec.md
+ */
+ public function setQuicksearchFallbackTarget(string $fallbackTarget): self {
+ $this->values['quicksearchFallbackTarget'] = $fallbackTarget;
+ return $this;
+ }//end setQuicksearchFallbackTarget()
- /**
- * Set every Nextcloud group (admin).
- *
- * @param array $allGroups List of `{id, displayName}` pairs.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setAllGroups(array $allGroups): self
- {
- $this->values['allGroups'] = $allGroups;
- return $this;
- }//end setAllGroups()
+ /**
+ * Set every Nextcloud group (admin).
+ *
+ * @param array $allGroups List of `{id, displayName}` pairs.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setAllGroups(array $allGroups): self {
+ $this->values['allGroups'] = $allGroups;
+ return $this;
+ }//end setAllGroups()
- /**
- * Set the configured (ordered) group ids (admin).
- *
- * @param array $configuredGroups Ordered list of group ids.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setConfiguredGroups(array $configuredGroups): self
- {
- $this->values['configuredGroups'] = $configuredGroups;
- return $this;
- }//end setConfiguredGroups()
+ /**
+ * Set the configured (ordered) group ids (admin).
+ *
+ * @param array $configuredGroups Ordered list of group ids.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setConfiguredGroups(array $configuredGroups): self {
+ $this->values['configuredGroups'] = $configuredGroups;
+ return $this;
+ }//end setConfiguredGroups()
- /**
- * Set the link-button-widget createFile extension allow-list (admin).
- *
- * Backed by the `link_create_file_extensions` admin setting
- * (REQ-LBN-004). When the admin has not customised the list the
- * caller passes the default values, so the renderer always sees
- * a non-empty array.
- *
- * @param array $extensions Lowercase extensions without dots,
- * e.g. `["txt","md","docx"]`.
- *
- * @return self Fluent.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function setLinkCreateFileExtensions(array $extensions): self
- {
- $this->values['linkCreateFileExtensions'] = $extensions;
- return $this;
- }//end setLinkCreateFileExtensions()
+ /**
+ * Set the link-button-widget createFile extension allow-list (admin).
+ *
+ * Backed by the `link_create_file_extensions` admin setting
+ * (REQ-LBN-004). When the admin has not customised the list the
+ * caller passes the default values, so the renderer always sees
+ * a non-empty array.
+ *
+ * @param array $extensions Lowercase extensions without dots,
+ * e.g. `["txt","md","docx"]`.
+ *
+ * @return self Fluent.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function setLinkCreateFileExtensions(array $extensions): self {
+ $this->values['linkCreateFileExtensions'] = $extensions;
+ return $this;
+ }//end setLinkCreateFileExtensions()
- /**
- * Validate required keys then push every buffered pair plus the
- * schema version to {@see IInitialState::provideInitialState()}.
- *
- * @return void
- *
- * @throws MissingInitialStateException When any required key for the
- * page was not set.
- *
- * @spec openspec/specs/initial-state-contract/spec.md
- */
- public function apply(): void
- {
- $required = self::REQUIRED_KEYS[$this->page->value];
+ /**
+ * Validate required keys then push every buffered pair plus the
+ * schema version to {@see IInitialState::provideInitialState()}.
+ *
+ * @return void
+ *
+ * @throws MissingInitialStateException When any required key for the
+ * page was not set.
+ *
+ * @spec openspec/specs/initial-state-contract/spec.md
+ */
+ public function apply(): void {
+ $required = self::REQUIRED_KEYS[$this->page->value];
- foreach ($required as $key) {
- if (array_key_exists($key, $this->values) === false) {
- throw new MissingInitialStateException(
- page: $this->page->value,
- key: $key
- );
- }
- }
+ foreach ($required as $key) {
+ if (array_key_exists($key, $this->values) === false) {
+ throw new MissingInitialStateException(
+ page: $this->page->value,
+ key: $key
+ );
+ }
+ }
- foreach ($this->values as $key => $value) {
- $this->initialState->provideInitialState($key, $value);
- }
+ foreach ($this->values as $key => $value) {
+ $this->initialState->provideInitialState($key, $value);
+ }
- $this->initialState->provideInitialState(
- self::KEY_SCHEMA_VERSION,
- self::INITIAL_STATE_SCHEMA_VERSION
- );
- }//end apply()
+ $this->initialState->provideInitialState(
+ self::KEY_SCHEMA_VERSION,
+ self::INITIAL_STATE_SCHEMA_VERSION
+ );
+ }//end apply()
}//end class
diff --git a/lib/Service/KioskService.php b/lib/Service/KioskService.php
index 1cad4245d..f58b0b944 100644
--- a/lib/Service/KioskService.php
+++ b/lib/Service/KioskService.php
@@ -36,7 +36,6 @@
use OCA\LaunchPad\Db\KioskPlaylist;
use OCA\LaunchPad\Db\KioskPlaylistMapper;
use OCA\LaunchPad\Exception\PlaylistNotFoundException;
-use OCA\LaunchPad\Exception\ShareNotFoundException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\IGroupManager;
@@ -46,390 +45,379 @@
/**
* Service for kiosk-playlist lifecycle management and public render.
*
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
- *
* @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
*/
-class KioskService
-{
-
- /**
- * Minimum dwell time per entry, in seconds.
- *
- * @var integer
- */
- public const DWELL_MIN = 10;
-
- /**
- * Maximum dwell time per entry, in seconds (24 hours).
- *
- * @var integer
- */
- public const DWELL_MAX = 86400;
-
- /**
- * Minimum in-place refresh interval, in seconds.
- *
- * @var integer
- */
- public const REFRESH_MIN = 30;
-
- /**
- * Maximum in-place refresh interval, in seconds (24 hours).
- *
- * @var integer
- */
- public const REFRESH_MAX = 86400;
-
- /**
- * Default in-place refresh interval, in seconds.
- *
- * @var integer
- */
- public const REFRESH_DEFAULT = 300;
-
- /**
- * Constructor.
- *
- * @param KioskPlaylistMapper $playlistMapper Mapper for kiosk playlists.
- * @param DashboardMapper $dashMapper Dashboard mapper for existence/ownership.
- * @param PublicShareService $shareService Reused owner-or-admin authorization rule.
- * @param IGroupManager $groupManager NC group manager for admin scoping.
- * @param ISecureRandom $secureRandom CSPRNG for token generation.
- * @param LoggerInterface $logger PSR-3 logger.
- */
- public function __construct(
- private readonly KioskPlaylistMapper $playlistMapper,
- private readonly DashboardMapper $dashMapper,
- private readonly PublicShareService $shareService,
- private readonly IGroupManager $groupManager,
- private readonly ISecureRandom $secureRandom,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Create a new kiosk playlist.
- *
- * Validates owner-or-admin permission for every referenced dashboard
- * (REQ-KIOSK-002): a single unauthorized dashboard rejects the whole
- * request. Dwell and refresh values are clamped before storage.
- *
- * @param string $name Playlist name.
- * @param array $entries Raw entries [{dashboardUuid, dwellSeconds}, ...].
- * @param int $refresh Requested refresh interval in seconds.
- * @param string $callerId User ID of the creating user.
- *
- * @return KioskPlaylist The new playlist with URL populated.
- *
- * @throws OCSForbiddenException When the caller may not share a referenced dashboard.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
- */
- public function createPlaylist(
- string $name,
- array $entries,
- int $refresh,
- string $callerId
- ): KioskPlaylist {
- $normalised = $this->validateAndNormaliseEntries(
- entries: $entries,
- callerId: $callerId
- );
-
- $playlist = new KioskPlaylist();
- // phpcs:disable CustomSn.Functions.NamedParameters -- Entity magic __call breaks with named args.
- $playlist->setName($name);
- $playlist->setToken(
- $this->secureRandom->generate(
- length: 64,
- characters: ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS
- )
- );
- $playlist->setEntries((string) json_encode($normalised));
- $playlist->setRefreshSeconds($this->clampRefresh(refresh: $refresh));
- $playlist->setCreatedBy($callerId);
- $playlist->setCreatedAt((new DateTime())->format('Y-m-d H:i:s'));
- // phpcs:enable CustomSn.Functions.NamedParameters
-
- $saved = $this->playlistMapper->insert(entity: $playlist);
-
- $this->logger->debug(
- message: sprintf('launchpad: kiosk playlist created by %s', $callerId),
- context: ['app' => 'launchpad']
- );
-
- return $saved;
- }//end createPlaylist()
-
- /**
- * Update an existing kiosk playlist.
- *
- * Re-validates owner-or-admin permission for every referenced dashboard
- * (REQ-KIOSK-002). On any unauthorized entry the playlist is left
- * unchanged and OCSForbiddenException is thrown.
- *
- * @param int $id Playlist primary key.
- * @param string $name New playlist name.
- * @param array $entries Raw entries [{dashboardUuid, dwellSeconds}, ...].
- * @param int $refresh Requested refresh interval in seconds.
- * @param string $callerId Caller user ID.
- *
- * @return KioskPlaylist The updated playlist with URL populated.
- *
- * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
- * @throws OCSForbiddenException When the caller may not edit it or share a dashboard.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
- */
- public function updatePlaylist(
- int $id,
- string $name,
- array $entries,
- int $refresh,
- string $callerId
- ): KioskPlaylist {
- $playlist = $this->resolveOwnedPlaylist(id: $id, callerId: $callerId);
-
- $normalised = $this->validateAndNormaliseEntries(
- entries: $entries,
- callerId: $callerId
- );
-
- // phpcs:disable CustomSn.Functions.NamedParameters
- $playlist->setName($name);
- $playlist->setEntries((string) json_encode($normalised));
- $playlist->setRefreshSeconds($this->clampRefresh(refresh: $refresh));
- // phpcs:enable CustomSn.Functions.NamedParameters
-
- $saved = $this->playlistMapper->update(entity: $playlist);
- return $this->playlistMapper->findById(id: (int) $saved->getId());
- }//end updatePlaylist()
-
- /**
- * List playlists visible to the caller.
- *
- * Regular users see their own active playlists; admins see all active
- * playlists (REQ-KIOSK-002).
- *
- * @param string $callerId Caller user ID.
- *
- * @return KioskPlaylist[]
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
- */
- public function listPlaylists(string $callerId): array
- {
- if ($this->groupManager->isAdmin(userId: $callerId) === true) {
- return $this->playlistMapper->findAllActive();
- }
-
- return $this->playlistMapper->findByCreator(createdBy: $callerId);
- }//end listPlaylists()
-
- /**
- * Soft-revoke a playlist.
- *
- * Owner-or-admin only. Idempotent — revoking an already-revoked playlist
- * is a no-op once the caller is authorized.
- *
- * @param int $id Playlist primary key.
- * @param string $callerId Caller user ID.
- *
- * @return void
- *
- * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
- * @throws OCSForbiddenException When the caller is neither owner nor admin.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
- */
- public function revokePlaylist(int $id, string $callerId): void
- {
- $playlist = $this->resolveOwnedPlaylist(id: $id, callerId: $callerId);
- $this->playlistMapper->softRevoke(id: (int) $playlist->getId());
- }//end revokePlaylist()
-
- /**
- * Render a playlist for anonymous wall-display access.
- *
- * Returns the playlist descriptor plus the read-only render payload for
- * each entry whose dashboard still exists. Entries referencing a deleted
- * dashboard are omitted (no error, no placeholder slot) per
- * REQ-KIOSK-003. Unknown or revoked tokens throw PlaylistNotFoundException
- * (HTTP 404) with no existence leak.
- *
- * @param string $token The playlist token.
- *
- * @return array{playlist: array, entries: array}
- *
- * @throws PlaylistNotFoundException When the token is unknown or revoked.
- *
- * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
- */
- public function renderPlaylist(string $token): array
- {
- try {
- $playlist = $this->playlistMapper->findByToken(token: $token);
- } catch (DoesNotExistException) {
- throw new PlaylistNotFoundException();
- }
-
- $rendered = [];
- foreach ($playlist->getEntriesArray() as $entry) {
- try {
- $dashboard = $this->dashMapper->findByUuid(
- uuid: $entry['dashboardUuid']
- );
- } catch (DoesNotExistException) {
- // Dashboard deleted since the playlist was created — skip it.
- continue;
- }
-
- $rendered[] = [
- 'dwellSeconds' => $entry['dwellSeconds'],
- 'dashboard' => $this->publicDashboardPayload(dashboard: $dashboard),
- ];
- }
-
- $descriptor = $playlist->jsonSerialize();
- // The token IS the public credential already in the URL; the
- // createdBy attribution is not needed by an anonymous renderer.
- unset($descriptor['createdBy']);
-
- return [
- 'playlist' => $descriptor,
- 'entries' => $rendered,
- ];
- }//end renderPlaylist()
-
- /**
- * Validate and normalise raw playlist entries.
- *
- * Each entry MUST reference an existing dashboard the caller may share
- * (owner-or-admin); a single failure rejects the whole request. Dwell
- * times are clamped to [DWELL_MIN, DWELL_MAX].
- *
- * @param array $entries Raw entries.
- * @param string $callerId Caller user ID.
- *
- * @return array
- *
- * @throws OCSForbiddenException When a referenced dashboard is missing or unauthorized.
- */
- private function validateAndNormaliseEntries(array $entries, string $callerId): array
- {
- $normalised = [];
-
- foreach ($entries as $entry) {
- $uuid = '';
- if (is_array($entry) === true && isset($entry['dashboardUuid']) === true) {
- $uuid = (string) $entry['dashboardUuid'];
- }
-
- if ($uuid === '') {
- throw new OCSForbiddenException('Invalid playlist entry');
- }
-
- try {
- $dashboard = $this->dashMapper->findByUuid(uuid: $uuid);
- } catch (DoesNotExistException) {
- // Treat a missing dashboard as a permission failure to avoid
- // leaking which UUIDs exist (matches public-share 404/403 model).
- throw new OCSForbiddenException('Not authorized');
- }
-
- // Reuse the dashboard-public-share owner-or-admin rule: the
- // playlist token grants anonymous read, so the caller must be
- // allowed to share each dashboard (REQ-PSHR-001).
- $this->shareService->authorizeShareMutation(
- dashboard: $dashboard,
- userId: $callerId
- );
-
- $dwell = self::DWELL_MIN;
- if (is_array($entry) === true && isset($entry['dwellSeconds']) === true) {
- $dwell = $this->clampDwell(dwell: (int) $entry['dwellSeconds']);
- }
-
- $normalised[] = [
- 'dashboardUuid' => $uuid,
- 'dwellSeconds' => $dwell,
- ];
- }//end foreach
-
- return $normalised;
- }//end validateAndNormaliseEntries()
-
- /**
- * Resolve a playlist the caller owns (or is admin of).
- *
- * @param int $id Playlist primary key.
- * @param string $callerId Caller user ID.
- *
- * @return KioskPlaylist
- *
- * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
- * @throws OCSForbiddenException When the caller is neither owner nor admin.
- */
- private function resolveOwnedPlaylist(int $id, string $callerId): KioskPlaylist
- {
- try {
- $playlist = $this->playlistMapper->findById(id: $id);
- } catch (DoesNotExistException) {
- throw new PlaylistNotFoundException();
- }
-
- $isOwner = ($playlist->getCreatedBy() === $callerId);
- $isAdmin = $this->groupManager->isAdmin(userId: $callerId);
- if ($isOwner === false && $isAdmin === false) {
- throw new OCSForbiddenException('Not authorized');
- }
-
- return $playlist;
- }//end resolveOwnedPlaylist()
-
- /**
- * Build the read-only public dashboard payload for an anonymous renderer.
- *
- * Mirrors the public-share render shape: identity and presentation
- * metadata only, no owner attribution.
- *
- * @param Dashboard $dashboard The dashboard to expose.
- *
- * @return array
- */
- private function publicDashboardPayload(Dashboard $dashboard): array
- {
- $payload = $dashboard->jsonSerialize();
- unset($payload['userId'], $payload['user_id']);
- return $payload;
- }//end publicDashboardPayload()
-
- /**
- * Clamp a dwell value to [DWELL_MIN, DWELL_MAX].
- *
- * @param int $dwell Requested dwell seconds.
- *
- * @return int Clamped dwell seconds.
- */
- private function clampDwell(int $dwell): int
- {
- return max(self::DWELL_MIN, min(self::DWELL_MAX, $dwell));
- }//end clampDwell()
-
- /**
- * Clamp a refresh value to [REFRESH_MIN, REFRESH_MAX].
- *
- * @param int $refresh Requested refresh seconds.
- *
- * @return int Clamped refresh seconds.
- */
- private function clampRefresh(int $refresh): int
- {
- if ($refresh <= 0) {
- return self::REFRESH_DEFAULT;
- }
-
- return max(self::REFRESH_MIN, min(self::REFRESH_MAX, $refresh));
- }//end clampRefresh()
+class KioskService {
+
+ /**
+ * Minimum dwell time per entry, in seconds.
+ *
+ * @var integer
+ */
+ public const DWELL_MIN = 10;
+
+ /**
+ * Maximum dwell time per entry, in seconds (24 hours).
+ *
+ * @var integer
+ */
+ public const DWELL_MAX = 86400;
+
+ /**
+ * Minimum in-place refresh interval, in seconds.
+ *
+ * @var integer
+ */
+ public const REFRESH_MIN = 30;
+
+ /**
+ * Maximum in-place refresh interval, in seconds (24 hours).
+ *
+ * @var integer
+ */
+ public const REFRESH_MAX = 86400;
+
+ /**
+ * Default in-place refresh interval, in seconds.
+ *
+ * @var integer
+ */
+ public const REFRESH_DEFAULT = 300;
+
+ /**
+ * Constructor.
+ *
+ * @param KioskPlaylistMapper $playlistMapper Mapper for kiosk playlists.
+ * @param DashboardMapper $dashMapper Dashboard mapper for existence/ownership.
+ * @param PublicShareService $shareService Reused owner-or-admin authorization rule.
+ * @param IGroupManager $groupManager NC group manager for admin scoping.
+ * @param ISecureRandom $secureRandom CSPRNG for token generation.
+ * @param LoggerInterface $logger PSR-3 logger.
+ */
+ public function __construct(
+ private readonly KioskPlaylistMapper $playlistMapper,
+ private readonly DashboardMapper $dashMapper,
+ private readonly PublicShareService $shareService,
+ private readonly IGroupManager $groupManager,
+ private readonly ISecureRandom $secureRandom,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Create a new kiosk playlist.
+ *
+ * Validates owner-or-admin permission for every referenced dashboard
+ * (REQ-KIOSK-002): a single unauthorized dashboard rejects the whole
+ * request. Dwell and refresh values are clamped before storage.
+ *
+ * @param string $name Playlist name.
+ * @param array $entries Raw entries [{dashboardUuid, dwellSeconds}, ...].
+ * @param int $refresh Requested refresh interval in seconds.
+ * @param string $callerId User ID of the creating user.
+ *
+ * @return KioskPlaylist The new playlist with URL populated.
+ *
+ * @throws OCSForbiddenException When the caller may not share a referenced dashboard.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
+ */
+ public function createPlaylist(
+ string $name,
+ array $entries,
+ int $refresh,
+ string $callerId,
+ ): KioskPlaylist {
+ $normalised = $this->validateAndNormaliseEntries(
+ entries: $entries,
+ callerId: $callerId
+ );
+
+ $playlist = new KioskPlaylist();
+ // phpcs:disable CustomSn.Functions.NamedParameters -- Entity magic __call breaks with named args.
+ $playlist->setName($name);
+ $playlist->setToken(
+ $this->secureRandom->generate(
+ length: 64,
+ characters: ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS
+ )
+ );
+ $playlist->setEntries((string)json_encode($normalised));
+ $playlist->setRefreshSeconds($this->clampRefresh(refresh: $refresh));
+ $playlist->setCreatedBy($callerId);
+ $playlist->setCreatedAt((new DateTime())->format('Y-m-d H:i:s'));
+ // phpcs:enable CustomSn.Functions.NamedParameters
+
+ $saved = $this->playlistMapper->insert(entity: $playlist);
+
+ $this->logger->debug(
+ message: sprintf('launchpad: kiosk playlist created by %s', $callerId),
+ context: ['app' => 'launchpad']
+ );
+
+ return $saved;
+ }//end createPlaylist()
+
+ /**
+ * Update an existing kiosk playlist.
+ *
+ * Re-validates owner-or-admin permission for every referenced dashboard
+ * (REQ-KIOSK-002). On any unauthorized entry the playlist is left
+ * unchanged and OCSForbiddenException is thrown.
+ *
+ * @param int $id Playlist primary key.
+ * @param string $name New playlist name.
+ * @param array $entries Raw entries [{dashboardUuid, dwellSeconds}, ...].
+ * @param int $refresh Requested refresh interval in seconds.
+ * @param string $callerId Caller user ID.
+ *
+ * @return KioskPlaylist The updated playlist with URL populated.
+ *
+ * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
+ * @throws OCSForbiddenException When the caller may not edit it or share a dashboard.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
+ */
+ public function updatePlaylist(
+ int $id,
+ string $name,
+ array $entries,
+ int $refresh,
+ string $callerId,
+ ): KioskPlaylist {
+ $playlist = $this->resolveOwnedPlaylist(id: $id, callerId: $callerId);
+
+ $normalised = $this->validateAndNormaliseEntries(
+ entries: $entries,
+ callerId: $callerId
+ );
+
+ // phpcs:disable CustomSn.Functions.NamedParameters
+ $playlist->setName($name);
+ $playlist->setEntries((string)json_encode($normalised));
+ $playlist->setRefreshSeconds($this->clampRefresh(refresh: $refresh));
+ // phpcs:enable CustomSn.Functions.NamedParameters
+
+ $saved = $this->playlistMapper->update(entity: $playlist);
+ return $this->playlistMapper->findById(id: (int)$saved->getId());
+ }//end updatePlaylist()
+
+ /**
+ * List playlists visible to the caller.
+ *
+ * Regular users see their own active playlists; admins see all active
+ * playlists (REQ-KIOSK-002).
+ *
+ * @param string $callerId Caller user ID.
+ *
+ * @return KioskPlaylist[]
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
+ */
+ public function listPlaylists(string $callerId): array {
+ if ($this->groupManager->isAdmin(userId: $callerId) === true) {
+ return $this->playlistMapper->findAllActive();
+ }
+
+ return $this->playlistMapper->findByCreator(createdBy: $callerId);
+ }//end listPlaylists()
+
+ /**
+ * Soft-revoke a playlist.
+ *
+ * Owner-or-admin only. Idempotent — revoking an already-revoked playlist
+ * is a no-op once the caller is authorized.
+ *
+ * @param int $id Playlist primary key.
+ * @param string $callerId Caller user ID.
+ *
+ * @return void
+ *
+ * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
+ * @throws OCSForbiddenException When the caller is neither owner nor admin.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
+ */
+ public function revokePlaylist(int $id, string $callerId): void {
+ $playlist = $this->resolveOwnedPlaylist(id: $id, callerId: $callerId);
+ $this->playlistMapper->softRevoke(id: (int)$playlist->getId());
+ }//end revokePlaylist()
+
+ /**
+ * Render a playlist for anonymous wall-display access.
+ *
+ * Returns the playlist descriptor plus the read-only render payload for
+ * each entry whose dashboard still exists. Entries referencing a deleted
+ * dashboard are omitted (no error, no placeholder slot) per
+ * REQ-KIOSK-003. Unknown or revoked tokens throw PlaylistNotFoundException
+ * (HTTP 404) with no existence leak.
+ *
+ * @param string $token The playlist token.
+ *
+ * @return array{playlist: array, entries: array}
+ *
+ * @throws PlaylistNotFoundException When the token is unknown or revoked.
+ *
+ * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-3
+ */
+ public function renderPlaylist(string $token): array {
+ try {
+ $playlist = $this->playlistMapper->findByToken(token: $token);
+ } catch (DoesNotExistException) {
+ throw new PlaylistNotFoundException();
+ }
+
+ $rendered = [];
+ foreach ($playlist->getEntriesArray() as $entry) {
+ try {
+ $dashboard = $this->dashMapper->findByUuid(
+ uuid: $entry['dashboardUuid']
+ );
+ } catch (DoesNotExistException) {
+ // Dashboard deleted since the playlist was created — skip it.
+ continue;
+ }
+
+ $rendered[] = [
+ 'dwellSeconds' => $entry['dwellSeconds'],
+ 'dashboard' => $this->publicDashboardPayload(dashboard: $dashboard),
+ ];
+ }
+
+ $descriptor = $playlist->jsonSerialize();
+ // The token IS the public credential already in the URL; the
+ // createdBy attribution is not needed by an anonymous renderer.
+ unset($descriptor['createdBy']);
+
+ return [
+ 'playlist' => $descriptor,
+ 'entries' => $rendered,
+ ];
+ }//end renderPlaylist()
+
+ /**
+ * Validate and normalise raw playlist entries.
+ *
+ * Each entry MUST reference an existing dashboard the caller may share
+ * (owner-or-admin); a single failure rejects the whole request. Dwell
+ * times are clamped to [DWELL_MIN, DWELL_MAX].
+ *
+ * @param array $entries Raw entries.
+ * @param string $callerId Caller user ID.
+ *
+ * @return array
+ *
+ * @throws OCSForbiddenException When a referenced dashboard is missing or unauthorized.
+ */
+ private function validateAndNormaliseEntries(array $entries, string $callerId): array {
+ $normalised = [];
+
+ foreach ($entries as $entry) {
+ $uuid = '';
+ if (is_array($entry) === true && isset($entry['dashboardUuid']) === true) {
+ $uuid = (string)$entry['dashboardUuid'];
+ }
+
+ if ($uuid === '') {
+ throw new OCSForbiddenException('Invalid playlist entry');
+ }
+
+ try {
+ $dashboard = $this->dashMapper->findByUuid(uuid: $uuid);
+ } catch (DoesNotExistException) {
+ // Treat a missing dashboard as a permission failure to avoid
+ // leaking which UUIDs exist (matches public-share 404/403 model).
+ throw new OCSForbiddenException('Not authorized');
+ }
+
+ // Reuse the dashboard-public-share owner-or-admin rule: the
+ // playlist token grants anonymous read, so the caller must be
+ // allowed to share each dashboard (REQ-PSHR-001).
+ $this->shareService->authorizeShareMutation(
+ dashboard: $dashboard,
+ userId: $callerId
+ );
+
+ $dwell = self::DWELL_MIN;
+ if (is_array($entry) === true && isset($entry['dwellSeconds']) === true) {
+ $dwell = $this->clampDwell(dwell: (int)$entry['dwellSeconds']);
+ }
+
+ $normalised[] = [
+ 'dashboardUuid' => $uuid,
+ 'dwellSeconds' => $dwell,
+ ];
+ }//end foreach
+
+ return $normalised;
+ }//end validateAndNormaliseEntries()
+
+ /**
+ * Resolve a playlist the caller owns (or is admin of).
+ *
+ * @param int $id Playlist primary key.
+ * @param string $callerId Caller user ID.
+ *
+ * @return KioskPlaylist
+ *
+ * @throws PlaylistNotFoundException When the playlist does not exist or is revoked.
+ * @throws OCSForbiddenException When the caller is neither owner nor admin.
+ */
+ private function resolveOwnedPlaylist(int $id, string $callerId): KioskPlaylist {
+ try {
+ $playlist = $this->playlistMapper->findById(id: $id);
+ } catch (DoesNotExistException) {
+ throw new PlaylistNotFoundException();
+ }
+
+ $isOwner = ($playlist->getCreatedBy() === $callerId);
+ $isAdmin = $this->groupManager->isAdmin(userId: $callerId);
+ if ($isOwner === false && $isAdmin === false) {
+ throw new OCSForbiddenException('Not authorized');
+ }
+
+ return $playlist;
+ }//end resolveOwnedPlaylist()
+
+ /**
+ * Build the read-only public dashboard payload for an anonymous renderer.
+ *
+ * Mirrors the public-share render shape: identity and presentation
+ * metadata only, no owner attribution.
+ *
+ * @param Dashboard $dashboard The dashboard to expose.
+ *
+ * @return array
+ */
+ private function publicDashboardPayload(Dashboard $dashboard): array {
+ $payload = $dashboard->jsonSerialize();
+ unset($payload['userId'], $payload['user_id']);
+ return $payload;
+ }//end publicDashboardPayload()
+
+ /**
+ * Clamp a dwell value to [DWELL_MIN, DWELL_MAX].
+ *
+ * @param int $dwell Requested dwell seconds.
+ *
+ * @return int Clamped dwell seconds.
+ */
+ private function clampDwell(int $dwell): int {
+ return max(self::DWELL_MIN, min(self::DWELL_MAX, $dwell));
+ }//end clampDwell()
+
+ /**
+ * Clamp a refresh value to [REFRESH_MIN, REFRESH_MAX].
+ *
+ * @param int $refresh Requested refresh seconds.
+ *
+ * @return int Clamped refresh seconds.
+ */
+ private function clampRefresh(int $refresh): int {
+ if ($refresh <= 0) {
+ return self::REFRESH_DEFAULT;
+ }
+
+ return max(self::REFRESH_MIN, min(self::REFRESH_MAX, $refresh));
+ }//end clampRefresh()
}//end class
diff --git a/lib/Service/LiveTileService.php b/lib/Service/LiveTileService.php
new file mode 100644
index 000000000..87f3e7934
--- /dev/null
+++ b/lib/Service/LiveTileService.php
@@ -0,0 +1,832 @@
+
+ * @copyright 2026 Conduction b.v.
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ * @version GIT:auto
+ * @link https://conduction.nl
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ */
+
+declare(strict_types=1);
+
+namespace OCA\LaunchPad\Service;
+
+use DateTime;
+use OCA\LaunchPad\AppInfo\Application;
+use OCA\LaunchPad\Db\WidgetPlacementMapper;
+use OCP\App\IAppManager;
+use OCP\Http\Client\IClientService;
+use OCP\IAppConfig;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+/**
+ * Service for resolving, caching, formatting, and badging live-data tile
+ * readings.
+ *
+ * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Combines dual-source
+ * resolution (OpenConnector leaf + allow-listed direct GET), JSONPath-
+ * lite extraction, formatting, badge thresholding, caching, and
+ * stale-fallback in one cohesive unit — mirrors WeatherService's shape
+ * for the same class of capability.
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Same cause as the complexity
+ * above: the dual-source resolution necessarily reaches OpenConnector, the
+ * HTTP client, the cache and the config. The collaborators are the feature.
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+class LiveTileService {
+
+ /**
+ * Default value-cache TTL in seconds when a placement has no (or an
+ * invalid) `refresh` configured (REQ-LIVETILE-002 "Refresh interval
+ * bounds").
+ *
+ * @var integer
+ */
+ public const DEFAULT_REFRESH_SECONDS = 300;
+
+ /**
+ * Minimum permitted refresh interval in seconds — any configured value
+ * below this is clamped up (REQ-LIVETILE-002).
+ *
+ * @var integer
+ */
+ public const MIN_REFRESH_SECONDS = 30;
+
+ /**
+ * HTTP connect timeout in seconds for the direct-URL fetch.
+ *
+ * @var integer
+ */
+ public const CONNECT_TIMEOUT = 10;
+
+ /**
+ * HTTP total request timeout in seconds for the direct-URL fetch.
+ *
+ * @var integer
+ */
+ public const REQUEST_TIMEOUT = 15;
+
+ /**
+ * IAppConfig key — JSON array of hostnames permitted for `url` source
+ * mode. FAIL-CLOSED: empty or missing means NO host is permitted.
+ *
+ * @var string
+ */
+ public const CONFIG_KEY_ALLOWED_HOSTS = 'livetile_allowed_hosts';
+
+ /**
+ * App id of the optional OpenConnector leaf.
+ *
+ * @var string
+ */
+ private const OPENCONNECTOR_APP_ID = 'openconnector';
+
+ /**
+ * FQCN of OpenConnector's dashboard data-source resolver, referenced
+ * only as a string so this file never hard-requires the class to
+ * exist (REQ-LIVETILE-005 "No direct class dependency") — resolved
+ * through the container only when the capability probe passes.
+ *
+ * @var string
+ */
+ private const OPENCONNECTOR_DATASOURCE_SERVICE_CLASS = 'OCA\\OpenConnector\\Service\\DashboardDataSourceService';
+
+ /**
+ * Method OpenConnector's data-source resolver is expected to expose:
+ * `resolveDashboardValue(string $sourceId, string $valueExpr): array{value: mixed}`.
+ * Guarded with `method_exists()` before every call — a shape mismatch
+ * degrades to "source unavailable" rather than a fatal error.
+ *
+ * @var string
+ */
+ private const OPENCONNECTOR_DATASOURCE_METHOD = 'resolveDashboardValue';
+
+ /**
+ * Badge threshold states, in priority order for icon/label fallback.
+ *
+ * @var array
+ */
+ private const BADGE_STATES = ['ok', 'warn', 'alert'];
+
+ /**
+ * Lazily resolved {@see ICache} backing the per-reading cache.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param IAppManager $appManager Detects whether `openconnector` is enabled.
+ * @param ContainerInterface $container App container used to optionally resolve
+ * OpenConnector's data-source service
+ * (REQ-LIVETILE-005 capability probe).
+ * @param IClientService $clientService HTTP client factory for the direct-URL fetch.
+ * @param ICacheFactory $cacheFactory Backing factory for the distributed value cache.
+ * @param IAppConfig $appConfig Admin config: allow-listed hosts.
+ * @param WidgetPlacementMapper $placementMapper Resolves placements by id.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ private readonly IAppManager $appManager,
+ private readonly ContainerInterface $container,
+ private readonly IClientService $clientService,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly IAppConfig $appConfig,
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Resolve the live-tile value for one placement. Never throws — every
+ * failure path returns either a stale cached value or the `{value:
+ * null, stale: true}` shape (REQ-LIVETILE-003 "Upstream failure
+ * degrades gracefully").
+ *
+ * @param integer $placementId The widget placement id.
+ *
+ * @return array `{value, formatted, badge, fetchedAt, stale}` or `{error: string}`.
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ public function resolveForPlacement(int $placementId): array {
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (Throwable $exception) {
+ return ['error' => 'placement_not_found'];
+ }
+
+ $config = $this->readPlacementConfig(placement: $placement);
+ $refresh = $this->clampRefresh(seconds: (int)($config['refresh'] ?? 0));
+
+ $cacheKey = $this->buildCacheKey(placementId: $placementId, config: $config);
+ $cache = $this->getCache();
+ $cached = $this->readCache(cache: $cache, cacheKey: $cacheKey);
+
+ if ($cached !== null) {
+ $age = (time() - (int)($cached['fetchedAtTs'] ?? 0));
+ if ($age >= 0 && $age < $refresh) {
+ return $this->publicShape(reading: $cached, config: $config, stale: false);
+ }
+ }
+
+ $fresh = $this->fetchFresh(config: $config);
+
+ if ($fresh !== null) {
+ $fresh['fetchedAtTs'] = time();
+ if ($cache !== null) {
+ $cache->set(key: $cacheKey, value: json_encode($fresh), ttl: $refresh);
+ }
+
+ return $this->publicShape(reading: $fresh, config: $config, stale: false);
+ }
+
+ if ($cached !== null) {
+ // Upstream failed (or the allow-list/capability probe refused
+ // the fetch) but a previous value exists — degrade gracefully
+ // rather than error (REQ-LIVETILE-003).
+ return $this->publicShape(reading: $cached, config: $config, stale: true);
+ }
+
+ return [
+ 'value' => null,
+ 'formatted' => null,
+ 'badge' => null,
+ 'fetchedAt' => null,
+ 'stale' => true,
+ ];
+ }//end resolveForPlacement()
+
+ /**
+ * Validate a candidate live-tile source config at save time
+ * (REQ-LIVETILE-002). FAIL-CLOSED for `url` mode: a URL whose host is
+ * not on the (possibly empty) allow-list is always rejected.
+ *
+ * @param array $config The candidate `{sourceMode, url|sourceId, valueExpr, refresh}` config.
+ *
+ * @return string[] Validation error codes; empty when the config is valid.
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ public function validateSourceConfig(array $config): array {
+ $errors = [];
+ $sourceMode = (string)($config['sourceMode'] ?? '');
+
+ if (in_array(needle: $sourceMode, haystack: ['connector', 'url'], strict: true) === false) {
+ $errors[] = 'invalid_source_mode';
+ return $errors;
+ }
+
+ if ($sourceMode === 'url') {
+ $url = trim(string: (string)($config['url'] ?? ''));
+ if ($url === '') {
+ $errors[] = 'url_required';
+ } elseif ($this->hasValidScheme(url: $url) === false) {
+ $errors[] = 'invalid_url';
+ } elseif ($this->isHostAllowed(url: $url) === false) {
+ // FAIL-CLOSED (REQ-LIVETILE-002 "rejected at save time").
+ $errors[] = 'host_not_allowed';
+ }
+ }
+
+ if ($sourceMode === 'connector') {
+ if (trim(string: (string)($config['sourceId'] ?? '')) === '') {
+ $errors[] = 'source_id_required';
+ }
+
+ if ($this->isConnectorAvailable() === false) {
+ $errors[] = 'connector_unavailable';
+ }
+ }
+
+ return $errors;
+ }//end validateSourceConfig()
+
+ /**
+ * Whether the OpenConnector `dashboard-http-datasource` capability is
+ * currently resolvable — app enabled AND the expected service present
+ * in the container. Never throws (REQ-LIVETILE-005).
+ *
+ * @return boolean
+ *
+ * @spec openspec/specs/live-data-tile-widget/spec.md
+ */
+ public function isConnectorAvailable(): bool {
+ try {
+ if ($this->appManager->isEnabledForUser(appId: self::OPENCONNECTOR_APP_ID) === false) {
+ return false;
+ }
+
+ return $this->container->has(id: self::OPENCONNECTOR_DATASOURCE_SERVICE_CLASS);
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'LiveTileService: OpenConnector capability probe failed, treating as absent',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return false;
+ }
+ }//end isConnectorAvailable()
+
+ /**
+ * Dispatch to the configured source mode's fetch path. Returns `null`
+ * — never throws — on any failure so the caller can fall through to a
+ * stale cache or the null/stale shape.
+ *
+ * @param array $config The placement's resolved config.
+ *
+ * @return array|null `{rawValue: mixed}` or `null`.
+ */
+ private function fetchFresh(array $config): ?array {
+ $sourceMode = (string)($config['sourceMode'] ?? 'url');
+
+ if ($sourceMode === 'connector') {
+ return $this->fetchFromConnector(config: $config);
+ }
+
+ if ($sourceMode === 'url') {
+ return $this->fetchFromUrl(config: $config);
+ }
+
+ return null;
+ }//end fetchFresh()
+
+ /**
+ * Resolve via OpenConnector's `dashboard-http-datasource` capability
+ * (REQ-LIVETILE-005). Returns `null` — never throws — when the
+ * capability probe fails, the service's expected method is absent, or
+ * the call itself fails; the caller then falls back to a stale cached
+ * value or the "unavailable" null/stale shape, which the widget
+ * renders as an informative state (REQ-LIVETILE-005 "existing
+ * connector-mode tiles MUST render an informative 'data source
+ * unavailable' state, not crash").
+ *
+ * @param array $config The placement's resolved config (`sourceId`, `valueExpr`).
+ *
+ * @return array|null `{rawValue: mixed}` or `null`.
+ */
+ private function fetchFromConnector(array $config): ?array {
+ if ($this->isConnectorAvailable() === false) {
+ return null;
+ }
+
+ $sourceId = (string)($config['sourceId'] ?? '');
+ $valueExpr = (string)($config['valueExpr'] ?? '');
+ if ($sourceId === '') {
+ return null;
+ }
+
+ try {
+ $service = $this->container->get(id: self::OPENCONNECTOR_DATASOURCE_SERVICE_CLASS);
+ if (method_exists(object_or_class: $service, method: self::OPENCONNECTOR_DATASOURCE_METHOD) === false) {
+ return null;
+ }
+
+ $result = $service->{self::OPENCONNECTOR_DATASOURCE_METHOD}($sourceId, $valueExpr);
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'LiveTileService: OpenConnector source-run call failed',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return null;
+ }
+
+ if (is_array(value: $result) === false || array_key_exists(key: 'value', array: $result) === false) {
+ return null;
+ }
+
+ return ['rawValue' => $result['value']];
+ }//end fetchFromConnector()
+
+ /**
+ * Resolve via a server-side, allow-listed HTTP GET (REQ-LIVETILE-003).
+ * Fails closed: an invalid scheme or a host not on
+ * `livetile_allowed_hosts` refuses the fetch entirely (never even
+ * opens a connection). Returns `null` — never throws — on any
+ * failure.
+ *
+ * @param array $config The placement's resolved config (`url`, `valueExpr`).
+ *
+ * @return array|null `{rawValue: mixed}` or `null`.
+ */
+ private function fetchFromUrl(array $config): ?array {
+ $url = trim(string: (string)($config['url'] ?? ''));
+ if ($url === '') {
+ return null;
+ }
+
+ if ($this->hasValidScheme(url: $url) === false) {
+ return null;
+ }
+
+ if ($this->isHostAllowed(url: $url) === false) {
+ $this->logger->warning(
+ message: 'LiveTileService: host not on livetile_allowed_hosts, refusing fetch (fail-closed)',
+ context: ['app' => Application::APP_ID]
+ );
+ return null;
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'connect_timeout' => self::CONNECT_TIMEOUT,
+ 'timeout' => self::REQUEST_TIMEOUT,
+ 'http_errors' => false,
+ // No auto-redirect — a 3xx to an unexpected host would
+ // bypass the allow-list check above.
+ 'allow_redirects' => false,
+ ]
+ );
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'LiveTileService: direct-URL fetch failed',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ return null;
+ }
+
+ $status = (int)$response->getStatusCode();
+ if ($status < 200 || $status >= 300) {
+ return null;
+ }
+
+ $decoded = json_decode(json: (string)$response->getBody(), associative: true);
+ if (is_array(value: $decoded) === false) {
+ return null;
+ }
+
+ $value = $this->extractValue(data: $decoded, expr: (string)($config['valueExpr'] ?? ''));
+ if ($value === null) {
+ return null;
+ }
+
+ return ['rawValue' => $value];
+ }//end fetchFromUrl()
+
+ /**
+ * Extract a value from a decoded JSON structure via a JSONPath-lite
+ * expression — supports `$.a.b` (property access) and `$.a[0].b`
+ * (numeric index access). No arbitrary code is evaluated. Returns
+ * `null` on any malformed expression, missing key, or out-of-range
+ * index.
+ *
+ * @param mixed $data The decoded JSON value (array/scalar tree).
+ * @param string $expr The JSONPath-lite expression, e.g. `$.data.open_count`.
+ *
+ * @return mixed|null The extracted value, or `null`.
+ */
+ private function extractValue(mixed $data, string $expr): mixed {
+ $expr = trim(string: $expr);
+ if ($expr === '' || $expr[0] !== '$') {
+ return null;
+ }
+
+ $rest = substr(string: $expr, offset: 1);
+ if ($rest === '') {
+ return $data;
+ }
+
+ $tokens = $this->tokenisePath(path: $rest);
+ if ($tokens === null) {
+ return null;
+ }
+
+ $current = $data;
+ foreach ($tokens as $token) {
+ // Alternation: a `[123]` match leaves group 1 as the empty
+ // string and fills group 2; a `.prop` match matches group 1
+ // and omits group 2 entirely. Discriminate on group 2 — its
+ // presence is what actually distinguishes the two forms.
+ $indexToken = ($token[2] ?? '');
+
+ $key = $token[1];
+ if ($indexToken !== '') {
+ $key = (int)$indexToken;
+ }
+
+ if (is_array(value: $current) === false || array_key_exists(key: $key, array: $current) === false) {
+ return null;
+ }
+
+ $current = $current[$key];
+ }
+
+ return $current;
+ }//end extractValue()
+
+ /**
+ * Split a JSONPath-lite path body into its `.prop` / `[index]` tokens.
+ *
+ * Rejects the whole expression when the tokens do not account for every
+ * character of `$path` — defence-in-depth, so a partially-matched
+ * trailing garbage segment can never be silently ignored.
+ *
+ * @param string $path The expression body (the part after the leading `$`).
+ *
+ * @return array>|null The PREG_SET_ORDER match
+ * sets, or null when the path
+ * is not fully recognised.
+ */
+ private function tokenisePath(string $path): ?array {
+ $tokens = [];
+ $matched = preg_match_all(
+ pattern: '/\.([A-Za-z0-9_]+)|\[(\d+)\]/',
+ subject: $path,
+ matches: $tokens,
+ flags: PREG_SET_ORDER
+ );
+
+ if ($matched === false || $matched === 0) {
+ return null;
+ }
+
+ $consumed = implode(
+ separator: '',
+ array: array_map(
+ callback: static fn (array $token): string => $token[0],
+ array: $tokens
+ )
+ );
+
+ if ($consumed !== $path) {
+ return null;
+ }
+
+ return $tokens;
+ }//end tokenisePath()
+
+ /**
+ * Format a raw resolved value for display (REQ-LIVETILE-004): prefix,
+ * optional thousands separator, suffix. Non-numeric values are
+ * returned as their string cast, unformatted.
+ *
+ * @param mixed $value The raw resolved value.
+ * @param array $format `{prefix?: string, suffix?: string, thousands?: bool}`.
+ *
+ * @return string The formatted display string.
+ */
+ private function formatValue(mixed $value, array $format): string {
+ $prefix = (string)($format['prefix'] ?? '');
+ $suffix = (string)($format['suffix'] ?? '');
+
+ if (is_numeric(value: $value) === false) {
+ return $prefix . ((string)$value) . $suffix;
+ }
+
+ $number = (float)$value;
+ $decimals = 2;
+ if ((float)(int)$number === $number) {
+ $decimals = 0;
+ }
+
+ $thousands = (bool)($format['thousands'] ?? false);
+
+ $body = (string)round(num: $number, precision: $decimals);
+ if ($thousands === true) {
+ $body = number_format(num: $number, decimals: $decimals);
+ }
+
+ return $prefix . $body . $suffix;
+ }//end formatValue()
+
+ /**
+ * Resolve the threshold badge for a raw value (REQ-LIVETILE-004
+ * "Threshold badge is not colour-only"). Thresholds are evaluated in
+ * ascending `max` order; the first threshold whose `max` the value
+ * does not exceed wins. Returns `null` when no thresholds are
+ * configured or the value is non-numeric.
+ *
+ * @param mixed $value The raw resolved value.
+ * @param array $badge `{thresholds?: array}`.
+ *
+ * @return array|null `{state, label}` or `null`.
+ */
+ private function resolveBadge(mixed $value, array $badge): ?array {
+ $thresholds = $badge['thresholds'] ?? null;
+ if (is_array(value: $thresholds) === false || $thresholds === [] || is_numeric(value: $value) === false) {
+ return null;
+ }
+
+ $number = (float)$value;
+ $sorted = $thresholds;
+ usort(
+ array: $sorted,
+ callback: static fn (array $a, array $b): int => ((float)($a['max'] ?? 0)) <=> ((float)($b['max'] ?? 0))
+ );
+
+ foreach ($sorted as $threshold) {
+ if (is_array(value: $threshold) === false) {
+ continue;
+ }
+
+ $max = (float)($threshold['max'] ?? 0);
+ if ($number <= $max) {
+ return $this->badgeShape(threshold: $threshold);
+ }
+ }
+
+ // Value exceeds every threshold — use the highest-max (last)
+ // threshold, which is conventionally the "alert" tier.
+ $last = end($sorted);
+ if (is_array(value: $last) === true) {
+ return $this->badgeShape(threshold: $last);
+ }
+
+ return null;
+ }//end resolveBadge()
+
+ /**
+ * Shape one threshold entry into the public badge contract.
+ *
+ * @param array $threshold `{state?, label?}`.
+ *
+ * @return array `{state, label}`.
+ */
+ private function badgeShape(array $threshold): array {
+ $state = (string)($threshold['state'] ?? 'ok');
+ if (in_array(needle: $state, haystack: self::BADGE_STATES, strict: true) === false) {
+ $state = 'ok';
+ }
+
+ $label = (string)($threshold['label'] ?? ucfirst(string: $state));
+
+ return ['state' => $state, 'label' => $label];
+ }//end badgeShape()
+
+ /**
+ * Validate a URL's scheme is `http` or `https`.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean
+ */
+ private function hasValidScheme(string $url): bool {
+ $scheme = strtolower(string: (string)parse_url(url: $url, component: PHP_URL_SCHEME));
+ return in_array(needle: $scheme, haystack: ['http', 'https'], strict: true);
+ }//end hasValidScheme()
+
+ /**
+ * Check a URL's host against `livetile_allowed_hosts`. FAIL-CLOSED: an
+ * empty, missing, or unparseable allow-list permits NO host — the
+ * admin must explicitly opt hosts in.
+ *
+ * @param string $url The URL to check.
+ *
+ * @return boolean True only when the host is explicitly allow-listed.
+ */
+ private function isHostAllowed(string $url): bool {
+ $host = parse_url(url: $url, component: PHP_URL_HOST);
+ if (is_string(value: $host) === false || $host === '') {
+ return false;
+ }
+
+ $raw = $this->appConfig->getValueString(
+ app: Application::APP_ID,
+ key: self::CONFIG_KEY_ALLOWED_HOSTS,
+ default: ''
+ );
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === false || $decoded === []) {
+ // FAIL-CLOSED — no configured list means no host is allowed.
+ return false;
+ }
+
+ $needle = strtolower(string: $host);
+ foreach ($decoded as $allowed) {
+ if (is_string(value: $allowed) === true && strtolower(string: $allowed) === $needle) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isHostAllowed()
+
+ /**
+ * Read the placement's live-tile config, falling back to the legacy
+ * `style_config.content` slot for pre-column rows (mirrors
+ * {@see WeatherService::readPlacementConfig()}).
+ *
+ * @param object $placement The {@see \OCA\LaunchPad\Db\WidgetPlacement} entity.
+ *
+ * @return array
+ */
+ private function readPlacementConfig(object $placement): array {
+ if (method_exists(object_or_class: $placement, method: 'getContentArray') === true) {
+ $content = $placement->getContentArray();
+ if (is_array(value: $content) === true && $content !== []) {
+ return $content;
+ }
+ }
+
+ if (method_exists(object_or_class: $placement, method: 'getStyleConfigArray') === true) {
+ $legacy = $placement->getStyleConfigArray();
+ if (isset($legacy['content']) === true && is_array(value: $legacy['content']) === true) {
+ return $legacy['content'];
+ }
+
+ if (is_array(value: $legacy) === true) {
+ return $legacy;
+ }
+ }
+
+ return [];
+ }//end readPlacementConfig()
+
+ /**
+ * Shape an internal reading array (which also carries the internal
+ * `fetchedAtTs` unix timestamp / `rawValue`) into the public response
+ * contract (REQ-LIVETILE-003): `{value, formatted, badge, fetchedAt,
+ * stale}`. NEVER includes the source URL, headers, or credentials.
+ *
+ * @param array $reading The internal reading.
+ * @param array $config The placement's resolved config (drives format/badge).
+ * @param boolean $stale Whether this is a stale (cache-expired-but-served) reading.
+ *
+ * @return array
+ */
+ private function publicShape(array $reading, array $config, bool $stale): array {
+ $rawValue = $reading['rawValue'] ?? null;
+ $fetchedAtTs = (int)($reading['fetchedAtTs'] ?? time());
+
+ $formatted = null;
+ $badge = null;
+ if ($rawValue !== null) {
+ $formatted = $this->formatValue(value: $rawValue, format: (array)($config['format'] ?? []));
+ $badge = $this->resolveBadge(value: $rawValue, badge: (array)($config['badge'] ?? []));
+ }
+
+ return [
+ 'value' => $rawValue,
+ 'formatted' => $formatted,
+ 'badge' => $badge,
+ 'fetchedAt' => (new DateTime('@' . $fetchedAtTs))->format(format: DATE_ATOM),
+ 'stale' => $stale,
+ ];
+ }//end publicShape()
+
+ /**
+ * Build the value cache key — placement id + a hash of the resolved
+ * config (proposal.md "Caching keyed on placement id + config hash").
+ *
+ * @param integer $placementId The widget placement id.
+ * @param array $config The placement's resolved config.
+ *
+ * @return string The cache key.
+ */
+ private function buildCacheKey(int $placementId, array $config): string {
+ return 'value_' . $placementId . '_' . hash(algo: 'sha256', data: (string)json_encode($config));
+ }//end buildCacheKey()
+
+ /**
+ * Read + JSON-decode a cache entry. Returns `null` on a miss or a
+ * corrupt entry.
+ *
+ * @param ICache|null $cache The cache instance, or `null` when the cache
+ * subsystem is unavailable.
+ * @param string $cacheKey The cache key.
+ *
+ * @return array|null
+ */
+ private function readCache(?ICache $cache, string $cacheKey): ?array {
+ if ($cache === null) {
+ return null;
+ }
+
+ $raw = $cache->get(key: $cacheKey);
+ if (is_string(value: $raw) === false) {
+ return null;
+ }
+
+ $decoded = json_decode(json: $raw, associative: true);
+ if (is_array(value: $decoded) === true) {
+ return $decoded;
+ }
+
+ return null;
+ }//end readCache()
+
+ /**
+ * Lazily resolve the distributed cache. Returns `null` when the cache
+ * subsystem is unavailable (e.g. unit tests with a stub factory).
+ *
+ * @return ICache|null
+ */
+ private function getCache(): ?ICache {
+ if ($this->cache !== null) {
+ return $this->cache;
+ }
+
+ try {
+ $this->cache = $this->cacheFactory->createDistributed(prefix: 'launchpad_livetile_');
+ } catch (Throwable $exception) {
+ $this->logger->info(
+ message: 'LiveTileService: cache subsystem unavailable, falling back to direct fetch',
+ context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()]
+ );
+ $this->cache = null;
+ }
+
+ return $this->cache;
+ }//end getCache()
+
+ /**
+ * Clamp a configured refresh interval: values `<= 0` (unset) default
+ * to {@see self::DEFAULT_REFRESH_SECONDS}; any positive value below
+ * {@see self::MIN_REFRESH_SECONDS} is raised to that minimum
+ * (REQ-LIVETILE-002 "Refresh interval bounds").
+ *
+ * @param integer $seconds The raw configured refresh interval, or `0`/negative when unset.
+ *
+ * @return integer The clamped refresh interval in seconds.
+ */
+ private function clampRefresh(int $seconds): int {
+ if ($seconds <= 0) {
+ return self::DEFAULT_REFRESH_SECONDS;
+ }
+
+ return max($seconds, self::MIN_REFRESH_SECONDS);
+ }//end clampRefresh()
+}//end class
diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php
index dce61b843..154cf683f 100644
--- a/lib/Service/MenuService.php
+++ b/lib/Service/MenuService.php
@@ -15,8 +15,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -34,135 +34,131 @@
* so violating placements MUST be rejected at save time rather than letting
* unrenderable rows persist.
*/
-class MenuService
-{
- /**
- * Maximum item nesting depth permitted (top + 2 child levels).
- *
- * @var integer
- */
- public const MAX_DEPTH = 3;
+class MenuService {
+ /**
+ * Maximum item nesting depth permitted (top + 2 child levels).
+ *
+ * @var integer
+ */
+ public const MAX_DEPTH = 3;
- /**
- * Allowed values for the `style` field.
- *
- * @var string[]
- */
- public const ALLOWED_STYLES = ['dropdown', 'megamenu', 'tree'];
+ /**
+ * Allowed values for the `style` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_STYLES = ['dropdown', 'megamenu', 'tree'];
- /**
- * Allowed values for the `orientation` field.
- *
- * @var string[]
- */
- public const ALLOWED_ORIENTATIONS = ['horizontal', 'vertical'];
+ /**
+ * Allowed values for the `orientation` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_ORIENTATIONS = ['horizontal', 'vertical'];
- /**
- * Allowed values for the `activeItemHighlight` field.
- *
- * @var string[]
- */
- public const ALLOWED_HIGHLIGHTS = ['background', 'underline', 'left-bar', 'none'];
+ /**
+ * Allowed values for the `activeItemHighlight` field.
+ *
+ * @var string[]
+ */
+ public const ALLOWED_HIGHLIGHTS = ['background', 'underline', 'left-bar', 'none'];
- /**
- * Validate the `items` array recursively.
- *
- * REQ-MENU-002: rejects any tree where an item's `children` list pushes
- * the depth beyond 3 levels. The error message is fixed so the API
- * response (HTTP 400) and form-side error remain in lock-step.
- *
- * @param array $items Top-level menu items.
- *
- * @return void
- * @throws InvalidArgumentException When depth exceeds 3 or an item is malformed.
- *
- * @spec openspec/specs/menu-widget/spec.md
- */
- public function validateMenuItems(array $items): void
- {
- $this->validateLevel(items: $items, depth: 1);
- }//end validateMenuItems()
+ /**
+ * Validate the `items` array recursively.
+ *
+ * REQ-MENU-002: rejects any tree where an item's `children` list pushes
+ * the depth beyond 3 levels. The error message is fixed so the API
+ * response (HTTP 400) and form-side error remain in lock-step.
+ *
+ * @param array $items Top-level menu items.
+ *
+ * @return void
+ * @throws InvalidArgumentException When depth exceeds 3 or an item is malformed.
+ *
+ * @spec openspec/specs/menu-widget/spec.md
+ */
+ public function validateMenuItems(array $items): void {
+ $this->validateLevel(items: $items, depth: 1);
+ }//end validateMenuItems()
- /**
- * Validate the closed-enum fields on the menu content blob.
- *
- * Each call short-circuits on the first illegal value so the caller
- * sees the most specific error possible. Defaults supplied by the
- * renderer are accepted unconditionally — only explicit overrides are
- * checked.
- *
- * @param array $content Full widget content (`style`, `orientation`,
- * `activeItemHighlight`, ...).
- *
- * @return void
- * @throws InvalidArgumentException When a field value is outside its allowed set.
- *
- * @spec openspec/specs/menu-widget/spec.md
- */
- public function validateMenuConfig(array $content): void
- {
- if (isset($content['style']) === true
- && in_array(needle: $content['style'], haystack: self::ALLOWED_STYLES, strict: true) === false
- ) {
- throw new InvalidArgumentException(
- message: 'Menu style must be one of: dropdown, megamenu, tree'
- );
- }
+ /**
+ * Validate the closed-enum fields on the menu content blob.
+ *
+ * Each call short-circuits on the first illegal value so the caller
+ * sees the most specific error possible. Defaults supplied by the
+ * renderer are accepted unconditionally — only explicit overrides are
+ * checked.
+ *
+ * @param array $content Full widget content (`style`, `orientation`,
+ * `activeItemHighlight`, ...).
+ *
+ * @return void
+ * @throws InvalidArgumentException When a field value is outside its allowed set.
+ *
+ * @spec openspec/specs/menu-widget/spec.md
+ */
+ public function validateMenuConfig(array $content): void {
+ if (isset($content['style']) === true
+ && in_array(needle: $content['style'], haystack: self::ALLOWED_STYLES, strict: true) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu style must be one of: dropdown, megamenu, tree'
+ );
+ }
- if (isset($content['orientation']) === true
- && in_array(
- needle: $content['orientation'],
- haystack: self::ALLOWED_ORIENTATIONS,
- strict: true
- ) === false
- ) {
- throw new InvalidArgumentException(
- message: 'Menu orientation must be one of: horizontal, vertical'
- );
- }
+ if (isset($content['orientation']) === true
+ && in_array(
+ needle: $content['orientation'],
+ haystack: self::ALLOWED_ORIENTATIONS,
+ strict: true
+ ) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu orientation must be one of: horizontal, vertical'
+ );
+ }
- if (isset($content['activeItemHighlight']) === true
- && in_array(
- needle: $content['activeItemHighlight'],
- haystack: self::ALLOWED_HIGHLIGHTS,
- strict: true
- ) === false
- ) {
- throw new InvalidArgumentException(
- message: 'Menu activeItemHighlight must be one of: background, underline, left-bar, none'
- );
- }
- }//end validateMenuConfig()
+ if (isset($content['activeItemHighlight']) === true
+ && in_array(
+ needle: $content['activeItemHighlight'],
+ haystack: self::ALLOWED_HIGHLIGHTS,
+ strict: true
+ ) === false
+ ) {
+ throw new InvalidArgumentException(
+ message: 'Menu activeItemHighlight must be one of: background, underline, left-bar, none'
+ );
+ }
+ }//end validateMenuConfig()
- /**
- * Validate one level of the items tree.
- *
- * @param array $items Items at the current depth.
- * @param integer $depth Current depth (1-indexed).
- *
- * @return void
- * @throws InvalidArgumentException When the depth cap is exceeded.
- */
- private function validateLevel(array $items, int $depth): void
- {
- if ($depth > self::MAX_DEPTH) {
- throw new InvalidArgumentException(
- message: 'Menu items can nest at most 3 levels deep'
- );
- }
+ /**
+ * Validate one level of the items tree.
+ *
+ * @param array $items Items at the current depth.
+ * @param integer $depth Current depth (1-indexed).
+ *
+ * @return void
+ * @throws InvalidArgumentException When the depth cap is exceeded.
+ */
+ private function validateLevel(array $items, int $depth): void {
+ if ($depth > self::MAX_DEPTH) {
+ throw new InvalidArgumentException(
+ message: 'Menu items can nest at most 3 levels deep'
+ );
+ }
- foreach ($items as $item) {
- if (is_array($item) === false) {
- throw new InvalidArgumentException(
- message: 'Menu item must be an object'
- );
- }
+ foreach ($items as $item) {
+ if (is_array($item) === false) {
+ throw new InvalidArgumentException(
+ message: 'Menu item must be an object'
+ );
+ }
- if (isset($item['children']) === true && is_array($item['children']) === true
- && count($item['children']) > 0
- ) {
- $this->validateLevel(items: $item['children'], depth: ($depth + 1));
- }
- }
- }//end validateLevel()
+ if (isset($item['children']) === true && is_array($item['children']) === true
+ && count($item['children']) > 0
+ ) {
+ $this->validateLevel(items: $item['children'], depth: ($depth + 1));
+ }
+ }
+ }//end validateLevel()
}//end class
diff --git a/lib/Service/MetadataService.php b/lib/Service/MetadataService.php
index ec22450d4..9bd7a03ef 100644
--- a/lib/Service/MetadataService.php
+++ b/lib/Service/MetadataService.php
@@ -19,8 +19,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -30,7 +30,6 @@
use DateTime;
use OCA\LaunchPad\Db\MetadataField;
use OCA\LaunchPad\Db\MetadataFieldMapper;
-use OCA\LaunchPad\Db\MetadataValue;
use OCA\LaunchPad\Db\MetadataValueMapper;
use OCA\LaunchPad\Exception\InvalidMetadataFieldException;
use OCA\LaunchPad\Exception\MetadataFieldHasValuesException;
@@ -40,727 +39,798 @@
/**
* Coordinator for the dashboard-metadata-fields capability.
*
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Orchestrates two
- * mappers + the validation service + the logger; this is the
- * single facade for the capability.
- * @SuppressWarnings(PHPMD.TooManyPublicMethods) The full CRUD +
- * read/write/filter surface is intentionally co-located here so
- * controllers stay thin.
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Per-type
* filter dispatch + multi-shape patch handling drive complexity;
* splitting would scatter the capability's invariants across
* multiple classes for no readability gain.
* @spec openspec/specs/dashboard-metadata-fields/spec.md
*/
-class MetadataService
-{
- /**
- * Maximum field-key length (matches schema column).
- *
- * @var int
- */
- private const MAX_KEY_LENGTH = 64;
-
- /**
- * Maximum label length (matches schema column).
- *
- * @var int
- */
- private const MAX_LABEL_LENGTH = 255;
-
- /**
- * Constructor.
- *
- * @param MetadataFieldMapper $fieldMapper The field mapper.
- * @param MetadataValueMapper $valueMapper The value mapper.
- * @param MetadataValidationService $validationService The validator.
- * @param LoggerInterface $logger PSR logger.
- */
- public function __construct(
- private readonly MetadataFieldMapper $fieldMapper,
- private readonly MetadataValueMapper $valueMapper,
- private readonly MetadataValidationService $validationService,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Return all field definitions ordered by `sortOrder`.
- *
- * @return MetadataField[] The sorted field list.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function listFields(): array
- {
- return $this->fieldMapper->findAll();
- }//end listFields()
-
- /**
- * Look up a field definition by id or throw.
- *
- * @param int $id The field id.
- *
- * @return MetadataField The matching field.
- *
- * @throws DoesNotExistException When missing.
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function getField(int $id): MetadataField
- {
- return $this->fieldMapper->findById(id: $id);
- }//end getField()
-
- /**
- * Create a new field definition with full validation
- * (REQ-MDFL-001).
- *
- * @param string $key The slugified key.
- * @param string $label The display label.
- * @param string $type One of {@see MetadataField::VALID_TYPES}.
- * @param array|null $options Option set (select types only).
- * @param int $required 0 / 1.
- * @param int $sortOrder UI sort order.
- *
- * @return MetadataField The persisted entity.
- *
- * @throws InvalidMetadataFieldException When validation fails.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function createFieldDefinition(
- string $key,
- string $label,
- string $type,
- ?array $options=null,
- int $required=0,
- int $sortOrder=0
- ): MetadataField {
- self::assertKeyShape(key: $key);
- self::assertLabelShape(label: $label);
- self::assertTypeShape(type: $type);
- self::assertOptionsShape(type: $type, options: $options);
-
- try {
- $this->fieldMapper->findByKey(key: $key);
- throw new InvalidMetadataFieldException(
- message: "Field key '".$key."' already exists"
- );
- } catch (DoesNotExistException) {
- // Expected — key is free.
- }
-
- $now = (new DateTime())->format(format: 'c');
- $field = new MetadataField();
- $requiredFlag = 0;
- if ($required === 1) {
- $requiredFlag = 1;
- }
-
- // Entity __call routes setter args via $args[0]; named params would
- // land in the wrong slot. Per-line phpcs ignore avoids the
- // codebase-wide named-args sniff that fires on every setter call.
- // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- $field->setFieldKey($key);
- $field->setLabel($label);
- $field->setType($type);
- $field->setRequired($requiredFlag);
- $field->setSortOrder($sortOrder);
- $field->setCreatedAt($now);
- $field->setUpdatedAt($now);
- // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
-
- $field->setOptionsArray(options: $options);
-
- return $this->fieldMapper->insert(entity: $field);
- }//end createFieldDefinition()
-
- /**
- * Update an existing field definition (REQ-MDFL-002).
- *
- * The `key` slug is immutable — supplying it triggers a 400.
- * Allowed patch keys: `label`, `sortOrder`, `required`, `options`.
- *
- * @param int $id The field id.
- * @param array $patch The shallow patch.
- *
- * @return MetadataField The persisted entity.
- *
- * @throws DoesNotExistException When the field is missing.
- * @throws InvalidMetadataFieldException When validation fails.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function updateFieldDefinition(int $id, array $patch): MetadataField
- {
- if (array_key_exists(key: 'key', array: $patch) === true) {
- throw new InvalidMetadataFieldException(
- message: 'Field key cannot be renamed'
- );
- }
-
- $field = $this->fieldMapper->findById(id: $id);
-
- // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
- if (array_key_exists(key: 'label', array: $patch) === true) {
- $label = (string) $patch['label'];
- self::assertLabelShape(label: $label);
- $field->setLabel($label);
- }
-
- if (array_key_exists(key: 'sortOrder', array: $patch) === true) {
- $field->setSortOrder((int) $patch['sortOrder']);
- }
-
- if (array_key_exists(key: 'required', array: $patch) === true) {
- $required = 0;
- if ((int) $patch['required'] === 1) {
- $required = 1;
- }
-
- $field->setRequired($required);
- }
-
- if (array_key_exists(key: 'options', array: $patch) === true) {
- $options = $patch['options'];
- if ($options !== null && is_array($options) === false) {
- throw new InvalidMetadataFieldException(
- message: 'Options must be an array of strings or null'
- );
- }
-
- self::assertOptionsShape(type: $field->getType(), options: $options);
- $field->setOptionsArray(options: $options);
- }
-
- $field->setUpdatedAt((new DateTime())->format(format: 'c'));
- // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
-
- return $this->fieldMapper->update(entity: $field);
- }//end updateFieldDefinition()
-
- /**
- * Delete a field definition (REQ-MDFL-003).
- *
- * Soft-by-default: when the field has dependent value rows the
- * caller MUST opt in via `$cascade = true`, otherwise a 409 is
- * raised.
- *
- * @param int $id The field id.
- * @param bool $cascade Whether to cascade-delete dependent values.
- *
- * @return bool True on success.
- *
- * @throws DoesNotExistException When the field is missing.
- * @throws MetadataFieldHasValuesException When values exist and
- * cascade is false.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function deleteFieldDefinition(int $id, bool $cascade=false): bool
- {
- $field = $this->fieldMapper->findById(id: $id);
- $valueCount = $this->fieldMapper->countValuesForField(fieldId: $id);
-
- if ($valueCount > 0 && $cascade === false) {
- throw new MetadataFieldHasValuesException(valueCount: $valueCount);
- }
-
- if ($cascade === true) {
- return $this->fieldMapper->deleteWithCascade(fieldId: $id);
- }
-
- $this->fieldMapper->delete(entity: $field);
- return true;
- }//end deleteFieldDefinition()
-
- /**
- * Read every metadata value for the dashboard, as a flat key→value
- * object (REQ-MDFL-004). Orphan rows referencing a deleted field
- * are silently skipped (and logged at warning level) so the
- * dashboard load never crashes.
- *
- * @param string $dashboardUuid The dashboard UUID.
- *
- * @return array The flat key→encoded-value map.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function getMetadataForDashboard(string $dashboardUuid): array
- {
- $rows = $this->valueMapper->findByDashboard(dashboardUuid: $dashboardUuid);
- if (count($rows) === 0) {
- return [];
- }
-
- $fieldIds = [];
- foreach ($rows as $row) {
- $fieldIds[] = (int) $row->getFieldId();
- }
-
- $fieldsById = $this->fieldMapper->findByIds(ids: $fieldIds);
-
- $result = [];
- foreach ($rows as $row) {
- $fieldId = (int) $row->getFieldId();
- if (array_key_exists(key: $fieldId, array: $fieldsById) === false) {
- $this->logger->warning(
- message: 'Orphaned dashboard metadata value (no field definition)',
- context: [
- 'dashboardUuid' => $dashboardUuid,
- 'fieldId' => $fieldId,
- ]
- );
- continue;
- }
-
- $field = $fieldsById[$fieldId];
- $result[$field->getFieldKey()] = $row->getValue();
- }
-
- return $result;
- }//end getMetadataForDashboard()
-
- /**
- * Upsert each (key → value) entry for the dashboard
- * (REQ-MDFL-005, REQ-MDFL-006). Unknown keys raise 400. Omitted
- * keys are NOT removed — only keys present in the payload are
- * touched.
- *
- * @param string $dashboardUuid The dashboard UUID.
- * @param array $keyValues The patch payload.
- *
- * @return array The full updated metadata object.
- *
- * @throws InvalidMetadataFieldException When any key is unknown
- * or value invalid.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function setMetadataForDashboard(
- string $dashboardUuid,
- array $keyValues
- ): array {
- foreach ($keyValues as $key => $value) {
- $stringKey = (string) $key;
- if ($stringKey === '') {
- throw new InvalidMetadataFieldException(
- message: 'Metadata keys must be non-empty strings'
- );
- }
-
- try {
- $field = $this->fieldMapper->findByKey(key: $stringKey);
- } catch (DoesNotExistException) {
- throw new InvalidMetadataFieldException(
- message: "Unknown metadata field '".$stringKey."'"
- );
- }
-
- $encoded = $this->validationService->validateValue(
- value: $value,
- field: $field
- );
-
- if ($encoded === '' && $field->getRequired() === 0) {
- // Empty optional value: remove the row so the read
- // payload omits the key (matches scenario "omitted
- // keys are not deleted" by allowing explicit empty
- // to clear the value — keeps client UX coherent).
- $existing = $this->valueMapper->findOne(
- dashboardUuid: $dashboardUuid,
- fieldId: (int) $field->getId()
- );
- if ($existing !== null) {
- $this->valueMapper->delete(entity: $existing);
- }
-
- continue;
- }
-
- $this->valueMapper->upsert(
- dashboardUuid: $dashboardUuid,
- fieldId: (int) $field->getId(),
- value: $encoded
- );
- }//end foreach
-
- return $this->getMetadataForDashboard(dashboardUuid: $dashboardUuid);
- }//end setMetadataForDashboard()
-
- /**
- * Apply `?metadata.=…` filters to a dashboard list
- * (REQ-MDFL-007). Filter keys not registered as fields are
- * ignored (so a stale URL never silently empties the list when
- * a field is deleted).
- *
- * Recognised filter shapes per type:
- * - text / select / boolean — exact-match string
- * - multi-select — substring of JSON-encoded value
- * - number — `"min"` / `"max"` keys (inclusive)
- * - date — `"after"` / `"before"` keys (inclusive)
- *
- * @param array $dashboards The candidate dashboards
- * (each MUST expose
- * `getUuid()`).
- * @param array $metadataFilters The filter set
- * (raw
- * `metadata.`
- * map).
- *
- * @return array The filtered subset.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function filterDashboards(
- array $dashboards,
- array $metadataFilters
- ): array {
- if (count($metadataFilters) === 0 || count($dashboards) === 0) {
- return $dashboards;
- }
-
- $resolved = [];
- foreach ($metadataFilters as $key => $criterion) {
- try {
- $field = $this->fieldMapper->findByKey(key: (string) $key);
- } catch (DoesNotExistException) {
- continue;
- }
-
- $resolved[] = ['field' => $field, 'criterion' => $criterion];
- }
-
- if (count($resolved) === 0) {
- return $dashboards;
- }
-
- $matchingByField = [];
- foreach ($resolved as $entry) {
- $field = $entry['field'];
- $criterion = $entry['criterion'];
-
- $matching = [];
- foreach ($this->valueMapper->findByField(fieldId: (int) $field->getId()) as $row) {
- if (self::matchesCriterion(
- field: $field,
- storedValue: $row->getValue(),
- criterion: $criterion
- ) === true
- ) {
- $matching[$row->getDashboardUuid()] = true;
- }
- }
-
- $matchingByField[] = $matching;
- }
-
- // AND the per-filter sets together.
- $intersection = $matchingByField[0];
- $matchingCount = count($matchingByField);
- for ($i = 1; $i < $matchingCount; $i++) {
- $intersection = array_intersect_key(
- $intersection,
- $matchingByField[$i]
- );
- }
-
- $filtered = [];
- foreach ($dashboards as $dashboard) {
- $uuid = self::extractUuid(dashboard: $dashboard);
- if ($uuid === null) {
- continue;
- }
-
- if (array_key_exists(key: $uuid, array: $intersection) === true) {
- $filtered[] = $dashboard;
- }
- }
-
- return $filtered;
- }//end filterDashboards()
-
- /**
- * Pull a UUID off a dashboard entity OR a serialised array row.
- *
- * @param mixed $dashboard The candidate dashboard.
- *
- * @return string|null The UUID or null when unresolvable.
- */
- private static function extractUuid(mixed $dashboard): ?string
- {
- if (is_object($dashboard) === true && method_exists($dashboard, 'getUuid') === true) {
- $uuid = $dashboard->getUuid();
- if ($uuid === null) {
- return null;
- }
-
- return (string) $uuid;
- }
-
- if (is_array($dashboard) === true && array_key_exists(key: 'uuid', array: $dashboard) === true) {
- return (string) $dashboard['uuid'];
- }
-
- return null;
- }//end extractUuid()
-
- /**
- * Per-criterion match logic.
- *
- * @param MetadataField $field The field definition.
- * @param string $storedValue The persisted value string.
- * @param mixed $criterion The raw filter value.
- *
- * @return bool True when the value satisfies the criterion.
- */
- private static function matchesCriterion(
- MetadataField $field,
- string $storedValue,
- mixed $criterion
- ): bool {
- return match ($field->getType()) {
- MetadataField::TYPE_NUMBER => self::matchesNumberRange(
- stored: $storedValue,
- criterion: $criterion
- ),
- MetadataField::TYPE_DATE => self::matchesDateRange(
- stored: $storedValue,
- criterion: $criterion
- ),
- MetadataField::TYPE_MULTI_SELECT => self::matchesMultiSelect(
- stored: $storedValue,
- criterion: $criterion
- ),
- default => self::matchesExact(
- stored: $storedValue,
- criterion: $criterion
- ),
- };
- }//end matchesCriterion()
-
- /**
- * Exact-string match for text / select / boolean.
- *
- * @param string $stored The persisted value.
- * @param mixed $criterion The filter value.
- *
- * @return bool True on equality.
- */
- private static function matchesExact(string $stored, mixed $criterion): bool
- {
- if (is_array($criterion) === true) {
- return false;
- }
-
- return ($stored === (string) $criterion);
- }//end matchesExact()
-
- /**
- * Numeric range filter — supports `min` / `max` (inclusive) or
- * scalar exact-match.
- *
- * @param string $stored The persisted decimal string.
- * @param mixed $criterion The filter value.
- *
- * @return bool True on match.
- */
- private static function matchesNumberRange(string $stored, mixed $criterion): bool
- {
- if (is_numeric(value: $stored) === false) {
- return false;
- }
-
- $value = (float) $stored;
-
- if (is_array($criterion) === true) {
- if (array_key_exists(key: 'min', array: $criterion) === true
- && $value < (float) $criterion['min']
- ) {
- return false;
- }
-
- if (array_key_exists(key: 'max', array: $criterion) === true
- && $value > (float) $criterion['max']
- ) {
- return false;
- }
-
- return true;
- }
-
- if (is_numeric(value: $criterion) === false) {
- return false;
- }
-
- return ($value === (float) $criterion);
- }//end matchesNumberRange()
-
- /**
- * Date range filter — supports `after` / `before` (inclusive) or
- * exact-match.
- *
- * @param string $stored The persisted ISO-8601 date.
- * @param mixed $criterion The filter value.
- *
- * @return bool True on match.
- */
- private static function matchesDateRange(string $stored, mixed $criterion): bool
- {
- if (is_array($criterion) === true) {
- if (array_key_exists(key: 'after', array: $criterion) === true
- && strcmp(
- string1: $stored,
- string2: (string) $criterion['after']
- ) < 0
- ) {
- return false;
- }
-
- if (array_key_exists(key: 'before', array: $criterion) === true
- && strcmp(
- string1: $stored,
- string2: (string) $criterion['before']
- ) > 0
- ) {
- return false;
- }
-
- return true;
- }//end if
-
- return ($stored === (string) $criterion);
- }//end matchesDateRange()
-
- /**
- * Multi-select containment: the stored value is a JSON array;
- * the criterion is matched as substring of the JSON encoding so
- * `?metadata.tags=news` matches an array containing `"news"`.
- *
- * Documented limitation in design.md (D6 risks): the substring
- * match is sufficient for the MVP; v2 will switch to JSON_CONTAINS
- * or PHP-side array containment.
- *
- * @param string $stored The persisted JSON-array string.
- * @param mixed $criterion The filter value.
- *
- * @return bool True on match.
- */
- private static function matchesMultiSelect(string $stored, mixed $criterion): bool
- {
- if (is_array($criterion) === true) {
- return false;
- }
-
- $needle = (string) $criterion;
- $decoded = json_decode(json: $stored, associative: true);
- if (is_array($decoded) === true) {
- return in_array(needle: $needle, haystack: $decoded, strict: true);
- }
-
- return str_contains(haystack: $stored, needle: $needle);
- }//end matchesMultiSelect()
-
- /**
- * Validate the slug shape: lowercase alphanumeric + underscore,
- * 1..MAX_KEY_LENGTH characters.
- *
- * @param string $key The candidate slug.
- *
- * @return void
- *
- * @throws InvalidMetadataFieldException When malformed.
- */
- private static function assertKeyShape(string $key): void
- {
- if ($key === '' || strlen(string: $key) > self::MAX_KEY_LENGTH) {
- throw new InvalidMetadataFieldException(
- message: 'Field key must be 1..'.self::MAX_KEY_LENGTH.' characters'
- );
- }
-
- if (preg_match(pattern: '/^[a-z0-9_]+$/', subject: $key) !== 1) {
- throw new InvalidMetadataFieldException(
- message: 'Field key must be lowercase alphanumeric with underscores only'
- );
- }
- }//end assertKeyShape()
-
- /**
- * Validate label length.
- *
- * @param string $label The candidate label.
- *
- * @return void
- *
- * @throws InvalidMetadataFieldException When malformed.
- */
- private static function assertLabelShape(string $label): void
- {
- if ($label === '' || strlen(string: $label) > self::MAX_LABEL_LENGTH) {
- throw new InvalidMetadataFieldException(
- message: 'Field label must be 1..'.self::MAX_LABEL_LENGTH.' characters'
- );
- }
- }//end assertLabelShape()
-
- /**
- * Validate that the type is in the supported enum.
- *
- * @param string $type The candidate type.
- *
- * @return void
- *
- * @throws InvalidMetadataFieldException When unsupported.
- */
- private static function assertTypeShape(string $type): void
- {
- if (in_array(needle: $type, haystack: MetadataField::VALID_TYPES, strict: true) === false) {
- throw new InvalidMetadataFieldException(
- message: "Unsupported field type '".$type."'"
- );
- }
- }//end assertTypeShape()
-
- /**
- * Validate that `options` matches the type: select types REQUIRE
- * a non-empty string array; non-select types REQUIRE NULL.
- *
- * @param string $type The field type.
- * @param array|null $options The candidate options (validated entry-by-entry).
- *
- * @return void
- *
- * @throws InvalidMetadataFieldException When mismatched.
- */
- private static function assertOptionsShape(string $type, ?array $options): void
- {
- $isSelect = ($type === MetadataField::TYPE_SELECT
- || $type === MetadataField::TYPE_MULTI_SELECT);
-
- if ($isSelect === true) {
- if ($options === null || count($options) === 0) {
- throw new InvalidMetadataFieldException(
- message: 'Select type requires non-empty options array'
- );
- }
-
- // Defensive runtime check: callers may pass non-string entries.
- foreach ($options as $option) {
- if (is_string($option) === false || $option === '') {
- throw new InvalidMetadataFieldException(
- message: 'Select options must be non-empty strings'
- );
- }
- }
-
- return;
- }
-
- if ($options !== null && count($options) > 0) {
- throw new InvalidMetadataFieldException(
- message: "Type '".$type."' does not support options"
- );
- }
- }//end assertOptionsShape()
+class MetadataService {
+ /**
+ * Maximum field-key length (matches schema column).
+ *
+ * @var int
+ */
+ private const MAX_KEY_LENGTH = 64;
+
+ /**
+ * Maximum label length (matches schema column).
+ *
+ * @var int
+ */
+ private const MAX_LABEL_LENGTH = 255;
+
+ /**
+ * Constructor.
+ *
+ * @param MetadataFieldMapper $fieldMapper The field mapper.
+ * @param MetadataValueMapper $valueMapper The value mapper.
+ * @param MetadataValidationService $validationService The validator.
+ * @param LoggerInterface $logger PSR logger.
+ */
+ public function __construct(
+ private readonly MetadataFieldMapper $fieldMapper,
+ private readonly MetadataValueMapper $valueMapper,
+ private readonly MetadataValidationService $validationService,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Return all field definitions ordered by `sortOrder`.
+ *
+ * @return MetadataField[] The sorted field list.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function listFields(): array {
+ return $this->fieldMapper->findAll();
+ }//end listFields()
+
+ /**
+ * Look up a field definition by id or throw.
+ *
+ * @param int $id The field id.
+ *
+ * @return MetadataField The matching field.
+ *
+ * @throws DoesNotExistException When missing.
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function getField(int $id): MetadataField {
+ return $this->fieldMapper->findById(id: $id);
+ }//end getField()
+
+ /**
+ * Create a new field definition with full validation
+ * (REQ-MDFL-001).
+ *
+ * @param string $key The slugified key.
+ * @param string $label The display label.
+ * @param string $type One of {@see MetadataField::VALID_TYPES}.
+ * @param array|null $options Option set (select types only).
+ * @param int $required 0 / 1.
+ * @param int $sortOrder UI sort order.
+ *
+ * @return MetadataField The persisted entity.
+ *
+ * @throws InvalidMetadataFieldException When validation fails.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function createFieldDefinition(
+ string $key,
+ string $label,
+ string $type,
+ ?array $options = null,
+ int $required = 0,
+ int $sortOrder = 0,
+ ): MetadataField {
+ self::assertKeyShape(key: $key);
+ self::assertLabelShape(label: $label);
+ self::assertTypeShape(type: $type);
+ self::assertOptionsShape(type: $type, options: $options);
+
+ try {
+ $this->fieldMapper->findByKey(key: $key);
+ throw new InvalidMetadataFieldException(
+ message: "Field key '" . $key . "' already exists"
+ );
+ } catch (DoesNotExistException) {
+ // Expected — key is free.
+ }
+
+ $now = (new DateTime())->format(format: 'c');
+ $field = new MetadataField();
+ $requiredFlag = 0;
+ if ($required === 1) {
+ $requiredFlag = 1;
+ }
+
+ // Entity __call routes setter args via $args[0]; named params would
+ // land in the wrong slot. Per-line phpcs ignore avoids the
+ // codebase-wide named-args sniff that fires on every setter call.
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ $field->setFieldKey($key);
+ $field->setLabel($label);
+ $field->setType($type);
+ $field->setRequired($requiredFlag);
+ $field->setSortOrder($sortOrder);
+ $field->setCreatedAt($now);
+ $field->setUpdatedAt($now);
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ $field->setOptionsArray(options: $options);
+
+ return $this->fieldMapper->insert(entity: $field);
+ }//end createFieldDefinition()
+
+ /**
+ * Update an existing field definition (REQ-MDFL-002).
+ *
+ * The `key` slug is immutable — supplying it triggers a 400.
+ * Allowed patch keys: `label`, `sortOrder`, `required`, `options`.
+ *
+ * @param int $id The field id.
+ * @param array $patch The shallow patch.
+ *
+ * @return MetadataField The persisted entity.
+ *
+ * @throws DoesNotExistException When the field is missing.
+ * @throws InvalidMetadataFieldException When validation fails.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function updateFieldDefinition(int $id, array $patch): MetadataField {
+ if (array_key_exists(key: 'key', array: $patch) === true) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key cannot be renamed'
+ );
+ }
+
+ $field = $this->fieldMapper->findById(id: $id);
+
+ // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+ if (array_key_exists(key: 'label', array: $patch) === true) {
+ $label = (string)$patch['label'];
+ self::assertLabelShape(label: $label);
+ $field->setLabel($label);
+ }
+
+ if (array_key_exists(key: 'sortOrder', array: $patch) === true) {
+ $field->setSortOrder((int)$patch['sortOrder']);
+ }
+
+ if (array_key_exists(key: 'required', array: $patch) === true) {
+ $required = 0;
+ if ((int)$patch['required'] === 1) {
+ $required = 1;
+ }
+
+ $field->setRequired($required);
+ }
+
+ if (array_key_exists(key: 'options', array: $patch) === true) {
+ $options = $patch['options'];
+ if ($options !== null && is_array($options) === false) {
+ throw new InvalidMetadataFieldException(
+ message: 'Options must be an array of strings or null'
+ );
+ }
+
+ self::assertOptionsShape(type: $field->getType(), options: $options);
+ $field->setOptionsArray(options: $options);
+ }
+
+ $field->setUpdatedAt((new DateTime())->format(format: 'c'));
+ // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters
+
+ return $this->fieldMapper->update(entity: $field);
+ }//end updateFieldDefinition()
+
+ /**
+ * Delete a field definition (REQ-MDFL-003).
+ *
+ * Soft-by-default: when the field has dependent value rows the
+ * caller MUST opt in via `$cascade = true`, otherwise a 409 is
+ * raised.
+ *
+ * @param int $id The field id.
+ * @param bool $cascade Whether to cascade-delete dependent values.
+ *
+ * @return bool True on success.
+ *
+ * @throws DoesNotExistException When the field is missing.
+ * @throws MetadataFieldHasValuesException When values exist and
+ * cascade is false.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function deleteFieldDefinition(int $id, bool $cascade = false): bool {
+ $field = $this->fieldMapper->findById(id: $id);
+ $valueCount = $this->fieldMapper->countValuesForField(fieldId: $id);
+
+ if ($valueCount > 0 && $cascade === false) {
+ throw new MetadataFieldHasValuesException(valueCount: $valueCount);
+ }
+
+ if ($cascade === true) {
+ return $this->fieldMapper->deleteWithCascade(fieldId: $id);
+ }
+
+ $this->fieldMapper->delete(entity: $field);
+ return true;
+ }//end deleteFieldDefinition()
+
+ /**
+ * Read every metadata value for the dashboard, as a flat key→value
+ * object (REQ-MDFL-004). Orphan rows referencing a deleted field
+ * are silently skipped (and logged at warning level) so the
+ * dashboard load never crashes.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ *
+ * @return array The flat key→encoded-value map.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function getMetadataForDashboard(string $dashboardUuid): array {
+ $rows = $this->valueMapper->findByDashboard(dashboardUuid: $dashboardUuid);
+ if (count($rows) === 0) {
+ return [];
+ }
+
+ $fieldIds = [];
+ foreach ($rows as $row) {
+ $fieldIds[] = (int)$row->getFieldId();
+ }
+
+ $fieldsById = $this->fieldMapper->findByIds(ids: $fieldIds);
+
+ $result = [];
+ foreach ($rows as $row) {
+ $fieldId = (int)$row->getFieldId();
+ if (array_key_exists(key: $fieldId, array: $fieldsById) === false) {
+ $this->logger->warning(
+ message: 'Orphaned dashboard metadata value (no field definition)',
+ context: [
+ 'dashboardUuid' => $dashboardUuid,
+ 'fieldId' => $fieldId,
+ ]
+ );
+ continue;
+ }
+
+ $field = $fieldsById[$fieldId];
+ $result[$field->getFieldKey()] = $row->getValue();
+ }
+
+ return $result;
+ }//end getMetadataForDashboard()
+
+ /**
+ * Upsert each (key → value) entry for the dashboard
+ * (REQ-MDFL-005, REQ-MDFL-006). Unknown keys raise 400. Omitted
+ * keys are NOT removed — only keys present in the payload are
+ * touched.
+ *
+ * @param string $dashboardUuid The dashboard UUID.
+ * @param array $keyValues The patch payload.
+ *
+ * @return array The full updated metadata object.
+ *
+ * @throws InvalidMetadataFieldException When any key is unknown
+ * or value invalid.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function setMetadataForDashboard(
+ string $dashboardUuid,
+ array $keyValues,
+ ): array {
+ foreach ($keyValues as $key => $value) {
+ $stringKey = (string)$key;
+ if ($stringKey === '') {
+ throw new InvalidMetadataFieldException(
+ message: 'Metadata keys must be non-empty strings'
+ );
+ }
+
+ try {
+ $field = $this->fieldMapper->findByKey(key: $stringKey);
+ } catch (DoesNotExistException) {
+ throw new InvalidMetadataFieldException(
+ message: "Unknown metadata field '" . $stringKey . "'"
+ );
+ }
+
+ $encoded = $this->validationService->validateValue(
+ value: $value,
+ field: $field
+ );
+
+ if ($encoded === '' && $field->getRequired() === 0) {
+ // Empty optional value: remove the row so the read
+ // payload omits the key (matches scenario "omitted
+ // keys are not deleted" by allowing explicit empty
+ // to clear the value — keeps client UX coherent).
+ $existing = $this->valueMapper->findOne(
+ dashboardUuid: $dashboardUuid,
+ fieldId: (int)$field->getId()
+ );
+ if ($existing !== null) {
+ $this->valueMapper->delete(entity: $existing);
+ }
+
+ continue;
+ }
+
+ $this->valueMapper->upsert(
+ dashboardUuid: $dashboardUuid,
+ fieldId: (int)$field->getId(),
+ value: $encoded
+ );
+ }//end foreach
+
+ return $this->getMetadataForDashboard(dashboardUuid: $dashboardUuid);
+ }//end setMetadataForDashboard()
+
+ /**
+ * Apply `?metadata.=…` filters to a dashboard list
+ * (REQ-MDFL-007). Filter keys not registered as fields are
+ * ignored (so a stale URL never silently empties the list when
+ * a field is deleted).
+ *
+ * Recognised filter shapes per type:
+ * - text / select / boolean — exact-match string
+ * - multi-select — substring of JSON-encoded value
+ * - number — `"min"` / `"max"` keys (inclusive)
+ * - date — `"after"` / `"before"` keys (inclusive)
+ *
+ * @param array $dashboards The candidate dashboards
+ * (each MUST expose
+ * `getUuid()`).
+ * @param array $metadataFilters The filter set
+ * (raw
+ * `metadata.`
+ * map).
+ *
+ * @return array The filtered subset.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function filterDashboards(
+ array $dashboards,
+ array $metadataFilters,
+ ): array {
+ if (count($metadataFilters) === 0 || count($dashboards) === 0) {
+ return $dashboards;
+ }
+
+ $resolved = $this->resolveFilterCriteria(metadataFilters: $metadataFilters);
+ if (count($resolved) === 0) {
+ return $dashboards;
+ }
+
+ $matchingByField = $this->collectMatchingUuidSets(resolved: $resolved);
+
+ // AND the per-filter sets together.
+ $intersection = self::intersectUuidSets(matchingByField: $matchingByField);
+
+ return self::selectDashboardsByUuid(
+ dashboards: $dashboards,
+ intersection: $intersection
+ );
+ }//end filterDashboards()
+
+ /**
+ * Resolve raw `metadata.` filter keys to their field definitions.
+ *
+ * Keys that no longer correspond to a registered field are dropped
+ * rather than treated as "matches nothing", so a stale bookmarked URL
+ * never silently empties the list (REQ-MDFL-007).
+ *
+ * @param array $metadataFilters The raw filter map.
+ *
+ * @return array The
+ * resolved
+ * filter
+ * pairs.
+ */
+ private function resolveFilterCriteria(array $metadataFilters): array {
+ $resolved = [];
+ foreach ($metadataFilters as $key => $criterion) {
+ try {
+ $field = $this->fieldMapper->findByKey(key: (string)$key);
+ } catch (DoesNotExistException) {
+ continue;
+ }
+
+ $resolved[] = ['field' => $field, 'criterion' => $criterion];
+ }
+
+ return $resolved;
+ }//end resolveFilterCriteria()
+
+ /**
+ * Build one dashboard-UUID set per resolved filter.
+ *
+ * Each set holds the UUIDs whose stored value satisfies that single
+ * filter; the caller intersects them to get the AND semantics.
+ *
+ * @param array $resolved The
+ * resolved
+ * filter
+ * pairs.
+ *
+ * @return array> One UUID set per filter.
+ */
+ private function collectMatchingUuidSets(array $resolved): array {
+ $matchingByField = [];
+ foreach ($resolved as $entry) {
+ $field = $entry['field'];
+ $criterion = $entry['criterion'];
+
+ $matching = [];
+ foreach ($this->valueMapper->findByField(fieldId: (int)$field->getId()) as $row) {
+ if (self::matchesCriterion(
+ field: $field,
+ storedValue: $row->getValue(),
+ criterion: $criterion
+ ) === true
+ ) {
+ $matching[$row->getDashboardUuid()] = true;
+ }
+ }
+
+ $matchingByField[] = $matching;
+ }
+
+ return $matchingByField;
+ }//end collectMatchingUuidSets()
+
+ /**
+ * Intersect the per-filter UUID sets into a single AND-ed set.
+ *
+ * @param array> $matchingByField One UUID set
+ * per filter
+ * (MUST be
+ * non-empty).
+ *
+ * @return array The UUIDs satisfying every filter.
+ */
+ private static function intersectUuidSets(array $matchingByField): array {
+ $intersection = $matchingByField[0];
+ $matchingCount = count($matchingByField);
+ for ($i = 1; $i < $matchingCount; $i++) {
+ $intersection = array_intersect_key(
+ $intersection,
+ $matchingByField[$i]
+ );
+ }
+
+ return $intersection;
+ }//end intersectUuidSets()
+
+ /**
+ * Keep only the dashboards whose UUID survived the intersection.
+ *
+ * Dashboards with no resolvable UUID are dropped — they cannot be
+ * proven to satisfy the filter set.
+ *
+ * @param array $dashboards The candidate dashboards.
+ * @param array $intersection The surviving UUID set.
+ *
+ * @return array The filtered subset.
+ */
+ private static function selectDashboardsByUuid(
+ array $dashboards,
+ array $intersection,
+ ): array {
+ $filtered = [];
+ foreach ($dashboards as $dashboard) {
+ $uuid = self::extractUuid(dashboard: $dashboard);
+ if ($uuid === null) {
+ continue;
+ }
+
+ if (array_key_exists(key: $uuid, array: $intersection) === true) {
+ $filtered[] = $dashboard;
+ }
+ }
+
+ return $filtered;
+ }//end selectDashboardsByUuid()
+
+ /**
+ * Pull a UUID off a dashboard entity OR a serialised array row.
+ *
+ * @param mixed $dashboard The candidate dashboard.
+ *
+ * @return string|null The UUID or null when unresolvable.
+ */
+ private static function extractUuid(mixed $dashboard): ?string {
+ if (is_object($dashboard) === true && method_exists($dashboard, 'getUuid') === true) {
+ $uuid = $dashboard->getUuid();
+ if ($uuid === null) {
+ return null;
+ }
+
+ return (string)$uuid;
+ }
+
+ if (is_array($dashboard) === true && array_key_exists(key: 'uuid', array: $dashboard) === true) {
+ return (string)$dashboard['uuid'];
+ }
+
+ return null;
+ }//end extractUuid()
+
+ /**
+ * Per-criterion match logic.
+ *
+ * @param MetadataField $field The field definition.
+ * @param string $storedValue The persisted value string.
+ * @param mixed $criterion The raw filter value.
+ *
+ * @return bool True when the value satisfies the criterion.
+ */
+ private static function matchesCriterion(
+ MetadataField $field,
+ string $storedValue,
+ mixed $criterion,
+ ): bool {
+ return match ($field->getType()) {
+ MetadataField::TYPE_NUMBER => self::matchesNumberRange(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ MetadataField::TYPE_DATE => self::matchesDateRange(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ MetadataField::TYPE_MULTI_SELECT => self::matchesMultiSelect(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ default => self::matchesExact(
+ stored: $storedValue,
+ criterion: $criterion
+ ),
+ };
+ }//end matchesCriterion()
+
+ /**
+ * Exact-string match for text / select / boolean.
+ *
+ * @param string $stored The persisted value.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on equality.
+ */
+ private static function matchesExact(string $stored, mixed $criterion): bool {
+ if (is_array($criterion) === true) {
+ return false;
+ }
+
+ return ($stored === (string)$criterion);
+ }//end matchesExact()
+
+ /**
+ * Numeric range filter — supports `min` / `max` (inclusive) or
+ * scalar exact-match.
+ *
+ * @param string $stored The persisted decimal string.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesNumberRange(string $stored, mixed $criterion): bool {
+ if (is_numeric(value: $stored) === false) {
+ return false;
+ }
+
+ $value = (float)$stored;
+
+ if (is_array($criterion) === true) {
+ if (array_key_exists(key: 'min', array: $criterion) === true
+ && $value < (float)$criterion['min']
+ ) {
+ return false;
+ }
+
+ if (array_key_exists(key: 'max', array: $criterion) === true
+ && $value > (float)$criterion['max']
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (is_numeric(value: $criterion) === false) {
+ return false;
+ }
+
+ return ($value === (float)$criterion);
+ }//end matchesNumberRange()
+
+ /**
+ * Date range filter — supports `after` / `before` (inclusive) or
+ * exact-match.
+ *
+ * @param string $stored The persisted ISO-8601 date.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesDateRange(string $stored, mixed $criterion): bool {
+ if (is_array($criterion) === true) {
+ if (array_key_exists(key: 'after', array: $criterion) === true
+ && strcmp(
+ string1: $stored,
+ string2: (string)$criterion['after']
+ ) < 0
+ ) {
+ return false;
+ }
+
+ if (array_key_exists(key: 'before', array: $criterion) === true
+ && strcmp(
+ string1: $stored,
+ string2: (string)$criterion['before']
+ ) > 0
+ ) {
+ return false;
+ }
+
+ return true;
+ }//end if
+
+ return ($stored === (string)$criterion);
+ }//end matchesDateRange()
+
+ /**
+ * Multi-select containment: the stored value is a JSON array;
+ * the criterion is matched as substring of the JSON encoding so
+ * `?metadata.tags=news` matches an array containing `"news"`.
+ *
+ * Documented limitation in design.md (D6 risks): the substring
+ * match is sufficient for the MVP; v2 will switch to JSON_CONTAINS
+ * or PHP-side array containment.
+ *
+ * @param string $stored The persisted JSON-array string.
+ * @param mixed $criterion The filter value.
+ *
+ * @return bool True on match.
+ */
+ private static function matchesMultiSelect(string $stored, mixed $criterion): bool {
+ if (is_array($criterion) === true) {
+ return false;
+ }
+
+ $needle = (string)$criterion;
+ $decoded = json_decode(json: $stored, associative: true);
+ if (is_array($decoded) === true) {
+ return in_array(needle: $needle, haystack: $decoded, strict: true);
+ }
+
+ return str_contains(haystack: $stored, needle: $needle);
+ }//end matchesMultiSelect()
+
+ /**
+ * Validate the slug shape: lowercase alphanumeric + underscore,
+ * 1..MAX_KEY_LENGTH characters.
+ *
+ * @param string $key The candidate slug.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function assertKeyShape(string $key): void {
+ if ($key === '' || strlen(string: $key) > self::MAX_KEY_LENGTH) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key must be 1..' . self::MAX_KEY_LENGTH . ' characters'
+ );
+ }
+
+ if (preg_match(pattern: '/^[a-z0-9_]+$/', subject: $key) !== 1) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field key must be lowercase alphanumeric with underscores only'
+ );
+ }
+ }//end assertKeyShape()
+
+ /**
+ * Validate label length.
+ *
+ * @param string $label The candidate label.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function assertLabelShape(string $label): void {
+ if ($label === '' || strlen(string: $label) > self::MAX_LABEL_LENGTH) {
+ throw new InvalidMetadataFieldException(
+ message: 'Field label must be 1..' . self::MAX_LABEL_LENGTH . ' characters'
+ );
+ }
+ }//end assertLabelShape()
+
+ /**
+ * Validate that the type is in the supported enum.
+ *
+ * @param string $type The candidate type.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When unsupported.
+ */
+ private static function assertTypeShape(string $type): void {
+ if (in_array(needle: $type, haystack: MetadataField::VALID_TYPES, strict: true) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Unsupported field type '" . $type . "'"
+ );
+ }
+ }//end assertTypeShape()
+
+ /**
+ * Validate that `options` matches the type: select types REQUIRE
+ * a non-empty string array; non-select types REQUIRE NULL.
+ *
+ * @param string $type The field type.
+ * @param array|null $options The candidate options (validated entry-by-entry).
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When mismatched.
+ */
+ private static function assertOptionsShape(string $type, ?array $options): void {
+ $isSelect = ($type === MetadataField::TYPE_SELECT
+ || $type === MetadataField::TYPE_MULTI_SELECT);
+
+ if ($isSelect === true) {
+ self::assertSelectOptions(options: $options);
+ return;
+ }
+
+ if ($options !== null && count($options) > 0) {
+ throw new InvalidMetadataFieldException(
+ message: "Type '" . $type . "' does not support options"
+ );
+ }
+ }//end assertOptionsShape()
+
+ /**
+ * Validate the `options` array of a select / multi-select field.
+ *
+ * The list MUST be present, non-empty, and hold only non-empty
+ * strings — an entry-by-entry runtime check because callers can hand
+ * in an arbitrarily shaped decoded JSON array.
+ *
+ * @param array|null $options The candidate options.
+ *
+ * @return void
+ *
+ * @throws InvalidMetadataFieldException When missing, empty, or holding
+ * a non-string / empty entry.
+ */
+ private static function assertSelectOptions(?array $options): void {
+ if ($options === null || count($options) === 0) {
+ throw new InvalidMetadataFieldException(
+ message: 'Select type requires non-empty options array'
+ );
+ }
+
+ // Defensive runtime check: callers may pass non-string entries.
+ foreach ($options as $option) {
+ if (is_string($option) === false || $option === '') {
+ throw new InvalidMetadataFieldException(
+ message: 'Select options must be non-empty strings'
+ );
+ }
+ }
+ }//end assertSelectOptions()
}//end class
diff --git a/lib/Service/MetadataValidationService.php b/lib/Service/MetadataValidationService.php
index 24d0b7a56..ad17ceda8 100644
--- a/lib/Service/MetadataValidationService.php
+++ b/lib/Service/MetadataValidationService.php
@@ -22,8 +22,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -36,246 +36,237 @@
/**
* Per-type value validation for the dashboard-metadata-fields capability.
*/
-class MetadataValidationService
-{
- /**
- * Validate `$value` against the field's type and required flag.
- *
- * @param mixed $value The raw incoming value (string, array,
- * null, etc.).
- * @param MetadataField $field The field definition.
- *
- * @return string The canonical encoded string ready for persistence.
- *
- * @throws InvalidMetadataFieldException When the value is invalid.
- *
- * @spec openspec/specs/dashboard-metadata-fields/spec.md
- */
- public function validateValue(mixed $value, MetadataField $field): string
- {
- if (self::isEmpty(value: $value) === true) {
- if ($field->getRequired() === 1) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' is required"
- );
- }
+class MetadataValidationService {
+ /**
+ * Validate `$value` against the field's type and required flag.
+ *
+ * @param mixed $value The raw incoming value (string, array,
+ * null, etc.).
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical encoded string ready for persistence.
+ *
+ * @throws InvalidMetadataFieldException When the value is invalid.
+ *
+ * @spec openspec/specs/dashboard-metadata-fields/spec.md
+ */
+ public function validateValue(mixed $value, MetadataField $field): string {
+ if (self::isEmpty(value: $value) === true) {
+ if ($field->getRequired() === 1) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' is required"
+ );
+ }
- return '';
- }
+ return '';
+ }
- return match ($field->getType()) {
- MetadataField::TYPE_TEXT => self::asString(value: $value),
- MetadataField::TYPE_NUMBER => self::validateNumber(value: $value, field: $field),
- MetadataField::TYPE_DATE => self::validateDate(value: $value, field: $field),
- MetadataField::TYPE_SELECT => self::validateSelect(value: $value, field: $field),
- MetadataField::TYPE_MULTI_SELECT => self::validateMultiSelect(value: $value, field: $field),
- MetadataField::TYPE_BOOLEAN => self::validateBoolean(value: $value, field: $field),
- default => throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' has unknown type '".$field->getType()."'"
- ),
- };
- }//end validateValue()
+ return match ($field->getType()) {
+ MetadataField::TYPE_TEXT => self::asString(value: $value),
+ MetadataField::TYPE_NUMBER => self::validateNumber(value: $value, field: $field),
+ MetadataField::TYPE_DATE => self::validateDate(value: $value, field: $field),
+ MetadataField::TYPE_SELECT => self::validateSelect(value: $value, field: $field),
+ MetadataField::TYPE_MULTI_SELECT => self::validateMultiSelect(value: $value, field: $field),
+ MetadataField::TYPE_BOOLEAN => self::validateBoolean(value: $value, field: $field),
+ default => throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' has unknown type '" . $field->getType() . "'"
+ ),
+ };
+ }//end validateValue()
- /**
- * Returns true when the value is null, empty string, or empty array.
- *
- * @param mixed $value The candidate value.
- *
- * @return bool True when treated as missing.
- */
- private static function isEmpty(mixed $value): bool
- {
- if ($value === null) {
- return true;
- }
+ /**
+ * Returns true when the value is null, empty string, or empty array.
+ *
+ * @param mixed $value The candidate value.
+ *
+ * @return bool True when treated as missing.
+ */
+ private static function isEmpty(mixed $value): bool {
+ if ($value === null) {
+ return true;
+ }
- if (is_string($value) === true && $value === '') {
- return true;
- }
+ if (is_string($value) === true && $value === '') {
+ return true;
+ }
- if (is_array($value) === true && count($value) === 0) {
- return true;
- }
+ if (is_array($value) === true && count($value) === 0) {
+ return true;
+ }
- return false;
- }//end isEmpty()
+ return false;
+ }//end isEmpty()
- /**
- * Cast scalar values to string (text type fall-through).
- *
- * @param mixed $value The candidate value.
- *
- * @return string The string form.
- */
- private static function asString(mixed $value): string
- {
- if (is_string($value) === true) {
- return $value;
- }
+ /**
+ * Cast scalar values to string (text type fall-through).
+ *
+ * @param mixed $value The candidate value.
+ *
+ * @return string The string form.
+ */
+ private static function asString(mixed $value): string {
+ if (is_string($value) === true) {
+ return $value;
+ }
- if (is_int($value) === true || is_float($value) === true) {
- return (string) $value;
- }
+ if (is_int($value) === true || is_float($value) === true) {
+ return (string)$value;
+ }
- if (is_bool($value) === true) {
- if ($value === true) {
- return '1';
- }
+ if (is_bool($value) === true) {
+ if ($value === true) {
+ return '1';
+ }
- return '0';
- }
+ return '0';
+ }
- return '';
- }//end asString()
+ return '';
+ }//end asString()
- /**
- * Number-type validation: numeric string only.
- *
- * @param mixed $value The candidate value.
- * @param MetadataField $field The field definition.
- *
- * @return string The canonical numeric string.
- *
- * @throws InvalidMetadataFieldException When non-numeric.
- */
- private static function validateNumber(mixed $value, MetadataField $field): string
- {
- $candidate = self::asString(value: $value);
- if (is_numeric(value: $candidate) === false) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' must be a valid number"
- );
- }
+ /**
+ * Number-type validation: numeric string only.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical numeric string.
+ *
+ * @throws InvalidMetadataFieldException When non-numeric.
+ */
+ private static function validateNumber(mixed $value, MetadataField $field): string {
+ $candidate = self::asString(value: $value);
+ if (is_numeric(value: $candidate) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' must be a valid number"
+ );
+ }
- return $candidate;
- }//end validateNumber()
+ return $candidate;
+ }//end validateNumber()
- /**
- * Date-type validation: strict YYYY-MM-DD.
- *
- * @param mixed $value The candidate value.
- * @param MetadataField $field The field definition.
- *
- * @return string The validated date string.
- *
- * @throws InvalidMetadataFieldException When malformed.
- */
- private static function validateDate(mixed $value, MetadataField $field): string
- {
- $candidate = self::asString(value: $value);
- if (preg_match(pattern: '/^\d{4}-\d{2}-\d{2}$/', subject: $candidate) !== 1) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' must be a valid date (YYYY-MM-DD)"
- );
- }
+ /**
+ * Date-type validation: strict YYYY-MM-DD.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The validated date string.
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function validateDate(mixed $value, MetadataField $field): string {
+ $candidate = self::asString(value: $value);
+ if (preg_match(pattern: '/^\d{4}-\d{2}-\d{2}$/', subject: $candidate) !== 1) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' must be a valid date (YYYY-MM-DD)"
+ );
+ }
- $parts = explode(separator: '-', string: $candidate);
- $check = checkdate(
- month: (int) $parts[1],
- day: (int) $parts[2],
- year: (int) $parts[0]
- );
- if ($check === false) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' must be a valid date (YYYY-MM-DD)"
- );
- }
+ $parts = explode(separator: '-', string: $candidate);
+ $check = checkdate(
+ month: (int)$parts[1],
+ day: (int)$parts[2],
+ year: (int)$parts[0]
+ );
+ if ($check === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' must be a valid date (YYYY-MM-DD)"
+ );
+ }
- return $candidate;
- }//end validateDate()
+ return $candidate;
+ }//end validateDate()
- /**
- * Select-type validation: value MUST be in the option set.
- *
- * @param mixed $value The candidate value.
- * @param MetadataField $field The field definition.
- *
- * @return string The validated option string.
- *
- * @throws InvalidMetadataFieldException When out of set.
- */
- private static function validateSelect(mixed $value, MetadataField $field): string
- {
- $candidate = self::asString(value: $value);
- $options = $field->getOptionsArray();
- if (in_array(needle: $candidate, haystack: $options, strict: true) === false) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' value '".$candidate."' not in allowed options"
- );
- }
+ /**
+ * Select-type validation: value MUST be in the option set.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The validated option string.
+ *
+ * @throws InvalidMetadataFieldException When out of set.
+ */
+ private static function validateSelect(mixed $value, MetadataField $field): string {
+ $candidate = self::asString(value: $value);
+ $options = $field->getOptionsArray();
+ if (in_array(needle: $candidate, haystack: $options, strict: true) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' value '" . $candidate . "' not in allowed options"
+ );
+ }
- return $candidate;
- }//end validateSelect()
+ return $candidate;
+ }//end validateSelect()
- /**
- * Multi-select validation: JSON array of strings, each in option set.
- *
- * Accepts either a PHP array directly or a JSON-encoded array string.
- *
- * @param mixed $value The candidate value.
- * @param MetadataField $field The field definition.
- *
- * @return string The canonical JSON-array string.
- *
- * @throws InvalidMetadataFieldException When malformed or out of set.
- */
- private static function validateMultiSelect(mixed $value, MetadataField $field): string
- {
- $items = $value;
- if (is_string($items) === true) {
- $decoded = json_decode(json: $items, associative: true);
- if (is_array($decoded) === true) {
- $items = $decoded;
- }
- }
+ /**
+ * Multi-select validation: JSON array of strings, each in option set.
+ *
+ * Accepts either a PHP array directly or a JSON-encoded array string.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical JSON-array string.
+ *
+ * @throws InvalidMetadataFieldException When malformed or out of set.
+ */
+ private static function validateMultiSelect(mixed $value, MetadataField $field): string {
+ $items = $value;
+ if (is_string($items) === true) {
+ $decoded = json_decode(json: $items, associative: true);
+ if (is_array($decoded) === true) {
+ $items = $decoded;
+ }
+ }
- if (is_array($items) === false) {
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' must be an array of options"
- );
- }
+ if (is_array($items) === false) {
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' must be an array of options"
+ );
+ }
- $options = $field->getOptionsArray();
- foreach ($items as $entry) {
- if (is_string($entry) === false
- || in_array(needle: $entry, haystack: $options, strict: true) === false
- ) {
- $rendered = '';
- if (is_string($entry) === true) {
- $rendered = $entry;
- }
+ $options = $field->getOptionsArray();
+ foreach ($items as $entry) {
+ if (is_string($entry) === false
+ || in_array(needle: $entry, haystack: $options, strict: true) === false
+ ) {
+ $rendered = '';
+ if (is_string($entry) === true) {
+ $rendered = $entry;
+ }
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' value '".$rendered."' not in allowed options"
- );
- }
- }
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' value '" . $rendered . "' not in allowed options"
+ );
+ }
+ }
- return (string) json_encode($items);
- }//end validateMultiSelect()
+ return (string)json_encode($items);
+ }//end validateMultiSelect()
- /**
- * Boolean validation: only literal `"0"` or `"1"` accepted.
- *
- * @param mixed $value The candidate value.
- * @param MetadataField $field The field definition.
- *
- * @return string The canonical `"0"` / `"1"`.
- *
- * @throws InvalidMetadataFieldException When malformed.
- */
- private static function validateBoolean(mixed $value, MetadataField $field): string
- {
- if ($value === true || $value === 1 || $value === '1') {
- return '1';
- }
+ /**
+ * Boolean validation: only literal `"0"` or `"1"` accepted.
+ *
+ * @param mixed $value The candidate value.
+ * @param MetadataField $field The field definition.
+ *
+ * @return string The canonical `"0"` / `"1"`.
+ *
+ * @throws InvalidMetadataFieldException When malformed.
+ */
+ private static function validateBoolean(mixed $value, MetadataField $field): string {
+ if ($value === true || $value === 1 || $value === '1') {
+ return '1';
+ }
- if ($value === false || $value === 0 || $value === '0') {
- return '0';
- }
+ if ($value === false || $value === 0 || $value === '0') {
+ return '0';
+ }
- throw new InvalidMetadataFieldException(
- message: "Field '".$field->getLabel()."' must be boolean (\"0\" or \"1\")"
- );
- }//end validateBoolean()
+ throw new InvalidMetadataFieldException(
+ message: "Field '" . $field->getLabel() . "' must be boolean (\"0\" or \"1\")"
+ );
+ }//end validateBoolean()
}//end class
diff --git a/lib/Service/NewsWidgetService.php b/lib/Service/NewsWidgetService.php
index 580fdf65c..d842e0a74 100644
--- a/lib/Service/NewsWidgetService.php
+++ b/lib/Service/NewsWidgetService.php
@@ -14,12 +14,12 @@
* @package OCA\LaunchPad\Service
* @author Conduction b.v.
* @copyright 2026 Conduction b.v.
- * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2
+ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -51,1006 +51,1018 @@
* reducing the surface that the news-widget capability has to
* present to callers.
*/
-class NewsWidgetService
-{
-
- /**
- * Default per-feed cache TTL in seconds (60 minutes). Overridden at
- * runtime by the app-config key
- * `launchpad.news_widget_feed_cache_ttl_seconds`.
- */
- private const DEFAULT_CACHE_TTL = 3600;
-
- /**
- * Hard ceiling on the per-request item count. Server-side cap that
- * enforces a safety rail regardless of client-supplied `limit` —
- * see design D4 in `openspec/changes/news-widget/design.md`.
- */
- private const HARD_ITEM_CEILING = 200;
-
- /**
- * Maximum accepted feed response size in bytes (1 MB). Rejects
- * oversized payloads before they reach the XML parser (C1 SSRF
- * DoS guard — REQ-NEWS-008).
- */
- private const MAX_RESPONSE_SIZE_BYTES = 1048576;
-
- /**
- * IAppConfig key for the JSON-encoded allow-list of feed hostnames.
- */
- private const CONFIG_KEY_ALLOWED_HOSTS = 'news_widget_allowed_feed_hosts';
-
- /**
- * HTML tags retained by the summary sanitiser (REQ-NEWS-005).
- *
- * @var array
- */
- private const ALLOWED_SUMMARY_TAGS = [
- 'p',
- 'a',
- 'strong',
- 'em',
- 'br',
- 'ul',
- 'ol',
- 'li',
- ];
-
- /**
- * Per-feed HTTP fetch timeout in seconds (REQ-NEWS-008).
- */
- private const FETCH_TIMEOUT_SECONDS = 10;
-
- /**
- * Lazily resolved {@see ICache} backing the per-feed payload cache.
- * Created on first use so unit tests can construct the service
- * with a stub `ICacheFactory` that never gets called.
- *
- * @var ICache|null
- */
- private ?ICache $cache = null;
-
- /**
- * Constructor.
- *
- * @param WidgetPlacementMapper $placementMapper Resolves placements by id.
- * @param IClientService $clientService Builds the HTTP client used
- * for synchronous on-demand
- * feed fetches.
- * @param IAppConfig $appConfig Reads admin settings:
- * `news_widget_feed_cache_ttl_seconds`
- * and
- * `news_widget_allowed_feed_hosts`.
- * @param ICacheFactory $cacheFactory Backing factory for the
- * distributed cache used to
- * hold raw feed payloads.
- * @param LoggerInterface $logger PSR logger.
- * @param UrlSafetyValidator $urlValidator Shared SSRF / allow-list guard.
- */
- public function __construct(
- private readonly WidgetPlacementMapper $placementMapper,
- private readonly IClientService $clientService,
- private readonly IAppConfig $appConfig,
- private readonly ICacheFactory $cacheFactory,
- private readonly LoggerInterface $logger,
- private readonly UrlSafetyValidator $urlValidator,
- ) {
- }//end __construct()
-
- /**
- * Resolve a placement and return the merged + sanitised feed items
- * for it. Honours the placement's metadata filter — when the filter
- * does not match, an empty array is returned without performing any
- * HTTP fetch (REQ-NEWS-007).
- *
- * @param integer $placementId Placement entity id.
- * @param integer $limit Max items to return; clamped to
- * [1, HARD_ITEM_CEILING].
- *
- * @return array{
- * items: array>,
- * feedsFailed: int,
- * failedUrls: array
- * }
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function getItemsForPlacement(int $placementId, int $limit=10): array
- {
- $clamped = $this->clampLimit(limit: $limit);
-
- try {
- $placement = $this->placementMapper->find(id: $placementId);
- } catch (DoesNotExistException $e) {
- return $this->emptyResponse();
- } catch (Throwable $e) {
- $this->logger->warning(
- message: 'NewsWidget: failed to resolve placement '.$placementId,
- context: ['exception' => $e]
- );
- return $this->emptyResponse();
- }
-
- $config = $this->extractNewsConfig(placement: $placement);
-
- // REQ-NEWS-007: if a metadata filter is configured and does not
- // match the dashboard, short-circuit before any feed fetch.
- if ($config['metadataFilter'] !== null
- && $this->checkMetadataFilter(
- dashboardId: (int) $placement->getDashboardId(),
- metadataFilter: $config['metadataFilter']
- ) === false
- ) {
- return $this->emptyResponse();
- }
-
- return $this->fetchAndMergeFeeds(
- feedUrls: $config['feedUrls'],
- limit: $clamped
- );
- }//end getItemsForPlacement()
-
- /**
- * Parse the placement's persisted JSON content into the canonical
- * news config shape, applying defaults where fields are missing or
- * malformed (REQ-NEWS-002).
- *
- * @param WidgetPlacement $placement The placement whose
- * `styleConfig` JSON column carries
- * the news widget configuration.
- *
- * @return array{
- * feedUrls: array,
- * layout: string,
- * itemLimit: int,
- * showThumbnails: bool,
- * showSummary: bool,
- * summaryMaxChars: int,
- * dateFormat: string,
- * metadataFilter: array{fieldKey: string, value: string}|null
- * }
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function extractNewsConfig(WidgetPlacement $placement): array
- {
- // The unified Add-Widget modal stores per-type config (feedUrls, …) in
- // the `content` column; `style_config` only carries chrome
- // (background/title). Read `content` first, falling back to
- // `style_config` for any legacy placement that stored config there.
- $decoded = $this->decodeStyleConfigBlob(raw: $placement->getContent());
- if ($this->extractFeedUrls(decoded: $decoded) === []) {
- $legacy = $this->decodeStyleConfigBlob(raw: $placement->getStyleConfig());
- if ($this->extractFeedUrls(decoded: $legacy) !== []) {
- $decoded = $legacy;
- }
- }
-
- return [
- 'feedUrls' => $this->extractFeedUrls(decoded: $decoded),
- 'layout' => $this->extractLayout(decoded: $decoded),
- 'itemLimit' => $this->extractIntInRange(
- decoded: $decoded,
- key: 'itemLimit',
- default: 10,
- min: 1,
- max: 50
- ),
- 'showThumbnails' => $this->extractBool(decoded: $decoded, key: 'showThumbnails', default: true),
- 'showSummary' => $this->extractBool(decoded: $decoded, key: 'showSummary', default: true),
- 'summaryMaxChars' => $this->extractIntInRange(
- decoded: $decoded,
- key: 'summaryMaxChars',
- default: 200,
- min: 0,
- max: 5000
- ),
- 'dateFormat' => $this->extractDateFormat(decoded: $decoded),
- 'metadataFilter' => $this->extractMetadataFilter(decoded: $decoded),
- ];
- }//end extractNewsConfig()
-
- /**
- * Parse the widget's persisted JSON blob and unwrap the outer
- * `{type, content}` envelope when present so callers always get
- * the flat content map.
- *
- * @param string|null $raw Raw JSON string from the placement.
- *
- * @return array Decoded content map (empty when invalid).
- */
- private function decodeStyleConfigBlob(?string $raw): array
- {
- if (is_string(value: $raw) === false || $raw === '') {
- return [];
- }
-
- $parsed = json_decode(json: $raw, associative: true);
- if (is_array(value: $parsed) === false) {
- return [];
- }
-
- if (isset($parsed['content']) === true && is_array(value: $parsed['content']) === true) {
- return $parsed['content'];
- }
-
- return $parsed;
- }//end decodeStyleConfigBlob()
-
- /**
- * Extract the configured feed URL list, dropping non-HTTP(S) entries.
- *
- * @param array $decoded Decoded config map.
- *
- * @return array
- */
- private function extractFeedUrls(array $decoded): array
- {
- $candidates = $decoded['feedUrls'] ?? null;
- if (is_array(value: $candidates) === false) {
- return [];
- }
-
- $out = [];
- foreach ($candidates as $candidate) {
- if (is_string(value: $candidate) === false || $candidate === '') {
- continue;
- }
-
- // C1: accept HTTPS only — plain HTTP leaks feed content in
- // transit and bypasses the SSRF guard's scheme check.
- $lower = strtolower(string: $candidate);
- if (str_starts_with(haystack: $lower, needle: 'https://') === true) {
- $out[] = $candidate;
- }
- }
-
- return $out;
- }//end extractFeedUrls()
-
- /**
- * Extract the layout enum (`list` | `grid` | `carousel`).
- *
- * @param array $decoded Decoded config map.
- *
- * @return string
- */
- private function extractLayout(array $decoded): string
- {
- $value = $decoded['layout'] ?? null;
- if (is_string(value: $value) === true
- && in_array(needle: $value, haystack: ['list', 'grid', 'carousel'], strict: true) === true
- ) {
- return $value;
- }
-
- return 'list';
- }//end extractLayout()
-
- /**
- * Extract a clamped int field.
- *
- * @param array $decoded Decoded config map.
- * @param string $key Field key.
- * @param integer $default Default value when missing/invalid.
- * @param integer $min Lower bound.
- * @param integer $max Upper bound.
- *
- * @return integer
- */
- private function extractIntInRange(array $decoded, string $key, int $default, int $min, int $max): int
- {
- $value = $decoded[$key] ?? null;
- if (is_int(value: $value) === false) {
- return $default;
- }
-
- return max($min, min($max, $value));
- }//end extractIntInRange()
-
- /**
- * Extract a boolean field with default fallback.
- *
- * @param array $decoded Decoded config map.
- * @param string $key Field key.
- * @param boolean $default Default value when missing.
- *
- * @return boolean
- */
- private function extractBool(array $decoded, string $key, bool $default): bool
- {
- if (array_key_exists(key: $key, array: $decoded) === false) {
- return $default;
- }
-
- return $decoded[$key] === true;
- }//end extractBool()
-
- /**
- * Extract the date-format enum, defaulting to `relative`.
- *
- * @param array $decoded Decoded config map.
- *
- * @return string `relative` or `absolute`.
- */
- private function extractDateFormat(array $decoded): string
- {
- if (($decoded['dateFormat'] ?? null) === 'absolute') {
- return 'absolute';
- }
-
- return 'relative';
- }//end extractDateFormat()
-
- /**
- * Extract the optional metadata filter `{fieldKey, value}` shape.
- *
- * @param array $decoded Decoded config map.
- *
- * @return array{fieldKey: string, value: string}|null
- */
- private function extractMetadataFilter(array $decoded): ?array
- {
- $filter = $decoded['metadataFilter'] ?? null;
- if (is_array(value: $filter) === false) {
- return null;
- }
-
- $key = $filter['fieldKey'] ?? null;
- $val = $filter['value'] ?? null;
- if (is_string(value: $key) === false || $key === '' || is_string(value: $val) === false) {
- return null;
- }
-
- return ['fieldKey' => $key, 'value' => $val];
- }//end extractMetadataFilter()
-
- /**
- * Fetch each URL (cache-first), parse, merge, deduplicate, sort, and
- * cap at $limit items. Failures of individual URLs are tolerated;
- * the response carries a count + list of failed URLs so the UI can
- * surface a corner badge (REQ-NEWS-008 / REQ-NEWS-010).
- *
- * @param array $feedUrls List of feed URLs (already
- * sanitised against http(s) by
- * {@see extractNewsConfig()}).
- * @param integer $limit Hard cap on returned item count
- * (already clamped).
- *
- * @return array{
- * items: array>,
- * feedsFailed: int,
- * failedUrls: array
- * }
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function fetchAndMergeFeeds(array $feedUrls, int $limit=10): array
- {
- if ($feedUrls === []) {
- return $this->emptyResponse();
- }
-
- $allItems = [];
- $failedUrls = [];
-
- foreach ($feedUrls as $url) {
- // C1: SSRF guard — reject non-HTTPS, private IPs, and any host
- // not in the admin allow-list (default-deny when list is
- // non-empty; open when list is empty per REQ-NEWS-006).
- if ($this->urlValidator->isSafe(url: $url) === false) {
- $this->logger->warning(
- message: 'NewsWidget: URL rejected by SSRF guard',
- context: ['url' => $url]
- );
- $failedUrls[] = $url;
- continue;
- }
-
- if ($this->checkAllowList(url: $url) === false) {
- $this->logger->warning(
- message: 'NewsWidget: URL skipped by allow-list',
- context: ['url' => $url]
- );
- $failedUrls[] = $url;
- continue;
- }
-
- $payload = $this->fetchFeedPayload(url: $url);
- if ($payload === null) {
- $failedUrls[] = $url;
- continue;
- }
-
- try {
- $parsed = $this->parseRssFeed(
- feedContent: $payload,
- sourceUrl: $url,
- sourceTitle: $url
- );
- } catch (Throwable $e) {
- $this->logger->warning(
- message: 'NewsWidget: feed parse failed for '.$url,
- context: ['exception' => $e]
- );
- $failedUrls[] = $url;
- continue;
- }
-
- foreach ($parsed as $item) {
- $allItems[] = $item;
- }
- }//end foreach
-
- $deduped = $this->deduplicateItems(items: $allItems);
- $sorted = $this->sortItemsByDate(items: $deduped);
- $sliced = array_slice(array: $sorted, offset: 0, length: $limit);
-
- return [
- 'items' => $sliced,
- 'feedsFailed' => count(value: $failedUrls),
- 'failedUrls' => array_values(array: $failedUrls),
- ];
- }//end fetchAndMergeFeeds()
-
- /**
- * Parse a raw feed payload (RSS 2.0 or Atom 1.0) into the canonical
- * item shape. Items without a guid receive a synthetic
- * `sha1(title|pubDate|sourceUrl)` identifier (REQ-NEWS-003).
- *
- * @param string $feedContent Raw XML payload.
- * @param string $sourceUrl URL the payload was fetched from.
- * @param string $sourceTitle Display name for the feed; replaced by
- * the channel/feed title when present.
- *
- * @return array> Parsed items.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function parseRssFeed(string $feedContent, string $sourceUrl, string $sourceTitle): array
- {
- if (trim(string: $feedContent) === '') {
- return [];
- }
-
- // C2: LIBXML_NOENT was removed — it resolves (not disables) entities,
- // enabling XXE. LIBXML_NONET blocks external DTD/entity fetches.
- $previousErrors = libxml_use_internal_errors(use_errors: true);
- $xml = simplexml_load_string(
- data: $feedContent,
- class_name: SimpleXMLElement::class,
- options: LIBXML_NONET
- );
- libxml_clear_errors();
- libxml_use_internal_errors(use_errors: $previousErrors);
-
- if ($xml === false) {
- $this->logger->info(
- message: 'NewsWidget: could not parse feed XML for '.$sourceUrl
- );
- return [];
- }
-
- $items = [];
- $rootName = $xml->getName();
-
- // Atom 1.0: … .
- if ($rootName === 'feed') {
- $resolvedTitle = $sourceTitle;
- if (isset($xml->title) === true) {
- $resolvedTitle = (string) $xml->title;
- }
-
- foreach ($xml->entry as $entry) {
- $items[] = $this->normaliseAtomEntry(
- entry: $entry,
- sourceUrl: $sourceUrl,
- sourceTitle: $resolvedTitle
- );
- }
-
- return $items;
- }
-
- // RSS 2.0: … .
- if ($rootName === 'rss' && isset($xml->channel) === true) {
- $channel = $xml->channel;
- $resolvedTitle = $sourceTitle;
- if (isset($channel->title) === true) {
- $resolvedTitle = (string) $channel->title;
- }
-
- foreach ($channel->item as $item) {
- $items[] = $this->normaliseRssItem(
- item: $item,
- sourceUrl: $sourceUrl,
- sourceTitle: $resolvedTitle
- );
- }
-
- return $items;
- }
-
- // Permit a bare wrapper (some publishers ship without ).
- if ($rootName === 'channel') {
- $resolvedTitle = $sourceTitle;
- if (isset($xml->title) === true) {
- $resolvedTitle = (string) $xml->title;
- }
-
- foreach ($xml->item as $item) {
- $items[] = $this->normaliseRssItem(
- item: $item,
- sourceUrl: $sourceUrl,
- sourceTitle: $resolvedTitle
- );
- }
-
- return $items;
- }
-
- $this->logger->info(
- message: 'NewsWidget: unsupported feed root <'.$rootName.'> for '.$sourceUrl
- );
- return [];
- }//end parseRssFeed()
-
- /**
- * Deduplicate parsed items by `guid`, keeping the first occurrence
- * (REQ-NEWS-003).
- *
- * @param array> $items Items to dedupe.
- *
- * @return array> Deduped items.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function deduplicateItems(array $items): array
- {
- $seen = [];
- $out = [];
- foreach ($items as $item) {
- $guid = '';
- if (isset($item['guid']) === true) {
- $guid = (string) $item['guid'];
- }
-
- if ($guid === '' || isset($seen[$guid]) === true) {
- if ($guid === '') {
- // Items missing a guid bypass the dedupe map but
- // still flow through so we don't lose them; the
- // synthetic guid in normaliseRssItem / normaliseAtomEntry
- // ensures this path is rarely hit.
- $out[] = $item;
- }
-
- continue;
- }
-
- $seen[$guid] = true;
- $out[] = $item;
- }//end foreach
-
- return $out;
- }//end deduplicateItems()
-
- /**
- * Sort items by ISO 8601 `pubDate`, descending (newest first).
- * Items with an invalid or missing pubDate sink to the end, in
- * input order, so the visible feed never silently drops them.
- *
- * @param array> $items Items to sort.
- *
- * @return array> Sorted items.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function sortItemsByDate(array $items): array
- {
- $sortable = $items;
- usort(
- array: $sortable,
- callback: function (array $a, array $b): int {
- $aTs = false;
- if (isset($a['pubDate']) === true) {
- $aTs = strtotime(datetime: (string) $a['pubDate']);
- }
-
- $bTs = false;
- if (isset($b['pubDate']) === true) {
- $bTs = strtotime(datetime: (string) $b['pubDate']);
- }
-
- if ($aTs === false && $bTs === false) {
- return 0;
- }
-
- if ($aTs === false) {
- return 1;
- }
-
- if ($bTs === false) {
- return -1;
- }
-
- return ($bTs <=> $aTs);
- }
- );
-
- return $sortable;
- }//end sortItemsByDate()
-
- /**
- * Allow-list the small set of inline tags safe to render inside a
- * feed item summary, strip everything else, and force `rel` on
- * surviving anchor tags (REQ-NEWS-005).
- *
- * @param string $html Raw summary HTML from a feed item.
- *
- * @return string Sanitised summary HTML.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function sanitiseSummaryHtml(string $html): string
- {
- if ($html === '') {
- return '';
- }
-
- $allowed = '<'.implode(separator: '><', array: self::ALLOWED_SUMMARY_TAGS).'>';
- $stripped = strip_tags(string: $html, allowed_tags: $allowed);
-
- // After tag stripping, any `javascript:` href that survives must
- // be neutralised; reuse PHP's HTML parser via DOMDocument so we
- // can rewrite href attributes safely without false-positives on
- // body text containing the substring.
- // C2: LIBXML_NOENT removed — resolves entities (XXE risk).
- $previousErrors = libxml_use_internal_errors(use_errors: true);
- $document = new DOMDocument();
- $document->loadHTML(
- source: ''.$stripped.'
',
- options: LIBXML_NONET
- );
- libxml_clear_errors();
- libxml_use_internal_errors(use_errors: $previousErrors);
-
- $anchors = $document->getElementsByTagName(qualifiedName: 'a');
- foreach ($anchors as $anchor) {
- $href = $anchor->getAttribute(qualifiedName: 'href');
- $normalised = strtolower(string: trim(string: $href));
- if (str_starts_with(haystack: $normalised, needle: 'javascript:') === true
- || str_starts_with(haystack: $normalised, needle: 'data:') === true
- ) {
- $anchor->setAttribute(qualifiedName: 'href', value: '#');
- }
-
- $anchor->setAttribute(qualifiedName: 'rel', value: 'noopener noreferrer');
- }
-
- // Re-serialise just the wrapper's children so we don't leak
- // from DOMDocument's auto-wrapping.
- $body = $document->getElementsByTagName(qualifiedName: 'div')->item(index: 0);
- if ($body === null) {
- return $stripped;
- }
-
- $out = '';
- foreach ($body->childNodes as $child) {
- $serialised = $document->saveHTML(node: $child);
- if (is_string(value: $serialised) === true) {
- $out .= $serialised;
- }
- }
-
- return $out;
- }//end sanitiseSummaryHtml()
-
- /**
- * Whether the supplied URL's hostname is permitted by the admin
- * allow-list. Empty / unset list means all hosts are allowed
- * (REQ-NEWS-006). Delegates to UrlSafetyValidator.
- *
- * @param string $url Candidate feed URL.
- *
- * @return boolean True when the host is allowed.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function checkAllowList(string $url): bool
- {
- return $this->urlValidator->checkAllowList(
- url: $url,
- appId: Application::APP_ID,
- configKey: self::CONFIG_KEY_ALLOWED_HOSTS
- );
- }//end checkAllowList()
-
- /**
- * Hook for the (out-of-branch) `dashboard-metadata-fields` capability.
- * Until that capability ships, the metadata store is unavailable and
- * the filter result is "no metadata defined" — which means a
- * configured filter never matches and the widget returns no items
- * (REQ-NEWS-007 scenarios "Metadata field missing" and "spec not
- * yet implemented").
- *
- * @param integer $dashboardId The dashboard whose
- * metadata to consult.
- * @param array{fieldKey: string, value: string} $metadataFilter The filter to apply.
- *
- * @return boolean True when the filter passes (allow fetch); false otherwise.
- *
- * @spec openspec/specs/news-widget/spec.md
- */
- public function checkMetadataFilter(int $dashboardId, array $metadataFilter): bool
- {
- unset($dashboardId);
- unset($metadataFilter);
-
- // REQ-NEWS-007: gracefully treat missing dashboard-metadata-fields
- // implementation as "no fields defined", which causes any
- // configured equality filter to fail.
- return false;
- }//end checkMetadataFilter()
-
- /**
- * Clamp a caller-requested `limit` to the supported range [1, 200].
- * The hard ceiling guards against runaway feeds. Out-of-range values
- * collapse to the closer bound rather than returning an error so the
- * widget always renders something.
- *
- * @param integer $limit Caller-requested limit.
- *
- * @return integer Clamped limit.
- */
- private function clampLimit(int $limit): int
- {
- if ($limit < 1) {
- return 1;
- }
-
- return min(self::HARD_ITEM_CEILING, $limit);
- }//end clampLimit()
-
- /**
- * Cache-first feed fetch. Returns `null` on any failure so the
- * caller can record the URL as failed without short-circuiting the
- * other URLs in the batch.
- *
- * @param string $url Feed URL to fetch.
- *
- * @return string|null Raw feed payload, or null on failure.
- */
- private function fetchFeedPayload(string $url): ?string
- {
- $cache = $this->getCache();
- $cacheKey = 'feed_'.sha1(string: $url);
-
- if ($cache !== null) {
- $cached = $cache->get(key: $cacheKey);
- if (is_string(value: $cached) === true && $cached !== '') {
- return $cached;
- }
- }
-
- try {
- $client = $this->clientService->newClient();
- $response = $client->get(
- uri: $url,
- options: [
- 'timeout' => self::FETCH_TIMEOUT_SECONDS,
- 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS,
- 'verify' => true,
- // C1/H3: disable redirect-following so an attacker
- // cannot chain a public URL to an internal redirect
- // target (SSRF via open redirect).
- 'allow_redirects' => false,
- ]
- );
-
- $statusCode = $response->getStatusCode();
- if ($statusCode < 200 || $statusCode >= 300) {
- $this->logger->warning(
- message: 'NewsWidget: feed fetch returned HTTP '.$statusCode.' for '.$url
- );
- return null;
- }
-
- $body = (string) $response->getBody();
- if ($body === '') {
- return null;
- }
-
- // C1: body cap — reject oversized payloads before XML parse.
- if (strlen(string: $body) > self::MAX_RESPONSE_SIZE_BYTES) {
- $this->logger->warning(
- message: 'NewsWidget: feed response exceeds 1MB cap, skipping '.$url
- );
- return null;
- }
-
- if ($cache !== null) {
- $ttl = $this->appConfig->getValueInt(
- app: Application::APP_ID,
- key: 'news_widget_feed_cache_ttl_seconds',
- default: self::DEFAULT_CACHE_TTL
- );
- $cache->set(key: $cacheKey, value: $body, ttl: $ttl);
- }
-
- return $body;
- } catch (Throwable $e) {
- $this->logger->warning(
- message: 'NewsWidget: feed fetch failed for '.$url,
- context: ['exception' => $e]
- );
- return null;
- }//end try
- }//end fetchFeedPayload()
-
- /**
- * Lazily resolve the distributed cache. Returns `null` when the
- * Nextcloud cache subsystem is unavailable (e.g. unit tests with a
- * stub factory that returns `null`).
- *
- * @return ICache|null
- */
- private function getCache(): ?ICache
- {
- if ($this->cache !== null) {
- return $this->cache;
- }
-
- try {
- $this->cache = $this->cacheFactory->createDistributed(prefix: 'launchpad_news_');
- } catch (Throwable $e) {
- $this->logger->info(
- message: 'NewsWidget: cache subsystem unavailable, falling back to direct fetch',
- context: ['exception' => $e]
- );
- $this->cache = null;
- }
-
- return $this->cache;
- }//end getCache()
-
- /**
- * Convert a single Atom `` into the canonical item shape.
- *
- * @param SimpleXMLElement $entry The Atom entry node.
- * @param string $sourceUrl The feed URL.
- * @param string $sourceTitle The display name for the feed.
- *
- * @return array Canonical item.
- */
- private function normaliseAtomEntry(SimpleXMLElement $entry, string $sourceUrl, string $sourceTitle): array
- {
- $title = '';
- if (isset($entry->title) === true) {
- $title = (string) $entry->title;
- }
-
- $summary = '';
- if (isset($entry->summary) === true) {
- $summary = (string) $entry->summary;
- } else if (isset($entry->content) === true) {
- $summary = (string) $entry->content;
- }
-
- $link = '';
- if (isset($entry->link) === true) {
- // Atom links are .
- $href = $entry->link['href'] ?? null;
- if ($href !== null) {
- $link = (string) $href;
- }
- }
-
- $pubDate = '';
- if (isset($entry->updated) === true) {
- $pubDate = (string) $entry->updated;
- } else if (isset($entry->published) === true) {
- $pubDate = (string) $entry->published;
- }
-
- $guid = '';
- if (isset($entry->id) === true) {
- $guid = (string) $entry->id;
- }
-
- if ($guid === '') {
- $guid = sha1(string: $title.'|'.$pubDate.'|'.$sourceUrl);
- }
-
- return [
- 'guid' => $guid,
- 'title' => $title,
- 'summary' => $this->sanitiseSummaryHtml(html: $summary),
- 'link' => $link,
- 'pubDate' => $pubDate,
- 'sourceUrl' => $sourceUrl,
- 'sourceTitle' => $sourceTitle,
- 'thumbnailUrl' => null,
- ];
- }//end normaliseAtomEntry()
-
- /**
- * Convert a single RSS `- ` into the canonical item shape.
- *
- * @param SimpleXMLElement $item The RSS item node.
- * @param string $sourceUrl The feed URL.
- * @param string $sourceTitle The display name for the feed.
- *
- * @return array
Canonical item.
- */
- private function normaliseRssItem(SimpleXMLElement $item, string $sourceUrl, string $sourceTitle): array
- {
- $title = '';
- if (isset($item->title) === true) {
- $title = (string) $item->title;
- }
-
- $summary = '';
- if (isset($item->description) === true) {
- $summary = (string) $item->description;
- }
-
- $link = '';
- if (isset($item->link) === true) {
- $link = (string) $item->link;
- }
-
- $pubDate = '';
- if (isset($item->pubDate) === true) {
- $pubDate = (string) $item->pubDate;
- }
-
- $guid = '';
- if (isset($item->guid) === true) {
- $guid = (string) $item->guid;
- }
-
- if ($guid === '') {
- $guid = sha1(string: $title.'|'.$pubDate.'|'.$sourceUrl);
- }
-
- $thumbnail = null;
- if (isset($item->enclosure) === true) {
- $url = $item->enclosure['url'] ?? null;
- $type = $item->enclosure['type'] ?? null;
- if ($url !== null
- && ($type === null || str_starts_with(haystack: (string) $type, needle: 'image/') === true)
- ) {
- $thumbnail = (string) $url;
- }
- }
-
- return [
- 'guid' => $guid,
- 'title' => $title,
- 'summary' => $this->sanitiseSummaryHtml(html: $summary),
- 'link' => $link,
- 'pubDate' => $pubDate,
- 'sourceUrl' => $sourceUrl,
- 'sourceTitle' => $sourceTitle,
- 'thumbnailUrl' => $thumbnail,
- ];
- }//end normaliseRssItem()
-
- /**
- * Canonical empty response so callers don't repeat the literal.
- *
- * @return array{
- * items: array>,
- * feedsFailed: int,
- * failedUrls: array
- * }
- */
- private function emptyResponse(): array
- {
- return [
- 'items' => [],
- 'feedsFailed' => 0,
- 'failedUrls' => [],
- ];
- }//end emptyResponse()
+class NewsWidgetService {
+
+ /**
+ * Default per-feed cache TTL in seconds (60 minutes). Overridden at
+ * runtime by the app-config key
+ * `launchpad.news_widget_feed_cache_ttl_seconds`.
+ */
+ private const DEFAULT_CACHE_TTL = 3600;
+
+ /**
+ * Hard ceiling on the per-request item count. Server-side cap that
+ * enforces a safety rail regardless of client-supplied `limit` —
+ * see design D4 in `openspec/changes/news-widget/design.md`.
+ */
+ private const HARD_ITEM_CEILING = 200;
+
+ /**
+ * Maximum accepted feed response size in bytes (1 MB). Rejects
+ * oversized payloads before they reach the XML parser (C1 SSRF
+ * DoS guard — REQ-NEWS-008).
+ */
+ private const MAX_RESPONSE_SIZE_BYTES = 1048576;
+
+ /**
+ * IAppConfig key for the JSON-encoded allow-list of feed hostnames.
+ */
+ private const CONFIG_KEY_ALLOWED_HOSTS = 'news_widget_allowed_feed_hosts';
+
+ /**
+ * HTML tags retained by the summary sanitiser (REQ-NEWS-005).
+ *
+ * @var array
+ */
+ private const ALLOWED_SUMMARY_TAGS = [
+ 'p',
+ 'a',
+ 'strong',
+ 'em',
+ 'br',
+ 'ul',
+ 'ol',
+ 'li',
+ ];
+
+ /**
+ * Per-feed HTTP fetch timeout in seconds (REQ-NEWS-008).
+ */
+ private const FETCH_TIMEOUT_SECONDS = 10;
+
+ /**
+ * Lazily resolved {@see ICache} backing the per-feed payload cache.
+ * Created on first use so unit tests can construct the service
+ * with a stub `ICacheFactory` that never gets called.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param WidgetPlacementMapper $placementMapper Resolves placements by id.
+ * @param IClientService $clientService Builds the HTTP client used
+ * for synchronous on-demand
+ * feed fetches.
+ * @param IAppConfig $appConfig Reads admin settings:
+ * `news_widget_feed_cache_ttl_seconds`
+ * and
+ * `news_widget_allowed_feed_hosts`.
+ * @param ICacheFactory $cacheFactory Backing factory for the
+ * distributed cache used to
+ * hold raw feed payloads.
+ * @param LoggerInterface $logger PSR logger.
+ * @param UrlSafetyValidator $urlValidator Shared SSRF / allow-list guard.
+ */
+ public function __construct(
+ private readonly WidgetPlacementMapper $placementMapper,
+ private readonly IClientService $clientService,
+ private readonly IAppConfig $appConfig,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly LoggerInterface $logger,
+ private readonly UrlSafetyValidator $urlValidator,
+ ) {
+ }//end __construct()
+
+ /**
+ * Resolve a placement and return the merged + sanitised feed items
+ * for it. Honours the placement's metadata filter — when the filter
+ * does not match, an empty array is returned without performing any
+ * HTTP fetch (REQ-NEWS-007).
+ *
+ * @param integer $placementId Placement entity id.
+ * @param integer $limit Max items to return; clamped to
+ * [1, HARD_ITEM_CEILING].
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function getItemsForPlacement(int $placementId, int $limit = 10): array {
+ $clamped = $this->clampLimit(limit: $limit);
+
+ try {
+ $placement = $this->placementMapper->find(id: $placementId);
+ } catch (DoesNotExistException $e) {
+ return $this->emptyResponse();
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: failed to resolve placement ' . $placementId,
+ context: ['exception' => $e]
+ );
+ return $this->emptyResponse();
+ }
+
+ $config = $this->extractNewsConfig(placement: $placement);
+
+ // REQ-NEWS-007: if a metadata filter is configured and does not
+ // match the dashboard, short-circuit before any feed fetch.
+ if ($config['metadataFilter'] !== null
+ && $this->checkMetadataFilter(
+ dashboardId: (int)$placement->getDashboardId(),
+ metadataFilter: $config['metadataFilter']
+ ) === false
+ ) {
+ return $this->emptyResponse();
+ }
+
+ return $this->fetchAndMergeFeeds(
+ feedUrls: $config['feedUrls'],
+ limit: $clamped
+ );
+ }//end getItemsForPlacement()
+
+ /**
+ * Parse the placement's persisted JSON content into the canonical
+ * news config shape, applying defaults where fields are missing or
+ * malformed (REQ-NEWS-002).
+ *
+ * @param WidgetPlacement $placement The placement whose
+ * `styleConfig` JSON column carries
+ * the news widget configuration.
+ *
+ * @return array{
+ * feedUrls: array,
+ * layout: string,
+ * itemLimit: int,
+ * showThumbnails: bool,
+ * showSummary: bool,
+ * summaryMaxChars: int,
+ * dateFormat: string,
+ * metadataFilter: array{fieldKey: string, value: string}|null
+ * }
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function extractNewsConfig(WidgetPlacement $placement): array {
+ // The unified Add-Widget modal stores per-type config (feedUrls, …) in
+ // the `content` column; `style_config` only carries chrome
+ // (background/title). Read `content` first, falling back to
+ // `style_config` for any legacy placement that stored config there.
+ $decoded = $this->decodeStyleConfigBlob(raw: $placement->getContent());
+ if ($decoded === [] || $this->extractFeedUrls(decoded: $decoded) === []) {
+ $legacy = $this->decodeStyleConfigBlob(raw: $placement->getStyleConfig());
+ if ($legacy !== []) {
+ $decoded = $legacy;
+ }
+ }
+
+ return [
+ 'feedUrls' => $this->extractFeedUrls(decoded: $decoded),
+ 'layout' => $this->extractLayout(decoded: $decoded),
+ 'itemLimit' => $this->extractIntInRange(
+ decoded: $decoded,
+ key: 'itemLimit',
+ default: 10,
+ min: 1,
+ max: 50
+ ),
+ 'showThumbnails' => $this->extractBool(decoded: $decoded, key: 'showThumbnails', default: true),
+ 'showSummary' => $this->extractBool(decoded: $decoded, key: 'showSummary', default: true),
+ 'summaryMaxChars' => $this->extractIntInRange(
+ decoded: $decoded,
+ key: 'summaryMaxChars',
+ default: 200,
+ min: 0,
+ max: 5000
+ ),
+ 'dateFormat' => $this->extractDateFormat(decoded: $decoded),
+ 'metadataFilter' => $this->extractMetadataFilter(decoded: $decoded),
+ ];
+ }//end extractNewsConfig()
+
+ /**
+ * Parse the widget's persisted JSON blob and unwrap the outer
+ * `{type, content}` envelope when present so callers always get
+ * the flat content map.
+ *
+ * @param string|null $raw Raw JSON string from the placement.
+ *
+ * @return array Decoded content map (empty when invalid).
+ */
+ private function decodeStyleConfigBlob(?string $raw): array {
+ if (is_string(value: $raw) === false || $raw === '') {
+ return [];
+ }
+
+ $parsed = json_decode(json: $raw, associative: true);
+ if (is_array(value: $parsed) === false) {
+ return [];
+ }
+
+ if (isset($parsed['content']) === true && is_array(value: $parsed['content']) === true) {
+ return $parsed['content'];
+ }
+
+ return $parsed;
+ }//end decodeStyleConfigBlob()
+
+ /**
+ * Extract the configured feed URL list, dropping non-HTTP(S) entries.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return array
+ */
+ private function extractFeedUrls(array $decoded): array {
+ $candidates = $decoded['feedUrls'] ?? null;
+ if (is_array(value: $candidates) === false) {
+ return [];
+ }
+
+ $out = [];
+ foreach ($candidates as $candidate) {
+ if (is_string(value: $candidate) === false || $candidate === '') {
+ continue;
+ }
+
+ // C1: accept HTTPS only — plain HTTP leaks feed content in
+ // transit and bypasses the SSRF guard's scheme check.
+ $lower = strtolower(string: $candidate);
+ if (str_starts_with(haystack: $lower, needle: 'https://') === true) {
+ $out[] = $candidate;
+ }
+ }
+
+ return $out;
+ }//end extractFeedUrls()
+
+ /**
+ * Extract the layout enum (`list` | `grid` | `carousel`).
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return string
+ */
+ private function extractLayout(array $decoded): string {
+ $value = $decoded['layout'] ?? null;
+ if (is_string(value: $value) === true
+ && in_array(needle: $value, haystack: ['list', 'grid', 'carousel'], strict: true) === true
+ ) {
+ return $value;
+ }
+
+ return 'list';
+ }//end extractLayout()
+
+ /**
+ * Extract a clamped int field.
+ *
+ * @param array $decoded Decoded config map.
+ * @param string $key Field key.
+ * @param integer $default Default value when missing/invalid.
+ * @param integer $min Lower bound.
+ * @param integer $max Upper bound.
+ *
+ * @return integer
+ */
+ private function extractIntInRange(array $decoded, string $key, int $default, int $min, int $max): int {
+ $value = $decoded[$key] ?? null;
+ if (is_int(value: $value) === false) {
+ return $default;
+ }
+
+ return max($min, min($max, $value));
+ }//end extractIntInRange()
+
+ /**
+ * Extract a boolean field with default fallback.
+ *
+ * @param array $decoded Decoded config map.
+ * @param string $key Field key.
+ * @param boolean $default Default value when missing.
+ *
+ * @return boolean
+ */
+ private function extractBool(array $decoded, string $key, bool $default): bool {
+ if (array_key_exists(key: $key, array: $decoded) === false) {
+ return $default;
+ }
+
+ return $decoded[$key] === true;
+ }//end extractBool()
+
+ /**
+ * Extract the date-format enum, defaulting to `relative`.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return string `relative` or `absolute`.
+ */
+ private function extractDateFormat(array $decoded): string {
+ if (($decoded['dateFormat'] ?? null) === 'absolute') {
+ return 'absolute';
+ }
+
+ return 'relative';
+ }//end extractDateFormat()
+
+ /**
+ * Extract the optional metadata filter `{fieldKey, value}` shape.
+ *
+ * @param array $decoded Decoded config map.
+ *
+ * @return array{fieldKey: string, value: string}|null
+ */
+ private function extractMetadataFilter(array $decoded): ?array {
+ $filter = $decoded['metadataFilter'] ?? null;
+ if (is_array(value: $filter) === false) {
+ return null;
+ }
+
+ $key = $filter['fieldKey'] ?? null;
+ $val = $filter['value'] ?? null;
+ if (is_string(value: $key) === false || $key === '' || is_string(value: $val) === false) {
+ return null;
+ }
+
+ return ['fieldKey' => $key, 'value' => $val];
+ }//end extractMetadataFilter()
+
+ /**
+ * Fetch each URL (cache-first), parse, merge, deduplicate, sort, and
+ * cap at $limit items. Failures of individual URLs are tolerated;
+ * the response carries a count + list of failed URLs so the UI can
+ * surface a corner badge (REQ-NEWS-008 / REQ-NEWS-010).
+ *
+ * @param array $feedUrls List of feed URLs (already
+ * sanitised against http(s) by
+ * {@see extractNewsConfig()}).
+ * @param integer $limit Hard cap on returned item count
+ * (already clamped).
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function fetchAndMergeFeeds(array $feedUrls, int $limit = 10): array {
+ if ($feedUrls === []) {
+ return $this->emptyResponse();
+ }
+
+ $allItems = [];
+ $failedUrls = [];
+
+ foreach ($feedUrls as $url) {
+ // C1: SSRF guard — reject non-HTTPS, private IPs, and any host
+ // not in the admin allow-list (default-deny when list is
+ // non-empty; open when list is empty per REQ-NEWS-006).
+ if ($this->urlValidator->isSafe(url: $url) === false) {
+ $this->logger->warning(
+ message: 'NewsWidget: URL rejected by SSRF guard',
+ context: ['url' => $url]
+ );
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ if ($this->checkAllowList(url: $url) === false) {
+ $this->logger->warning(
+ message: 'NewsWidget: URL skipped by allow-list',
+ context: ['url' => $url]
+ );
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ $payload = $this->fetchFeedPayload(url: $url);
+ if ($payload === null) {
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ try {
+ $parsed = $this->parseRssFeed(
+ feedContent: $payload,
+ sourceUrl: $url,
+ sourceTitle: $url
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed parse failed for ' . $url,
+ context: ['exception' => $e]
+ );
+ $failedUrls[] = $url;
+ continue;
+ }
+
+ foreach ($parsed as $item) {
+ $allItems[] = $item;
+ }
+ }//end foreach
+
+ $deduped = $this->deduplicateItems(items: $allItems);
+ $sorted = $this->sortItemsByDate(items: $deduped);
+ $sliced = array_slice(array: $sorted, offset: 0, length: $limit);
+
+ return [
+ 'items' => $sliced,
+ 'feedsFailed' => count(value: $failedUrls),
+ // No array_values(): $failedUrls is already a list.
+ 'failedUrls' => $failedUrls,
+ ];
+ }//end fetchAndMergeFeeds()
+
+ /**
+ * Parse a raw feed payload (RSS 2.0 or Atom 1.0) into the canonical
+ * item shape. Items without a guid receive a synthetic
+ * `sha1(title|pubDate|sourceUrl)` identifier (REQ-NEWS-003).
+ *
+ * @param string $feedContent Raw XML payload.
+ * @param string $sourceUrl URL the payload was fetched from.
+ * @param string $sourceTitle Display name for the feed; replaced by
+ * the channel/feed title when present.
+ *
+ * @return array> Parsed items.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function parseRssFeed(string $feedContent, string $sourceUrl, string $sourceTitle): array {
+ if (trim(string: $feedContent) === '') {
+ return [];
+ }
+
+ $xml = $this->loadFeedXml(feedContent: $feedContent, sourceUrl: $sourceUrl);
+ if ($xml === null) {
+ return [];
+ }
+
+ $items = [];
+ $rootName = $xml->getName();
+
+ // Atom 1.0: … .
+ if ($rootName === 'feed') {
+ $resolvedTitle = $sourceTitle;
+ if (isset($xml->title) === true) {
+ $resolvedTitle = (string)$xml->title;
+ }
+
+ foreach ($xml->entry as $entry) {
+ $items[] = $this->normaliseAtomEntry(
+ entry: $entry,
+ sourceUrl: $sourceUrl,
+ sourceTitle: $resolvedTitle
+ );
+ }
+
+ return $items;
+ }
+
+ // RSS 2.0 () plus the bare some publishers ship.
+ $channel = self::resolveRssChannel(xml: $xml, rootName: $rootName);
+ if ($channel === null) {
+ $this->logger->info(
+ message: 'NewsWidget: unsupported feed root <' . $rootName . '> for ' . $sourceUrl
+ );
+ return [];
+ }
+
+ $resolvedTitle = $sourceTitle;
+ if (isset($channel->title) === true) {
+ $resolvedTitle = (string)$channel->title;
+ }
+
+ foreach ($channel->item as $item) {
+ $items[] = $this->normaliseRssItem(
+ item: $item,
+ sourceUrl: $sourceUrl,
+ sourceTitle: $resolvedTitle
+ );
+ }
+
+ return $items;
+ }//end parseRssFeed()
+
+ /**
+ * Parse a raw feed payload, logging and returning null when the XML is
+ * not well-formed so the caller can skip the feed.
+ *
+ * C2: LIBXML_NOENT is deliberately NOT passed — it resolves (not
+ * disables) entities, enabling XXE. LIBXML_NONET blocks external
+ * DTD/entity fetches.
+ *
+ * @param string $feedContent Raw XML payload.
+ * @param string $sourceUrl URL the payload came from (for logging).
+ *
+ * @return SimpleXMLElement|null Parsed document, or null when malformed.
+ */
+ private function loadFeedXml(string $feedContent, string $sourceUrl): ?SimpleXMLElement {
+ $previousErrors = libxml_use_internal_errors(use_errors: true);
+ $xml = simplexml_load_string(
+ data: $feedContent,
+ class_name: SimpleXMLElement::class,
+ options: LIBXML_NONET
+ );
+ libxml_clear_errors();
+ libxml_use_internal_errors(use_errors: $previousErrors);
+
+ if ($xml === false) {
+ $this->logger->info(
+ message: 'NewsWidget: could not parse feed XML for ' . $sourceUrl
+ );
+ return null;
+ }
+
+ return $xml;
+ }//end loadFeedXml()
+
+ /**
+ * Locate the RSS `` node for either supported RSS shape.
+ *
+ * Accepts the standard `` nesting and the bare
+ * `` root some publishers ship without an `` wrapper.
+ *
+ * @param SimpleXMLElement $xml The parsed feed document.
+ * @param string $rootName Name of the document root element.
+ *
+ * @return SimpleXMLElement|null The channel node, or null when the
+ * root is not a supported RSS shape.
+ */
+ private static function resolveRssChannel(SimpleXMLElement $xml, string $rootName): ?SimpleXMLElement {
+ if ($rootName === 'rss' && isset($xml->channel) === true) {
+ return $xml->channel;
+ }
+
+ if ($rootName === 'channel') {
+ return $xml;
+ }
+
+ return null;
+ }//end resolveRssChannel()
+
+ /**
+ * Deduplicate parsed items by `guid`, keeping the first occurrence
+ * (REQ-NEWS-003).
+ *
+ * @param array> $items Items to dedupe.
+ *
+ * @return array> Deduped items.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function deduplicateItems(array $items): array {
+ $seen = [];
+ $out = [];
+ foreach ($items as $item) {
+ $guid = '';
+ if (isset($item['guid']) === true) {
+ $guid = (string)$item['guid'];
+ }
+
+ if ($guid === '' || isset($seen[$guid]) === true) {
+ if ($guid === '') {
+ // Items missing a guid bypass the dedupe map but
+ // still flow through so we don't lose them; the
+ // synthetic guid in normaliseRssItem / normaliseAtomEntry
+ // ensures this path is rarely hit.
+ $out[] = $item;
+ }
+
+ continue;
+ }
+
+ $seen[$guid] = true;
+ $out[] = $item;
+ }//end foreach
+
+ return $out;
+ }//end deduplicateItems()
+
+ /**
+ * Sort items by ISO 8601 `pubDate`, descending (newest first).
+ * Items with an invalid or missing pubDate sink to the end, in
+ * input order, so the visible feed never silently drops them.
+ *
+ * @param array> $items Items to sort.
+ *
+ * @return array> Sorted items.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function sortItemsByDate(array $items): array {
+ $sortable = $items;
+ usort(
+ array: $sortable,
+ callback: function (array $a, array $b): int {
+ $aTs = false;
+ if (isset($a['pubDate']) === true) {
+ $aTs = strtotime(datetime: (string)$a['pubDate']);
+ }
+
+ $bTs = false;
+ if (isset($b['pubDate']) === true) {
+ $bTs = strtotime(datetime: (string)$b['pubDate']);
+ }
+
+ if ($aTs === false && $bTs === false) {
+ return 0;
+ }
+
+ if ($aTs === false) {
+ return 1;
+ }
+
+ if ($bTs === false) {
+ return -1;
+ }
+
+ return ($bTs <=> $aTs);
+ }
+ );
+
+ return $sortable;
+ }//end sortItemsByDate()
+
+ /**
+ * Allow-list the small set of inline tags safe to render inside a
+ * feed item summary, strip everything else, and force `rel` on
+ * surviving anchor tags (REQ-NEWS-005).
+ *
+ * @param string $html Raw summary HTML from a feed item.
+ *
+ * @return string Sanitised summary HTML.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function sanitiseSummaryHtml(string $html): string {
+ if ($html === '') {
+ return '';
+ }
+
+ $allowed = '<' . implode(separator: '><', array: self::ALLOWED_SUMMARY_TAGS) . '>';
+ $stripped = strip_tags(string: $html, allowed_tags: $allowed);
+
+ // After tag stripping, any `javascript:` href that survives must
+ // be neutralised; reuse PHP's HTML parser via DOMDocument so we
+ // can rewrite href attributes safely without false-positives on
+ // body text containing the substring.
+ // C2: LIBXML_NOENT removed — resolves entities (XXE risk).
+ $previousErrors = libxml_use_internal_errors(use_errors: true);
+ $document = new DOMDocument();
+ $document->loadHTML(
+ source: '' . $stripped . '
',
+ options: LIBXML_NONET
+ );
+ libxml_clear_errors();
+ libxml_use_internal_errors(use_errors: $previousErrors);
+
+ $anchors = $document->getElementsByTagName(qualifiedName: 'a');
+ foreach ($anchors as $anchor) {
+ $href = $anchor->getAttribute(qualifiedName: 'href');
+ $normalised = strtolower(string: trim(string: $href));
+ if (str_starts_with(haystack: $normalised, needle: 'javascript:') === true
+ || str_starts_with(haystack: $normalised, needle: 'data:') === true
+ ) {
+ $anchor->setAttribute(qualifiedName: 'href', value: '#');
+ }
+
+ $anchor->setAttribute(qualifiedName: 'rel', value: 'noopener noreferrer');
+ }
+
+ // Re-serialise just the wrapper's children so we don't leak
+ // from DOMDocument's auto-wrapping.
+ $body = $document->getElementsByTagName(qualifiedName: 'div')->item(index: 0);
+ if ($body === null) {
+ return $stripped;
+ }
+
+ $out = '';
+ foreach ($body->childNodes as $child) {
+ $serialised = $document->saveHTML(node: $child);
+ if (is_string(value: $serialised) === true) {
+ $out .= $serialised;
+ }
+ }
+
+ return $out;
+ }//end sanitiseSummaryHtml()
+
+ /**
+ * Whether the supplied URL's hostname is permitted by the admin
+ * allow-list. Empty / unset list means all hosts are allowed
+ * (REQ-NEWS-006). Delegates to UrlSafetyValidator.
+ *
+ * @param string $url Candidate feed URL.
+ *
+ * @return boolean True when the host is allowed.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function checkAllowList(string $url): bool {
+ return $this->urlValidator->checkAllowList(
+ url: $url,
+ appId: Application::APP_ID,
+ configKey: self::CONFIG_KEY_ALLOWED_HOSTS
+ );
+ }//end checkAllowList()
+
+ /**
+ * Hook for the (out-of-branch) `dashboard-metadata-fields` capability.
+ * Until that capability ships, the metadata store is unavailable and
+ * the filter result is "no metadata defined" — which means a
+ * configured filter never matches and the widget returns no items
+ * (REQ-NEWS-007 scenarios "Metadata field missing" and "spec not
+ * yet implemented").
+ *
+ * @param integer $dashboardId The dashboard whose
+ * metadata to consult.
+ * @param array{fieldKey: string, value: string} $metadataFilter The filter to apply.
+ *
+ * @return boolean True when the filter passes (allow fetch); false otherwise.
+ *
+ * @spec openspec/specs/news-widget/spec.md
+ */
+ public function checkMetadataFilter(int $dashboardId, array $metadataFilter): bool {
+ unset($dashboardId);
+ unset($metadataFilter);
+
+ // REQ-NEWS-007: gracefully treat missing dashboard-metadata-fields
+ // implementation as "no fields defined", which causes any
+ // configured equality filter to fail.
+ return false;
+ }//end checkMetadataFilter()
+
+ /**
+ * Clamp a caller-requested `limit` to the supported range [1, 200].
+ * The hard ceiling guards against runaway feeds. Out-of-range values
+ * collapse to the closer bound rather than returning an error so the
+ * widget always renders something.
+ *
+ * @param integer $limit Caller-requested limit.
+ *
+ * @return integer Clamped limit.
+ */
+ private function clampLimit(int $limit): int {
+ if ($limit < 1) {
+ return 1;
+ }
+
+ return min(self::HARD_ITEM_CEILING, $limit);
+ }//end clampLimit()
+
+ /**
+ * Cache-first feed fetch. Returns `null` on any failure so the
+ * caller can record the URL as failed without short-circuiting the
+ * other URLs in the batch.
+ *
+ * @param string $url Feed URL to fetch.
+ *
+ * @return string|null Raw feed payload, or null on failure.
+ */
+ private function fetchFeedPayload(string $url): ?string {
+ $cache = $this->getCache();
+ $cacheKey = 'feed_' . sha1(string: $url);
+
+ $cached = $this->readCachedPayload(cache: $cache, cacheKey: $cacheKey);
+ if ($cached !== null) {
+ return $cached;
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ uri: $url,
+ options: [
+ 'timeout' => self::FETCH_TIMEOUT_SECONDS,
+ 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS,
+ 'verify' => true,
+ // C1/H3: disable redirect-following so an attacker
+ // cannot chain a public URL to an internal redirect
+ // target (SSRF via open redirect).
+ 'allow_redirects' => false,
+ ]
+ );
+
+ $statusCode = $response->getStatusCode();
+ if ($statusCode < 200 || $statusCode >= 300) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed fetch returned HTTP ' . $statusCode . ' for ' . $url
+ );
+ return null;
+ }
+
+ $body = (string)$response->getBody();
+ if ($body === '') {
+ return null;
+ }
+
+ // C1: body cap — reject oversized payloads before XML parse.
+ if (strlen(string: $body) > self::MAX_RESPONSE_SIZE_BYTES) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed response exceeds 1MB cap, skipping ' . $url
+ );
+ return null;
+ }
+
+ if ($cache !== null) {
+ $ttl = $this->appConfig->getValueInt(
+ app: Application::APP_ID,
+ key: 'news_widget_feed_cache_ttl_seconds',
+ default: self::DEFAULT_CACHE_TTL
+ );
+ $cache->set(key: $cacheKey, value: $body, ttl: $ttl);
+ }
+
+ return $body;
+ } catch (Throwable $e) {
+ $this->logger->warning(
+ message: 'NewsWidget: feed fetch failed for ' . $url,
+ context: ['exception' => $e]
+ );
+ return null;
+ }//end try
+ }//end fetchFeedPayload()
+
+ /**
+ * Read a previously cached feed payload.
+ *
+ * Treats a missing cache subsystem, a cache miss, a non-string entry
+ * and an empty entry alike: all mean "no usable payload", so the
+ * caller falls through to a live fetch.
+ *
+ * @param ICache|null $cache The resolved cache, or null when the
+ * subsystem is unavailable.
+ * @param string $cacheKey Cache key for this feed URL.
+ *
+ * @return string|null Cached payload, or null when there is none.
+ */
+ private function readCachedPayload(?ICache $cache, string $cacheKey): ?string {
+ if ($cache === null) {
+ return null;
+ }
+
+ $cached = $cache->get(key: $cacheKey);
+ if (is_string(value: $cached) === true && $cached !== '') {
+ return $cached;
+ }
+
+ return null;
+ }//end readCachedPayload()
+
+ /**
+ * Lazily resolve the distributed cache. Returns `null` when the
+ * Nextcloud cache subsystem is unavailable (e.g. unit tests with a
+ * stub factory that returns `null`).
+ *
+ * @return ICache|null
+ */
+ private function getCache(): ?ICache {
+ if ($this->cache !== null) {
+ return $this->cache;
+ }
+
+ try {
+ $this->cache = $this->cacheFactory->createDistributed(prefix: 'launchpad_news_');
+ } catch (Throwable $e) {
+ $this->logger->info(
+ message: 'NewsWidget: cache subsystem unavailable, falling back to direct fetch',
+ context: ['exception' => $e]
+ );
+ $this->cache = null;
+ }
+
+ return $this->cache;
+ }//end getCache()
+
+ /**
+ * Text of the first child element present out of `$names`.
+ *
+ * Encodes the "optional child, empty string when absent" convention
+ * the feed normalisers apply to every scalar field. A child that
+ * exists but is empty still wins over a later name in the list.
+ *
+ * @param SimpleXMLElement $node Parent node.
+ * @param array $names Child element names, tried in order.
+ *
+ * @return string Text of the first present child, or '' when none is.
+ */
+ private static function childText(SimpleXMLElement $node, array $names): string {
+ foreach ($names as $name) {
+ if (isset($node->$name) === true) {
+ return (string)$node->$name;
+ }
+ }
+
+ return '';
+ }//end childText()
+
+ /**
+ * Convert a single Atom `` into the canonical item shape.
+ *
+ * @param SimpleXMLElement $entry The Atom entry node.
+ * @param string $sourceUrl The feed URL.
+ * @param string $sourceTitle The display name for the feed.
+ *
+ * @return array Canonical item.
+ */
+ private function normaliseAtomEntry(SimpleXMLElement $entry, string $sourceUrl, string $sourceTitle): array {
+ $title = self::childText(node: $entry, names: ['title']);
+ $summary = self::childText(node: $entry, names: ['summary', 'content']);
+ $pubDate = self::childText(node: $entry, names: ['updated', 'published']);
+ $guid = self::childText(node: $entry, names: ['id']);
+
+ $link = '';
+ if (isset($entry->link) === true) {
+ // Atom links are .
+ $href = $entry->link['href'] ?? null;
+ if ($href !== null) {
+ $link = (string)$href;
+ }
+ }
+
+ if ($guid === '') {
+ $guid = sha1(string: $title . '|' . $pubDate . '|' . $sourceUrl);
+ }
+
+ return [
+ 'guid' => $guid,
+ 'title' => $title,
+ 'summary' => $this->sanitiseSummaryHtml(html: $summary),
+ 'link' => $link,
+ 'pubDate' => $pubDate,
+ 'sourceUrl' => $sourceUrl,
+ 'sourceTitle' => $sourceTitle,
+ 'thumbnailUrl' => null,
+ ];
+ }//end normaliseAtomEntry()
+
+ /**
+ * Convert a single RSS `- ` into the canonical item shape.
+ *
+ * @param SimpleXMLElement $item The RSS item node.
+ * @param string $sourceUrl The feed URL.
+ * @param string $sourceTitle The display name for the feed.
+ *
+ * @return array
Canonical item.
+ */
+ private function normaliseRssItem(SimpleXMLElement $item, string $sourceUrl, string $sourceTitle): array {
+ $title = self::childText(node: $item, names: ['title']);
+ $summary = self::childText(node: $item, names: ['description']);
+ $link = self::childText(node: $item, names: ['link']);
+ $pubDate = self::childText(node: $item, names: ['pubDate']);
+ $guid = self::childText(node: $item, names: ['guid']);
+
+ if ($guid === '') {
+ $guid = sha1(string: $title . '|' . $pubDate . '|' . $sourceUrl);
+ }
+
+ $thumbnail = null;
+ if (isset($item->enclosure) === true) {
+ $url = $item->enclosure['url'] ?? null;
+ $type = $item->enclosure['type'] ?? null;
+ if ($url !== null
+ && ($type === null || str_starts_with(haystack: (string)$type, needle: 'image/') === true)
+ ) {
+ $thumbnail = (string)$url;
+ }
+ }
+
+ return [
+ 'guid' => $guid,
+ 'title' => $title,
+ 'summary' => $this->sanitiseSummaryHtml(html: $summary),
+ 'link' => $link,
+ 'pubDate' => $pubDate,
+ 'sourceUrl' => $sourceUrl,
+ 'sourceTitle' => $sourceTitle,
+ 'thumbnailUrl' => $thumbnail,
+ ];
+ }//end normaliseRssItem()
+
+ /**
+ * Canonical empty response so callers don't repeat the literal.
+ *
+ * @return array{
+ * items: array>,
+ * feedsFailed: int,
+ * failedUrls: array
+ * }
+ */
+ private function emptyResponse(): array {
+ return [
+ 'items' => [],
+ 'feedsFailed' => 0,
+ 'failedUrls' => [],
+ ];
+ }//end emptyResponse()
}//end class
diff --git a/lib/Service/OrgNavigationService.php b/lib/Service/OrgNavigationService.php
index 8cca165ce..1e0c735b6 100644
--- a/lib/Service/OrgNavigationService.php
+++ b/lib/Service/OrgNavigationService.php
@@ -24,8 +24,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -42,612 +42,589 @@
/**
* Org-wide navigation tree service.
*
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) IAppData + group
- * resolver are both
- * unavoidable here.
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Validation +
* filtering + persistence
* share one bounded class
* by design.
*/
-class OrgNavigationService
-{
- /**
- * Name of the IAppData subfolder where org-nav JSON files live.
- *
- * @var string
- */
- public const FOLDER = 'org-navigation';
-
- /**
- * Maximum file size accepted on read or write (5 MB; REQ-ONAV-001).
- *
- * @var int
- */
- public const MAX_FILE_BYTES = (5 * 1024 * 1024);
-
- /**
- * Maximum tree depth (root counts as level 1; REQ-ONAV-001/003).
- *
- * @var int
- */
- public const MAX_DEPTH = 3;
-
- /**
- * Default language code used when the caller does not specify
- * `?lang=` (REQ-ONAV-001).
- *
- * @var string
- */
- public const DEFAULT_LANGUAGE = 'nl';
-
- /**
- * Languages supported in v1 of the capability (REQ-ONAV-001).
- *
- * @var array
- */
- public const SUPPORTED_LANGUAGES = ['nl', 'en'];
-
- /**
- * URL schemes rejected outright by the validator
- * (REQ-ONAV-003, REQ-ONAV-011).
- *
- * @var array
- */
- private const FORBIDDEN_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
-
- /**
- * Constructor.
- *
- * @param IAppData $appData Nextcloud app-data
- * accessor for the
- * LaunchPad app.
- * @param AdminTemplateService $templateService Routing resolver — the
- * only allowed wrapper
- * around
- * `IGroupManager::getUserGroupIds`
- * (REQ-TMPL-013).
- */
- public function __construct(
- private readonly IAppData $appData,
- private readonly AdminTemplateService $templateService,
- ) {
- }//end __construct()
-
- /**
- * Read the persisted tree for the given language.
- *
- * Returns `[]` when the file does not yet exist; a corrupted JSON
- * payload also resolves to `[]` so a hand-edited file never bricks
- * the rail (REQ-ONAV-008 empty-state handling).
- *
- * @param string $language Language code (validated against the
- * supported set).
- *
- * @return array> The persisted tree.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- public function getTree(string $language=self::DEFAULT_LANGUAGE): array
- {
- $lang = $this->normaliseLanguage(language: $language);
-
- try {
- $folder = $this->appData->getFolder(name: self::FOLDER);
- } catch (NotFoundException) {
- return [];
- }
-
- try {
- $file = $folder->getFile(name: $this->fileNameFor(language: $lang));
- } catch (NotFoundException) {
- return [];
- }
-
- return $this->decodeFile(file: $file);
- }//end getTree()
-
- /**
- * Persist a validated tree for the given language.
- *
- * Wholesale replacement (REQ-ONAV-003). Validates BEFORE touching
- * storage so a malformed payload never half-writes.
- *
- * @param array $tree The tree to persist.
- * @param string $language Language code.
- *
- * @return void
- *
- * @throws InvalidArgumentException When validation fails (the
- * controller maps to HTTP 400).
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- public function setTree(array $tree, string $language=self::DEFAULT_LANGUAGE): void
- {
- $lang = $this->normaliseLanguage(language: $language);
-
- $this->validateTree(tree: $tree);
-
- $folder = $this->getOrCreateFolder();
- $name = $this->fileNameFor(language: $lang);
-
- $payload = json_encode(value: $tree, flags: (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
- if ($payload === false) {
- throw new InvalidArgumentException(message: 'Failed to encode tree to JSON');
- }
-
- if (strlen(string: $payload) > self::MAX_FILE_BYTES) {
- throw new InvalidArgumentException(
- message: 'Tree exceeds maximum file size of 5 MB'
- );
- }
-
- try {
- $file = $folder->getFile(name: $name);
- $file->putContent(data: $payload);
- } catch (NotFoundException) {
- $folder->newFile(name: $name, content: $payload);
- }
- }//end setTree()
-
- /**
- * Filter a tree down to nodes the given user is permitted to see.
- *
- * Visibility rule per REQ-ONAV-002:
- * - `groupVisibility === null` → visible to everyone
- * - `groupVisibility === [g1, g2, ...]` → visible iff user is in
- * at least one listed group
- * - hidden parent cascades to children → children dropped wholesale
- *
- * @param array> $tree The tree to filter.
- * @param string $userId The viewing user.
- *
- * @return array> The filtered tree.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- public function filterTreeByUserGroups(array $tree, string $userId): array
- {
- if ($tree === []) {
- return [];
- }
-
- $userGroups = $this->templateService->getUserGroupIdsFor(userId: $userId);
-
- return $this->filterRecursive(tree: $tree, userGroups: $userGroups);
- }//end filterTreeByUserGroups()
-
- /**
- * Validate a tree wholesale (REQ-ONAV-003).
- *
- * @param array $tree The tree to validate.
- *
- * @return void
- *
- * @throws InvalidArgumentException With a stable message on the
- * first violation discovered.
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- public function validateTree(array $tree): void
- {
- $seenIds = [];
- $this->validateRecursive(nodes: $tree, level: 1, seenIds: $seenIds);
- }//end validateTree()
-
- /**
- * Reject URLs that use a disallowed scheme.
- *
- * Returns the original URL when accepted; otherwise throws so the
- * caller can surface a single curated error message
- * (REQ-ONAV-003, REQ-ONAV-011).
- *
- * @param string $url The URL to test.
- *
- * @return string The accepted URL (unchanged).
- *
- * @throws InvalidArgumentException When the URL uses
- * `javascript:`, `data:`, or
- * `vbscript:` (any case).
- *
- * @spec openspec/specs/navigation-editor-org/spec.md
- */
- public function sanitiseUrl(string $url): string
- {
- $lower = strtolower(string: ltrim(string: $url));
- foreach (self::FORBIDDEN_URL_SCHEMES as $scheme) {
- if (str_starts_with(haystack: $lower, needle: $scheme) === true) {
- throw new InvalidArgumentException(
- message: 'URL scheme is not allowed'
- );
- }
- }
-
- return $url;
- }//end sanitiseUrl()
-
- /**
- * Normalise a caller-supplied language code.
- *
- * Falls back to {@see self::DEFAULT_LANGUAGE} when the input is
- * empty or unsupported. Defensive — the controller already
- * validates, but keeping the fallback in the service means a
- * direct service consumer (e.g. CLI tooling) cannot crash here.
- *
- * @param string $language Caller language code.
- *
- * @return string Normalised language code (always one of
- * {@see self::SUPPORTED_LANGUAGES}).
- */
- private function normaliseLanguage(string $language): string
- {
- $lower = strtolower(string: trim(string: $language));
- if (in_array(needle: $lower, haystack: self::SUPPORTED_LANGUAGES, strict: true) === false) {
- return self::DEFAULT_LANGUAGE;
- }
-
- return $lower;
- }//end normaliseLanguage()
-
- /**
- * Build the file name for a given language code.
- *
- * @param string $language Language code (already normalised).
- *
- * @return string The file name (no path).
- */
- private function fileNameFor(string $language): string
- {
- return ($language.'.json');
- }//end fileNameFor()
-
- /**
- * Read and decode the JSON file body, returning `[]` on any failure.
- *
- * @param ISimpleFile $file The file to read.
- *
- * @return array> The decoded tree, or
- * `[]` when the file is
- * empty or corrupt.
- */
- private function decodeFile(ISimpleFile $file): array
- {
- try {
- $size = $file->getSize();
- } catch (Throwable) {
- return [];
- }
-
- if ($size > self::MAX_FILE_BYTES) {
- return [];
- }
-
- try {
- $contents = $file->getContent();
- } catch (Throwable) {
- return [];
- }
-
- if ($contents === '') {
- return [];
- }
-
- $decoded = json_decode(json: $contents, associative: true);
- if (is_array($decoded) === false) {
- return [];
- }
-
- return $decoded;
- }//end decodeFile()
-
- /**
- * Get the org-navigation folder, creating it if it does not exist.
- *
- * @return ISimpleFolder The folder.
- */
- private function getOrCreateFolder(): ISimpleFolder
- {
- try {
- return $this->appData->getFolder(name: self::FOLDER);
- } catch (NotFoundException) {
- return $this->appData->newFolder(name: self::FOLDER);
- }
- }//end getOrCreateFolder()
-
- /**
- * Recursive validator (REQ-ONAV-003).
- *
- * @param array $nodes The current level's nodes.
- * @param int $level The depth of `$nodes` (root = 1).
- * @param array $seenIds Map of UUIDs already used (by
- * reference so siblings,
- * children and grandchildren
- * share the same uniqueness
- * namespace).
- *
- * @return void
- *
- * @throws InvalidArgumentException On the first violation.
- */
- private function validateRecursive(array $nodes, int $level, array &$seenIds): void
- {
- if ($level > self::MAX_DEPTH) {
- throw new InvalidArgumentException(
- message: 'Tree depth cannot exceed 3 levels'
- );
- }
-
- foreach ($nodes as $node) {
- if (is_array($node) === false) {
- throw new InvalidArgumentException(
- message: 'Each node must be an object'
- );
- }
-
- $this->validateNodeShape(node: $node, seenIds: $seenIds);
-
- $children = ($node['children'] ?? []);
- if (is_array($children) === false) {
- throw new InvalidArgumentException(
- message: 'Node children must be an array'
- );
- }
-
- if ($children !== []) {
- $this->validateRecursive(
- nodes: $children,
- level: ($level + 1),
- seenIds: $seenIds
- );
- }
- }//end foreach
- }//end validateRecursive()
-
- /**
- * Validate a single node's shape and per-field rules.
- *
- * @param array $node The node.
- * @param array $seenIds Map of UUIDs already used
- * (by reference; updated on
- * success).
- *
- * @return void
- *
- * @throws InvalidArgumentException On the first violation.
- */
- private function validateNodeShape(array $node, array &$seenIds): void
- {
- $this->assertId(node: $node, seenIds: $seenIds);
- $this->assertLabel(node: $node);
- $this->assertUrl(node: $node);
- $this->assertGroupVisibility(node: $node);
- $this->assertOptionalScalars(node: $node);
- }//end validateNodeShape()
-
- /**
- * Validate the node id and stamp it into the seen-ids map.
- *
- * @param array $node The node.
- * @param array $seenIds Map of UUIDs already used
- * (by reference; updated on success).
- *
- * @return void
- *
- * @throws InvalidArgumentException When the id is missing,
- * not a UUID, or duplicated.
- */
- private function assertId(array $node, array &$seenIds): void
- {
- $id = ($node['id'] ?? null);
- if (is_string($id) === false || $this->isUuid(value: $id) === false) {
- throw new InvalidArgumentException(
- message: 'Node id must be a valid UUID'
- );
- }
-
- if (isset($seenIds[$id]) === true) {
- throw new InvalidArgumentException(
- message: 'duplicate node id: '.$id
- );
- }
-
- $seenIds[$id] = true;
-
- }//end assertId()
-
- /**
- * Validate the node label.
- *
- * @param array $node The node.
- *
- * @return void
- *
- * @throws InvalidArgumentException When the label is missing or empty.
- */
- private function assertLabel(array $node): void
- {
- $label = ($node['label'] ?? null);
- if (is_string($label) === false || trim(string: $label) === '') {
- throw new InvalidArgumentException(
- message: 'label is required'
- );
- }
-
- }//end assertLabel()
-
- /**
- * Validate the optional URL field.
- *
- * @param array $node The node.
- *
- * @return void
- *
- * @throws InvalidArgumentException When the URL has an invalid type
- * or scheme.
- */
- private function assertUrl(array $node): void
- {
- $url = ($node['url'] ?? null);
- if ($url === null) {
- return;
- }
-
- if (is_string($url) === false) {
- throw new InvalidArgumentException(
- message: 'url must be a string when set'
- );
- }
-
- // Rejects javascript:, data:, vbscript:.
- $this->sanitiseUrl(url: $url);
-
- }//end assertUrl()
-
- /**
- * Validate the optional `groupVisibility` array.
- *
- * @param array $node The node.
- *
- * @return void
- *
- * @throws InvalidArgumentException On any shape violation.
- */
- private function assertGroupVisibility(array $node): void
- {
- $visibility = ($node['groupVisibility'] ?? null);
- if ($visibility === null) {
- return;
- }
-
- if (is_array($visibility) === false || $visibility === []) {
- throw new InvalidArgumentException(
- message: 'groupVisibility must be null or a non-empty array of strings'
- );
- }
-
- foreach ($visibility as $groupId) {
- if (is_string($groupId) === false || $groupId === '') {
- throw new InvalidArgumentException(
- message: 'groupVisibility entries must be non-empty strings'
- );
- }
- }
-
- }//end assertGroupVisibility()
-
- /**
- * Validate the remaining optional scalar fields.
- *
- * @param array $node The node.
- *
- * @return void
- *
- * @throws InvalidArgumentException When a field carries the
- * wrong type.
- */
- private function assertOptionalScalars(array $node): void
- {
- $newTab = ($node['openInNewTab'] ?? null);
- if ($newTab !== null && is_bool($newTab) === false) {
- throw new InvalidArgumentException(
- message: 'openInNewTab must be a boolean when set'
- );
- }
-
- $icon = ($node['icon'] ?? null);
- if ($icon !== null && is_string($icon) === false) {
- throw new InvalidArgumentException(
- message: 'icon must be a string when set'
- );
- }
-
- }//end assertOptionalScalars()
-
- /**
- * Recursive group-visibility filter (REQ-ONAV-002).
- *
- * @param array> $tree The current
- * subtree.
- * @param string[] $userGroups The viewing
- * user's groups.
- *
- * @return array> The filtered subtree.
- */
- private function filterRecursive(array $tree, array $userGroups): array
- {
- $userGroupIndex = array_flip(array: $userGroups);
- $result = [];
-
- foreach ($tree as $node) {
- if ($this->isVisible(node: $node, userGroupIndex: $userGroupIndex) === false) {
- // Hidden parent cascades — children dropped wholesale.
- continue;
- }
-
- $children = ($node['children'] ?? []);
- if (is_array($children) === true && $children !== []) {
- $node['children'] = $this->filterRecursive(
- tree: $children,
- userGroups: $userGroups
- );
- }
-
- $result[] = $node;
- }
-
- return $result;
- }//end filterRecursive()
-
- /**
- * True when the user is permitted to see the given node.
- *
- * @param array $node The node to test.
- * @param array $userGroupIndex Flipped user groups
- * (group id → array
- * position) for O(1)
- * membership lookup.
- *
- * @return bool True when visible.
- */
- private function isVisible(array $node, array $userGroupIndex): bool
- {
- $visibility = ($node['groupVisibility'] ?? null);
- if ($visibility === null) {
- return true;
- }
-
- if (is_array($visibility) === false || $visibility === []) {
- // Treat malformed visibility as "visible to all" so a
- // legacy/migrated record never disappears silently.
- return true;
- }
-
- foreach ($visibility as $groupId) {
- if (is_string($groupId) === false) {
- continue;
- }
-
- if (isset($userGroupIndex[$groupId]) === true) {
- return true;
- }
- }
-
- return false;
- }//end isVisible()
-
- /**
- * UUID v1..v5 detector — matches the canonical 8-4-4-4-12 hex form.
- *
- * @param string $value The candidate value.
- *
- * @return bool True when the input is a valid UUID.
- */
- private function isUuid(string $value): bool
- {
- return preg_match(
- pattern: '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
- subject: $value
- ) === 1;
- }//end isUuid()
+class OrgNavigationService {
+ /**
+ * Name of the IAppData subfolder where org-nav JSON files live.
+ *
+ * @var string
+ */
+ public const FOLDER = 'org-navigation';
+
+ /**
+ * Maximum file size accepted on read or write (5 MB; REQ-ONAV-001).
+ *
+ * @var int
+ */
+ public const MAX_FILE_BYTES = (5 * 1024 * 1024);
+
+ /**
+ * Maximum tree depth (root counts as level 1; REQ-ONAV-001/003).
+ *
+ * @var int
+ */
+ public const MAX_DEPTH = 3;
+
+ /**
+ * Default language code used when the caller does not specify
+ * `?lang=` (REQ-ONAV-001).
+ *
+ * @var string
+ */
+ public const DEFAULT_LANGUAGE = 'nl';
+
+ /**
+ * Languages supported in v1 of the capability (REQ-ONAV-001).
+ *
+ * @var array
+ */
+ public const SUPPORTED_LANGUAGES = ['nl', 'en'];
+
+ /**
+ * URL schemes rejected outright by the validator
+ * (REQ-ONAV-003, REQ-ONAV-011).
+ *
+ * @var array
+ */
+ private const FORBIDDEN_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
+
+ /**
+ * Constructor.
+ *
+ * @param IAppData $appData Nextcloud app-data
+ * accessor for the
+ * LaunchPad app.
+ * @param AdminTemplateService $templateService Routing resolver — the
+ * only allowed wrapper
+ * around
+ * `IGroupManager::getUserGroupIds`
+ * (REQ-TMPL-013).
+ */
+ public function __construct(
+ private readonly IAppData $appData,
+ private readonly AdminTemplateService $templateService,
+ ) {
+ }//end __construct()
+
+ /**
+ * Read the persisted tree for the given language.
+ *
+ * Returns `[]` when the file does not yet exist; a corrupted JSON
+ * payload also resolves to `[]` so a hand-edited file never bricks
+ * the rail (REQ-ONAV-008 empty-state handling).
+ *
+ * @param string $language Language code (validated against the
+ * supported set).
+ *
+ * @return array> The persisted tree.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ public function getTree(string $language = self::DEFAULT_LANGUAGE): array {
+ $lang = $this->normaliseLanguage(language: $language);
+
+ try {
+ $folder = $this->appData->getFolder(name: self::FOLDER);
+ } catch (NotFoundException) {
+ return [];
+ }
+
+ try {
+ $file = $folder->getFile(name: $this->fileNameFor(language: $lang));
+ } catch (NotFoundException) {
+ return [];
+ }
+
+ return $this->decodeFile(file: $file);
+ }//end getTree()
+
+ /**
+ * Persist a validated tree for the given language.
+ *
+ * Wholesale replacement (REQ-ONAV-003). Validates BEFORE touching
+ * storage so a malformed payload never half-writes.
+ *
+ * @param array $tree The tree to persist.
+ * @param string $language Language code.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When validation fails (the
+ * controller maps to HTTP 400).
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ public function setTree(array $tree, string $language = self::DEFAULT_LANGUAGE): void {
+ $lang = $this->normaliseLanguage(language: $language);
+
+ $this->validateTree(tree: $tree);
+
+ $folder = $this->getOrCreateFolder();
+ $name = $this->fileNameFor(language: $lang);
+
+ $payload = json_encode(value: $tree, flags: (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ if ($payload === false) {
+ throw new InvalidArgumentException(message: 'Failed to encode tree to JSON');
+ }
+
+ if (strlen(string: $payload) > self::MAX_FILE_BYTES) {
+ throw new InvalidArgumentException(
+ message: 'Tree exceeds maximum file size of 5 MB'
+ );
+ }
+
+ try {
+ $file = $folder->getFile(name: $name);
+ $file->putContent(data: $payload);
+ } catch (NotFoundException) {
+ $folder->newFile(name: $name, content: $payload);
+ }
+ }//end setTree()
+
+ /**
+ * Filter a tree down to nodes the given user is permitted to see.
+ *
+ * Visibility rule per REQ-ONAV-002:
+ * - `groupVisibility === null` → visible to everyone
+ * - `groupVisibility === [g1, g2, ...]` → visible iff user is in
+ * at least one listed group
+ * - hidden parent cascades to children → children dropped wholesale
+ *
+ * @param array> $tree The tree to filter.
+ * @param string $userId The viewing user.
+ *
+ * @return array> The filtered tree.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ public function filterTreeByUserGroups(array $tree, string $userId): array {
+ if ($tree === []) {
+ return [];
+ }
+
+ $userGroups = $this->templateService->getUserGroupIdsFor(userId: $userId);
+
+ return $this->filterRecursive(tree: $tree, userGroups: $userGroups);
+ }//end filterTreeByUserGroups()
+
+ /**
+ * Validate a tree wholesale (REQ-ONAV-003).
+ *
+ * @param array $tree The tree to validate.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException With a stable message on the
+ * first violation discovered.
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ public function validateTree(array $tree): void {
+ $seenIds = [];
+ $this->validateRecursive(nodes: $tree, level: 1, seenIds: $seenIds);
+ }//end validateTree()
+
+ /**
+ * Reject URLs that use a disallowed scheme.
+ *
+ * Returns the original URL when accepted; otherwise throws so the
+ * caller can surface a single curated error message
+ * (REQ-ONAV-003, REQ-ONAV-011).
+ *
+ * @param string $url The URL to test.
+ *
+ * @return string The accepted URL (unchanged).
+ *
+ * @throws InvalidArgumentException When the URL uses
+ * `javascript:`, `data:`, or
+ * `vbscript:` (any case).
+ *
+ * @spec openspec/specs/navigation-editor-org/spec.md
+ */
+ public function sanitiseUrl(string $url): string {
+ $lower = strtolower(string: ltrim(string: $url));
+ foreach (self::FORBIDDEN_URL_SCHEMES as $scheme) {
+ if (str_starts_with(haystack: $lower, needle: $scheme) === true) {
+ throw new InvalidArgumentException(
+ message: 'URL scheme is not allowed'
+ );
+ }
+ }
+
+ return $url;
+ }//end sanitiseUrl()
+
+ /**
+ * Normalise a caller-supplied language code.
+ *
+ * Falls back to {@see self::DEFAULT_LANGUAGE} when the input is
+ * empty or unsupported. Defensive — the controller already
+ * validates, but keeping the fallback in the service means a
+ * direct service consumer (e.g. CLI tooling) cannot crash here.
+ *
+ * @param string $language Caller language code.
+ *
+ * @return string Normalised language code (always one of
+ * {@see self::SUPPORTED_LANGUAGES}).
+ */
+ private function normaliseLanguage(string $language): string {
+ $lower = strtolower(string: trim(string: $language));
+ if (in_array(needle: $lower, haystack: self::SUPPORTED_LANGUAGES, strict: true) === false) {
+ return self::DEFAULT_LANGUAGE;
+ }
+
+ return $lower;
+ }//end normaliseLanguage()
+
+ /**
+ * Build the file name for a given language code.
+ *
+ * @param string $language Language code (already normalised).
+ *
+ * @return string The file name (no path).
+ */
+ private function fileNameFor(string $language): string {
+ return ($language . '.json');
+ }//end fileNameFor()
+
+ /**
+ * Read and decode the JSON file body, returning `[]` on any failure.
+ *
+ * @param ISimpleFile $file The file to read.
+ *
+ * @return array> The decoded tree, or
+ * `[]` when the file is
+ * empty or corrupt.
+ */
+ private function decodeFile(ISimpleFile $file): array {
+ try {
+ $size = $file->getSize();
+ } catch (Throwable) {
+ return [];
+ }
+
+ if ($size > self::MAX_FILE_BYTES) {
+ return [];
+ }
+
+ try {
+ $contents = $file->getContent();
+ } catch (Throwable) {
+ return [];
+ }
+
+ if ($contents === '') {
+ return [];
+ }
+
+ $decoded = json_decode(json: $contents, associative: true);
+ if (is_array($decoded) === false) {
+ return [];
+ }
+
+ return $decoded;
+ }//end decodeFile()
+
+ /**
+ * Get the org-navigation folder, creating it if it does not exist.
+ *
+ * @return ISimpleFolder The folder.
+ */
+ private function getOrCreateFolder(): ISimpleFolder {
+ try {
+ return $this->appData->getFolder(name: self::FOLDER);
+ } catch (NotFoundException) {
+ return $this->appData->newFolder(name: self::FOLDER);
+ }
+ }//end getOrCreateFolder()
+
+ /**
+ * Recursive validator (REQ-ONAV-003).
+ *
+ * @param array $nodes The current level's nodes.
+ * @param int $level The depth of `$nodes` (root = 1).
+ * @param array $seenIds Map of UUIDs already used (by
+ * reference so siblings,
+ * children and grandchildren
+ * share the same uniqueness
+ * namespace).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On the first violation.
+ */
+ private function validateRecursive(array $nodes, int $level, array &$seenIds): void {
+ if ($level > self::MAX_DEPTH) {
+ throw new InvalidArgumentException(
+ message: 'Tree depth cannot exceed 3 levels'
+ );
+ }
+
+ foreach ($nodes as $node) {
+ if (is_array($node) === false) {
+ throw new InvalidArgumentException(
+ message: 'Each node must be an object'
+ );
+ }
+
+ $this->validateNodeShape(node: $node, seenIds: $seenIds);
+
+ $children = ($node['children'] ?? []);
+ if (is_array($children) === false) {
+ throw new InvalidArgumentException(
+ message: 'Node children must be an array'
+ );
+ }
+
+ if ($children !== []) {
+ $this->validateRecursive(
+ nodes: $children,
+ level: ($level + 1),
+ seenIds: $seenIds
+ );
+ }
+ }//end foreach
+ }//end validateRecursive()
+
+ /**
+ * Validate a single node's shape and per-field rules.
+ *
+ * @param array $node The node.
+ * @param array $seenIds Map of UUIDs already used
+ * (by reference; updated on
+ * success).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On the first violation.
+ */
+ private function validateNodeShape(array $node, array &$seenIds): void {
+ $this->assertId(node: $node, seenIds: $seenIds);
+ $this->assertLabel(node: $node);
+ $this->assertUrl(node: $node);
+ $this->assertGroupVisibility(node: $node);
+ $this->assertOptionalScalars(node: $node);
+ }//end validateNodeShape()
+
+ /**
+ * Validate the node id and stamp it into the seen-ids map.
+ *
+ * @param array $node The node.
+ * @param array $seenIds Map of UUIDs already used
+ * (by reference; updated on success).
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the id is missing,
+ * not a UUID, or duplicated.
+ */
+ private function assertId(array $node, array &$seenIds): void {
+ $id = ($node['id'] ?? null);
+ if (is_string($id) === false || $this->isUuid(value: $id) === false) {
+ throw new InvalidArgumentException(
+ message: 'Node id must be a valid UUID'
+ );
+ }
+
+ if (isset($seenIds[$id]) === true) {
+ throw new InvalidArgumentException(
+ message: 'duplicate node id: ' . $id
+ );
+ }
+
+ $seenIds[$id] = true;
+
+ }//end assertId()
+
+ /**
+ * Validate the node label.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the label is missing or empty.
+ */
+ private function assertLabel(array $node): void {
+ $label = ($node['label'] ?? null);
+ if (is_string($label) === false || trim(string: $label) === '') {
+ throw new InvalidArgumentException(
+ message: 'label is required'
+ );
+ }
+
+ }//end assertLabel()
+
+ /**
+ * Validate the optional URL field.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When the URL has an invalid type
+ * or scheme.
+ */
+ private function assertUrl(array $node): void {
+ $url = ($node['url'] ?? null);
+ if ($url === null) {
+ return;
+ }
+
+ if (is_string($url) === false) {
+ throw new InvalidArgumentException(
+ message: 'url must be a string when set'
+ );
+ }
+
+ // Rejects javascript:, data:, vbscript:.
+ $this->sanitiseUrl(url: $url);
+
+ }//end assertUrl()
+
+ /**
+ * Validate the optional `groupVisibility` array.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException On any shape violation.
+ */
+ private function assertGroupVisibility(array $node): void {
+ $visibility = ($node['groupVisibility'] ?? null);
+ if ($visibility === null) {
+ return;
+ }
+
+ if (is_array($visibility) === false || $visibility === []) {
+ throw new InvalidArgumentException(
+ message: 'groupVisibility must be null or a non-empty array of strings'
+ );
+ }
+
+ foreach ($visibility as $groupId) {
+ if (is_string($groupId) === false || $groupId === '') {
+ throw new InvalidArgumentException(
+ message: 'groupVisibility entries must be non-empty strings'
+ );
+ }
+ }
+
+ }//end assertGroupVisibility()
+
+ /**
+ * Validate the remaining optional scalar fields.
+ *
+ * @param array $node The node.
+ *
+ * @return void
+ *
+ * @throws InvalidArgumentException When a field carries the
+ * wrong type.
+ */
+ private function assertOptionalScalars(array $node): void {
+ $newTab = ($node['openInNewTab'] ?? null);
+ if ($newTab !== null && is_bool($newTab) === false) {
+ throw new InvalidArgumentException(
+ message: 'openInNewTab must be a boolean when set'
+ );
+ }
+
+ $icon = ($node['icon'] ?? null);
+ if ($icon !== null && is_string($icon) === false) {
+ throw new InvalidArgumentException(
+ message: 'icon must be a string when set'
+ );
+ }
+
+ }//end assertOptionalScalars()
+
+ /**
+ * Recursive group-visibility filter (REQ-ONAV-002).
+ *
+ * @param array> $tree The current
+ * subtree.
+ * @param string[] $userGroups The viewing
+ * user's groups.
+ *
+ * @return array> The filtered subtree.
+ */
+ private function filterRecursive(array $tree, array $userGroups): array {
+ $userGroupIndex = array_flip(array: $userGroups);
+ $result = [];
+
+ foreach ($tree as $node) {
+ if ($this->isVisible(node: $node, userGroupIndex: $userGroupIndex) === false) {
+ // Hidden parent cascades — children dropped wholesale.
+ continue;
+ }
+
+ $children = ($node['children'] ?? []);
+ if (is_array($children) === true && $children !== []) {
+ $node['children'] = $this->filterRecursive(
+ tree: $children,
+ userGroups: $userGroups
+ );
+ }
+
+ $result[] = $node;
+ }
+
+ return $result;
+ }//end filterRecursive()
+
+ /**
+ * True when the user is permitted to see the given node.
+ *
+ * @param array $node The node to test.
+ * @param array $userGroupIndex Flipped user groups
+ * (group id → array
+ * position) for O(1)
+ * membership lookup.
+ *
+ * @return bool True when visible.
+ */
+ private function isVisible(array $node, array $userGroupIndex): bool {
+ $visibility = ($node['groupVisibility'] ?? null);
+ if ($visibility === null) {
+ return true;
+ }
+
+ if (is_array($visibility) === false || $visibility === []) {
+ // Treat malformed visibility as "visible to all" so a
+ // legacy/migrated record never disappears silently.
+ return true;
+ }
+
+ foreach ($visibility as $groupId) {
+ if (is_string($groupId) === false) {
+ continue;
+ }
+
+ if (isset($userGroupIndex[$groupId]) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }//end isVisible()
+
+ /**
+ * UUID v1..v5 detector — matches the canonical 8-4-4-4-12 hex form.
+ *
+ * @param string $value The candidate value.
+ *
+ * @return bool True when the input is a valid UUID.
+ */
+ private function isUuid(string $value): bool {
+ return preg_match(
+ pattern: '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
+ subject: $value
+ ) === 1;
+ }//end isUuid()
}//end class
diff --git a/lib/Service/OrphanedDataCleanupService.php b/lib/Service/OrphanedDataCleanupService.php
index b2119939d..9fc94ac52 100644
--- a/lib/Service/OrphanedDataCleanupService.php
+++ b/lib/Service/OrphanedDataCleanupService.php
@@ -30,8 +30,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -51,402 +51,445 @@
/**
* Orchestrates scan + purge across all registered cleanup categories.
- *
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The orchestrator
- * legitimately
- * composes the
- * registry, cache,
- * DB transaction,
- * activity and
- * logger
- * collaborators in
- * one place.
*/
-class OrphanedDataCleanupService
-{
- /**
- * Cache TTL for scan results, in seconds (REQ-CLN-010).
- *
- * Five minutes — short enough that an admin who navigates to the
- * cleanup page after a manual purge sees fresh numbers, long
- * enough that repeated UI refreshes don't hammer the DB.
- *
- * @var int
- */
- public const CACHE_TTL_SECONDS = 300;
-
- /**
- * Cache key for the most recent scan result.
- *
- * @var string
- */
- public const CACHE_KEY_SCAN = 'launchpad.cleanup.scan';
-
- /**
- * Activity event type emitted on every real (non-dry-run) purge.
- *
- * @var string
- */
- public const ACTIVITY_TYPE = 'launchpad_cleanup_purge';
-
- /**
- * Cache instance, lazily resolved via {@see ICacheFactory}.
- *
- * @var ICache|null
- */
- private ?ICache $cache = null;
-
- /**
- * Constructor.
- *
- * @param CategoryRegistryService $registry Category registry.
- * @param ICacheFactory $cacheFactory Distributed-cache
- * factory.
- * @param IDBConnection $db DB connection
- * (for dry-run
- * transactions).
- * @param IActivityManager $activityManager Activity event
- * publisher.
- * @param LoggerInterface $logger PSR-3 logger
- * used for purge
- * audit lines.
- */
- public function __construct(
- private readonly CategoryRegistryService $registry,
- private readonly ICacheFactory $cacheFactory,
- private readonly IDBConnection $db,
- private readonly IActivityManager $activityManager,
- private readonly LoggerInterface $logger,
- ) {
- }//end __construct()
-
- /**
- * Scan one or more categories for orphaned rows.
- *
- * When `$categoryNames` is empty the full registered set is
- * scanned. Categories whose `isAvailable()` returns `false` are
- * recorded under `skipped` instead of contributing a count.
- *
- * The result is cached under {@see self::CACHE_KEY_SCAN} for
- * {@see self::CACHE_TTL_SECONDS} seconds whenever the caller
- * asks for the full set; partial scans bypass the cache to avoid
- * polluting the full-set entry with category-filtered numbers.
- *
- * @param array $categoryNames Names to scan, or [].
- *
- * @return CleanupResult The result.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- public function scan(array $categoryNames=[]): CleanupResult
- {
- if (count(value: $categoryNames) === 0) {
- $cached = $this->getCachedScanResult();
- if ($cached !== null) {
- return $cached;
- }
- }
-
- $names = $this->resolveCategoryNames(requested: $categoryNames);
-
- $start = (int) round(num: (microtime(as_float: true) * 1000));
- $byCategory = [];
- $skipped = [];
-
- foreach ($names as $name) {
- $category = $this->registry->getCategoryByName(name: $name);
- if ($category === null) {
- $skipped[] = $name;
- continue;
- }
-
- if ($category->isAvailable() === false) {
- $skipped[] = $name;
- continue;
- }
-
- $byCategory[$name] = (int) $category->scan();
- }
-
- $duration = ((int) round(num: (microtime(as_float: true) * 1000)) - $start);
- $result = CleanupResult::fromCounts(
- byCategory: $byCategory,
- durationMs: $duration,
- dryRun: false,
- skipped: $skipped,
- );
-
- if (count(value: $categoryNames) === 0) {
- $this->setCachedScanResult(result: $result);
- }
-
- return $result;
- }//end scan()
-
- /**
- * Purge orphaned rows in one or more categories.
- *
- * When `$categoryNames` is empty the full registered set is
- * purged. Categories whose `isAvailable()` returns `false` are
- * recorded under `skipped`.
- *
- * Dry-run mode wraps the entire walk in a single transaction and
- * rolls it back at the end so individual category implementations
- * can use one delete path for both modes (REQ-CLN-003).
- *
- * On a successful real purge the scan cache is invalidated
- * (REQ-CLN-010) and exactly one Activity event is emitted
- * (REQ-CLN-009).
- *
- * @param array $categoryNames Names to purge, or [].
- * @param bool $dryRun True for simulation.
- * @param string|null $userId The actor for audit
- * (null = system).
- * @param string $source Origin label for the
- * activity event ('cli',
- * 'api', 'job').
- *
- * @return CleanupResult The result.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- public function purge(
- array $categoryNames=[],
- bool $dryRun=false,
- ?string $userId=null,
- string $source='api'
- ): CleanupResult {
- $names = $this->resolveCategoryNames(requested: $categoryNames);
- $start = (int) round(num: (microtime(as_float: true) * 1000));
- $byCategory = [];
- $skipped = [];
-
- if ($dryRun === true) {
- $this->db->beginTransaction();
- }
-
- try {
- foreach ($names as $name) {
- $category = $this->registry->getCategoryByName(name: $name);
- if ($category === null) {
- $skipped[] = $name;
- continue;
- }
-
- if ($category->isAvailable() === false) {
- $skipped[] = $name;
- continue;
- }
-
- $byCategory[$name] = (int) $category->purge(dryRun: $dryRun);
- }
-
- if ($dryRun === true) {
- // REQ-CLN-003: rollback so the simulation has zero
- // persistent side effect.
- $this->db->rollBack();
- }
- } catch (Throwable $t) {
- if ($dryRun === true && $this->db->inTransaction() === true) {
- $this->db->rollBack();
- }
-
- throw $t;
- }//end try
-
- $duration = ((int) round(num: (microtime(as_float: true) * 1000)) - $start);
- $result = CleanupResult::fromCounts(
- byCategory: $byCategory,
- durationMs: $duration,
- dryRun: $dryRun,
- skipped: $skipped,
- );
-
- if ($dryRun === false) {
- $this->invalidateCache();
-
- if ($result->getTotalRows() > 0) {
- $this->emitActivityEvent(
- result: $result,
- userId: $userId,
- source: $source,
- );
- }
-
- $this->logger->info(
- message: sprintf(
- 'launchpad.cleanup.purge source=%s user=%s rows=%d duration_ms=%d categories=%s',
- $source,
- ($userId ?? 'system'),
- $result->getTotalRows(),
- $result->getDurationMs(),
- implode(separator: ',', array: array_keys(array: $byCategory)),
- )
- );
- }//end if
-
- return $result;
- }//end purge()
-
- /**
- * Read the most recent cached scan result.
- *
- * Returns `null` when the cache backend has no value (expired,
- * never written, or unavailable). The cache is constructed lazily
- * via {@see ICacheFactory::createDistributed()} so installs
- * without a memory cache silently fall through to fresh scans.
- *
- * @return CleanupResult|null The cached result or null.
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- public function getCachedScanResult(): ?CleanupResult
- {
- $payload = $this->cache()->get(key: self::CACHE_KEY_SCAN);
- if (is_array(value: $payload) === false) {
- return null;
- }
-
- return new CleanupResult(
- byCategory: (array) ($payload['byCategory'] ?? []),
- totalRows: (int) ($payload['totalRows'] ?? 0),
- durationMs: (int) ($payload['durationMs'] ?? 0),
- dryRun: (bool) ($payload['dryRun'] ?? false),
- scannedAt: (string) ($payload['scannedAt'] ?? ''),
- skipped: (array) ($payload['skipped'] ?? []),
- );
- }//end getCachedScanResult()
-
- /**
- * Persist a scan result into the distributed cache for
- * {@see self::CACHE_TTL_SECONDS} seconds.
- *
- * @param CleanupResult $result The result to cache.
- *
- * @return void
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- public function setCachedScanResult(CleanupResult $result): void
- {
- $this->cache()->set(
- key: self::CACHE_KEY_SCAN,
- value: $result->jsonSerialize(),
- ttl: self::CACHE_TTL_SECONDS,
- );
- }//end setCachedScanResult()
-
- /**
- * Invalidate the cached scan result.
- *
- * Called automatically after every successful real purge so the
- * next scan reflects the fresh state (REQ-CLN-010).
- *
- * @return void
- *
- * @spec openspec/specs/orphaned-data-cleanup/spec.md
- */
- public function invalidateCache(): void
- {
- $this->cache()->remove(key: self::CACHE_KEY_SCAN);
- }//end invalidateCache()
-
- /**
- * Resolve the requested category-name list, normalising the
- * "empty means all" convention.
- *
- * @param array $requested The caller's list.
- *
- * @return array The resolved list.
- */
- private function resolveCategoryNames(array $requested): array
- {
- if (count(value: $requested) === 0) {
- return $this->registry->getCategoryNames();
- }
-
- return array_values(
- array: array_unique(
- array: array_filter(
- array: $requested,
- callback: static function ($value): bool {
- return is_string(value: $value) === true && $value !== '';
- }
- )
- )
- );
- }//end resolveCategoryNames()
-
- /**
- * Lazily resolve the distributed cache.
- *
- * @return ICache The cache instance.
- */
- private function cache(): ICache
- {
- if ($this->cache === null) {
- $this->cache = $this->cacheFactory->createDistributed(
- prefix: 'launchpad_cleanup'
- );
- }
-
- return $this->cache;
- }//end cache()
-
- /**
- * Emit one Activity event summarising a real purge run.
- *
- * Best-effort — a misconfigured Activity backend MUST NOT cause
- * the purge call to fail; the audit trail is still written to
- * the PSR-3 logger by the caller.
- *
- * @param CleanupResult $result The purge result.
- * @param string|null $userId The actor (null = system).
- * @param string $source The source label.
- *
- * @return void
- */
- private function emitActivityEvent(
- CleanupResult $result,
- ?string $userId,
- string $source
- ): void {
- try {
- $event = $this->activityManager->generateEvent();
- $event->setApp(app: Application::APP_ID)
- ->setType(type: self::ACTIVITY_TYPE)
- ->setAffectedUser(affectedUser: ($userId ?? ''))
- ->setAuthor(author: ($userId ?? ''))
- ->setSubject(
- subject: 'launchpad_cleanup_purge',
- parameters: [
- 'totalRows' => $result->getTotalRows(),
- 'byCategory' => $result->getByCategory(),
- 'durationMs' => $result->getDurationMs(),
- 'source' => $source,
- ]
- )
- ->setObject(
- objectType: 'launchpad_cleanup',
- objectId: 0,
- objectName: $source
- );
-
- $this->activityManager->publish(event: $event);
- } catch (IncompleteActivityException $e) {
- $this->logger->warning(
- message: sprintf(
- 'launchpad.cleanup.activity_emit_failed: %s',
- $e->getMessage()
- )
- );
- } catch (Throwable $t) {
- $this->logger->warning(
- message: sprintf(
- 'launchpad.cleanup.activity_emit_threw: %s',
- $t->getMessage()
- )
- );
- }//end try
- }//end emitActivityEvent()
+class OrphanedDataCleanupService {
+ /**
+ * Cache TTL for scan results, in seconds (REQ-CLN-010).
+ *
+ * Five minutes — short enough that an admin who navigates to the
+ * cleanup page after a manual purge sees fresh numbers, long
+ * enough that repeated UI refreshes don't hammer the DB.
+ *
+ * @var int
+ */
+ public const CACHE_TTL_SECONDS = 300;
+
+ /**
+ * Cache key for the most recent scan result.
+ *
+ * @var string
+ */
+ public const CACHE_KEY_SCAN = 'launchpad.cleanup.scan';
+
+ /**
+ * Activity event type emitted on every real (non-dry-run) purge.
+ *
+ * @var string
+ */
+ public const ACTIVITY_TYPE = 'launchpad_cleanup_purge';
+
+ /**
+ * Cache instance, lazily resolved via {@see ICacheFactory}.
+ *
+ * @var ICache|null
+ */
+ private ?ICache $cache = null;
+
+ /**
+ * Constructor.
+ *
+ * @param CategoryRegistryService $registry Category registry.
+ * @param ICacheFactory $cacheFactory Distributed-cache
+ * factory.
+ * @param IDBConnection $db DB connection
+ * (for dry-run
+ * transactions).
+ * @param IActivityManager $activityManager Activity event
+ * publisher.
+ * @param LoggerInterface $logger PSR-3 logger
+ * used for purge
+ * audit lines.
+ */
+ public function __construct(
+ private readonly CategoryRegistryService $registry,
+ private readonly ICacheFactory $cacheFactory,
+ private readonly IDBConnection $db,
+ private readonly IActivityManager $activityManager,
+ private readonly LoggerInterface $logger,
+ ) {
+ }//end __construct()
+
+ /**
+ * Scan one or more categories for orphaned rows.
+ *
+ * When `$categoryNames` is empty the full registered set is
+ * scanned. Categories whose `isAvailable()` returns `false` are
+ * recorded under `skipped` instead of contributing a count.
+ *
+ * The result is cached under {@see self::CACHE_KEY_SCAN} for
+ * {@see self::CACHE_TTL_SECONDS} seconds whenever the caller
+ * asks for the full set; partial scans bypass the cache to avoid
+ * polluting the full-set entry with category-filtered numbers.
+ *
+ * @param array $categoryNames Names to scan, or [].
+ *
+ * @return CleanupResult The result.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ public function scan(array $categoryNames = []): CleanupResult {
+ if (count(value: $categoryNames) === 0) {
+ $cached = $this->getCachedScanResult();
+ if ($cached !== null) {
+ return $cached;
+ }
+ }
+
+ $names = $this->resolveCategoryNames(requested: $categoryNames);
+
+ $start = (int)round(num: (microtime(as_float: true) * 1000));
+ $byCategory = [];
+ $skipped = [];
+
+ foreach ($names as $name) {
+ $category = $this->registry->getCategoryByName(name: $name);
+ if ($category === null) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ if ($category->isAvailable() === false) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ $byCategory[$name] = (int)$category->scan();
+ }
+
+ $duration = ((int)round(num: (microtime(as_float: true) * 1000)) - $start);
+ $result = CleanupResult::fromCounts(
+ byCategory: $byCategory,
+ durationMs: $duration,
+ dryRun: false,
+ skipped: $skipped,
+ );
+
+ if (count(value: $categoryNames) === 0) {
+ $this->setCachedScanResult(result: $result);
+ }
+
+ return $result;
+ }//end scan()
+
+ /**
+ * Purge orphaned rows in one or more categories.
+ *
+ * When `$categoryNames` is empty the full registered set is
+ * purged. Categories whose `isAvailable()` returns `false` are
+ * recorded under `skipped`.
+ *
+ * Dry-run mode wraps the entire walk in a single transaction and
+ * rolls it back at the end so individual category implementations
+ * can use one delete path for both modes (REQ-CLN-003).
+ *
+ * On a successful real purge the scan cache is invalidated
+ * (REQ-CLN-010) and exactly one Activity event is emitted
+ * (REQ-CLN-009).
+ *
+ * @param array $categoryNames Names to purge, or [].
+ * @param bool $dryRun True for simulation.
+ * @param string|null $userId The actor for audit
+ * (null = system).
+ * @param string $source Origin label for the
+ * activity event ('cli',
+ * 'api', 'job').
+ *
+ * @return CleanupResult The result.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ public function purge(
+ array $categoryNames = [],
+ bool $dryRun = false,
+ ?string $userId = null,
+ string $source = 'api',
+ ): CleanupResult {
+ $names = $this->resolveCategoryNames(requested: $categoryNames);
+ $start = (int)round(num: (microtime(as_float: true) * 1000));
+ $byCategory = [];
+ $skipped = [];
+
+ if ($dryRun === true) {
+ $this->db->beginTransaction();
+ }
+
+ $this->runCategoryPurges(
+ names: $names,
+ dryRun: $dryRun,
+ byCategory: $byCategory,
+ skipped: $skipped,
+ );
+
+ $duration = ((int)round(num: (microtime(as_float: true) * 1000)) - $start);
+ $result = CleanupResult::fromCounts(
+ byCategory: $byCategory,
+ durationMs: $duration,
+ dryRun: $dryRun,
+ skipped: $skipped,
+ );
+
+ if ($dryRun === false) {
+ $this->recordPurgeOutcome(
+ result: $result,
+ byCategory: $byCategory,
+ userId: $userId,
+ source: $source,
+ );
+ }
+
+ return $result;
+ }//end purge()
+
+ /**
+ * Run every named category's purge, filling the count and skip lists.
+ *
+ * A category that is unregistered or reports itself unavailable is
+ * skipped rather than failing the whole run. On a dry run the caller
+ * has already opened a transaction, so this rolls it back on both the
+ * happy path and the failure path — REQ-CLN-003 requires a simulation
+ * to leave zero persistent side effect.
+ *
+ * @param array $names The category names to purge.
+ * @param bool $dryRun True for simulation.
+ * @param array $byCategory Per-category row counts, filled in place.
+ * @param array $skipped Skipped category names, filled in place.
+ *
+ * @return void
+ *
+ * @throws Throwable Re-thrown after the dry-run transaction is rolled back.
+ */
+ private function runCategoryPurges(
+ array $names,
+ bool $dryRun,
+ array &$byCategory,
+ array &$skipped,
+ ): void {
+ try {
+ foreach ($names as $name) {
+ $category = $this->registry->getCategoryByName(name: $name);
+ if ($category === null) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ if ($category->isAvailable() === false) {
+ $skipped[] = $name;
+ continue;
+ }
+
+ $byCategory[$name] = (int)$category->purge(dryRun: $dryRun);
+ }
+
+ if ($dryRun === true) {
+ // REQ-CLN-003: rollback so the simulation has zero
+ // persistent side effect.
+ $this->db->rollBack();
+ }
+ } catch (Throwable $t) {
+ if ($dryRun === true && $this->db->inTransaction() === true) {
+ $this->db->rollBack();
+ }
+
+ throw $t;
+ }//end try
+ }//end runCategoryPurges()
+
+ /**
+ * Record the side effects of a real (non-dry-run) purge.
+ *
+ * Invalidates the scan cache (REQ-CLN-010), emits exactly one Activity
+ * event when rows were actually removed (REQ-CLN-009), and writes the
+ * audit log line.
+ *
+ * @param CleanupResult $result The completed purge result.
+ * @param array $byCategory Per-category row counts.
+ * @param string|null $userId The actor for audit (null = system).
+ * @param string $source Origin label ('cli', 'api', 'job').
+ *
+ * @return void
+ */
+ private function recordPurgeOutcome(
+ CleanupResult $result,
+ array $byCategory,
+ ?string $userId,
+ string $source,
+ ): void {
+ $this->invalidateCache();
+
+ if ($result->getTotalRows() > 0) {
+ $this->emitActivityEvent(
+ result: $result,
+ userId: $userId,
+ source: $source,
+ );
+ }
+
+ $this->logger->info(
+ message: sprintf(
+ 'launchpad.cleanup.purge source=%s user=%s rows=%d duration_ms=%d categories=%s',
+ $source,
+ ($userId ?? 'system'),
+ $result->getTotalRows(),
+ $result->getDurationMs(),
+ implode(separator: ',', array: array_keys(array: $byCategory)),
+ )
+ );
+ }//end recordPurgeOutcome()
+
+ /**
+ * Read the most recent cached scan result.
+ *
+ * Returns `null` when the cache backend has no value (expired,
+ * never written, or unavailable). The cache is constructed lazily
+ * via {@see ICacheFactory::createDistributed()} so installs
+ * without a memory cache silently fall through to fresh scans.
+ *
+ * @return CleanupResult|null The cached result or null.
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ public function getCachedScanResult(): ?CleanupResult {
+ $payload = $this->cache()->get(key: self::CACHE_KEY_SCAN);
+ if (is_array(value: $payload) === false) {
+ return null;
+ }
+
+ return new CleanupResult(
+ byCategory: (array)($payload['byCategory'] ?? []),
+ totalRows: (int)($payload['totalRows'] ?? 0),
+ durationMs: (int)($payload['durationMs'] ?? 0),
+ dryRun: (bool)($payload['dryRun'] ?? false),
+ scannedAt: (string)($payload['scannedAt'] ?? ''),
+ skipped: (array)($payload['skipped'] ?? []),
+ );
+ }//end getCachedScanResult()
+
+ /**
+ * Persist a scan result into the distributed cache for
+ * {@see self::CACHE_TTL_SECONDS} seconds.
+ *
+ * @param CleanupResult $result The result to cache.
+ *
+ * @return void
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ public function setCachedScanResult(CleanupResult $result): void {
+ $this->cache()->set(
+ key: self::CACHE_KEY_SCAN,
+ value: $result->jsonSerialize(),
+ ttl: self::CACHE_TTL_SECONDS,
+ );
+ }//end setCachedScanResult()
+
+ /**
+ * Invalidate the cached scan result.
+ *
+ * Called automatically after every successful real purge so the
+ * next scan reflects the fresh state (REQ-CLN-010).
+ *
+ * @return void
+ *
+ * @spec openspec/specs/orphaned-data-cleanup/spec.md
+ */
+ public function invalidateCache(): void {
+ $this->cache()->remove(key: self::CACHE_KEY_SCAN);
+ }//end invalidateCache()
+
+ /**
+ * Resolve the requested category-name list, normalising the
+ * "empty means all" convention.
+ *
+ * @param array $requested The caller's list.
+ *
+ * @return array The resolved list.
+ */
+ private function resolveCategoryNames(array $requested): array {
+ if (count(value: $requested) === 0) {
+ return $this->registry->getCategoryNames();
+ }
+
+ return array_values(
+ array: array_unique(
+ array: array_filter(
+ array: $requested,
+ callback: static function ($value): bool {
+ return is_string(value: $value) === true && $value !== '';
+ }
+ )
+ )
+ );
+ }//end resolveCategoryNames()
+
+ /**
+ * Lazily resolve the distributed cache.
+ *
+ * @return ICache The cache instance.
+ */
+ private function cache(): ICache {
+ if ($this->cache === null) {
+ $this->cache = $this->cacheFactory->createDistributed(
+ prefix: 'launchpad_cleanup'
+ );
+ }
+
+ return $this->cache;
+ }//end cache()
+
+ /**
+ * Emit one Activity event summarising a real purge run.
+ *
+ * Best-effort — a misconfigured Activity backend MUST NOT cause
+ * the purge call to fail; the audit trail is still written to
+ * the PSR-3 logger by the caller.
+ *
+ * @param CleanupResult $result The purge result.
+ * @param string|null $userId The actor (null = system).
+ * @param string $source The source label.
+ *
+ * @return void
+ */
+ private function emitActivityEvent(
+ CleanupResult $result,
+ ?string $userId,
+ string $source,
+ ): void {
+ try {
+ $event = $this->activityManager->generateEvent();
+ $event->setApp(app: Application::APP_ID)
+ ->setType(type: self::ACTIVITY_TYPE)
+ ->setAffectedUser(affectedUser: ($userId ?? ''))
+ ->setAuthor(author: ($userId ?? ''))
+ ->setSubject(
+ subject: 'launchpad_cleanup_purge',
+ parameters: [
+ 'totalRows' => $result->getTotalRows(),
+ 'byCategory' => $result->getByCategory(),
+ 'durationMs' => $result->getDurationMs(),
+ 'source' => $source,
+ ]
+ )
+ ->setObject(
+ objectType: 'launchpad_cleanup',
+ objectId: 0,
+ objectName: $source
+ );
+
+ $this->activityManager->publish(event: $event);
+ } catch (IncompleteActivityException $e) {
+ $this->logger->warning(
+ message: sprintf(
+ 'launchpad.cleanup.activity_emit_failed: %s',
+ $e->getMessage()
+ )
+ );
+ } catch (Throwable $t) {
+ $this->logger->warning(
+ message: sprintf(
+ 'launchpad.cleanup.activity_emit_threw: %s',
+ $t->getMessage()
+ )
+ );
+ }//end try
+ }//end emitActivityEvent()
}//end class
diff --git a/lib/Service/PeopleWidgetService.php b/lib/Service/PeopleWidgetService.php
index f8fa699b0..9ed9beea0 100644
--- a/lib/Service/PeopleWidgetService.php
+++ b/lib/Service/PeopleWidgetService.php
@@ -22,8 +22,8 @@
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
*/
declare(strict_types=1);
@@ -52,543 +52,785 @@
* state across multiple services.
* @spec openspec/specs/people-widget/spec.md
*/
-class PeopleWidgetService
-{
- /**
- * Hard ceiling on the `limit` query parameter (REQ-PPL-003).
- *
- * The shipping reference allows max 100 per page; we keep that.
- *
- * @var int
- */
- public const MAX_LIMIT = 100;
-
- /**
- * Default page size when the caller omits `limit`.
- *
- * @var int
- */
- public const DEFAULT_LIMIT = 50;
-
- /**
- * Avatar fetch resolution (REQ-PPL-007). Display size is layout-driven
- * client-side; the URL we return always points at the 128 px endpoint
- * so the same URL works for card / grid / list at retina densities.
- *
- * @var int
- */
- public const AVATAR_SIZE_PX = 128;
-
- /**
- * Standard `IAccountManager` properties projected into the response.
- *
- * Order is irrelevant — the response is a flat object — but the
- * constant doubles as the iteration list inside
- * {@see self::buildAccountFields()} so we keep it tightly scoped to
- * properties the widget actually surfaces.
- *
- * @var string[]
- */
- private const STANDARD_PROPERTIES = [
- IAccountManager::PROPERTY_PHONE,
- IAccountManager::PROPERTY_ADDRESS,
- IAccountManager::PROPERTY_WEBSITE,
- IAccountManager::PROPERTY_TWITTER,
- IAccountManager::PROPERTY_FEDIVERSE,
- IAccountManager::PROPERTY_ORGANISATION,
- IAccountManager::PROPERTY_ROLE,
- IAccountManager::PROPERTY_HEADLINE,
- IAccountManager::PROPERTY_BIOGRAPHY,
- IAccountManager::PROPERTY_PRONOUNS,
- IAccountManager::PROPERTY_BIRTHDATE,
- ];
-
- /**
- * Constructor.
- *
- * @param IUserManager $userManager Nextcloud user manager.
- * @param IGroupManager $groupManager Nextcloud group manager.
- * @param IAccountManager $accountManager Nextcloud account / profile manager.
- * @param IURLGenerator $urlGenerator Absolute-URL helper for avatars.
- * @param AdminTemplateService $adminTemplateService Single-source-of-truth wrapper around
- * `IGroupManager::getUserGroupIds`
- * (REQ-TMPL-013 grep guard).
- */
- public function __construct(
- private readonly IUserManager $userManager,
- private readonly IGroupManager $groupManager,
- private readonly IAccountManager $accountManager,
- private readonly IURLGenerator $urlGenerator,
- private readonly AdminTemplateService $adminTemplateService,
- ) {
- }//end __construct()
-
- /**
- * List visible users matching the placement configuration.
- *
- * REQ-PPL-003: offset-based pagination, capped at {@see self::MAX_LIMIT}.
- * REQ-PPL-006: `filters` array supports `{fieldName: 'group', operator:
- * 'in', values: [...]}` to restrict the candidate pool to the union of
- * the listed group memberships.
- *
- * Response shape: `{users: [...], total: int, hasMore: bool}`.
- *
- * @param array $filters Filter predicate list. Each entry has
- * the shape `{fieldName, operator, values}`.
- * @param bool $excludeDisabled True (default) excludes disabled NC
- * users from the candidate pool.
- * @param bool $showBirthdays False strips the birthdate field from
- * every result row.
- * @param string $sortBy One of `displayName` (default),
- * `group`, or `recent-activity`.
- * @param int $limit Page size (1..MAX_LIMIT).
- * @param int $offset Page offset (>=0).
- *
- * @return array Pagination envelope with keys `users`, `total`, `hasMore`.
- *
- * @throws InvalidArgumentException When `limit` exceeds MAX_LIMIT, is
- * non-positive, or `offset` is negative;
- * or when `sortBy` is `recent-activity`.
- *
- * @spec openspec/specs/people-widget/spec.md
- */
- public function listUsers(
- array $filters=[],
- bool $excludeDisabled=true,
- bool $showBirthdays=true,
- string $sortBy='displayName',
- int $limit=self::DEFAULT_LIMIT,
- int $offset=0,
- ): array {
- if ($limit < 1 || $limit > self::MAX_LIMIT) {
- throw new InvalidArgumentException(
- message: 'limit must be between 1 and '.self::MAX_LIMIT
- );
- }
-
- if ($offset < 0) {
- throw new InvalidArgumentException(message: 'offset must be >= 0');
- }
-
- if ($sortBy === 'recent-activity') {
- throw new InvalidArgumentException(
- message: 'sortBy=recent-activity is not yet implemented'
- );
- }
-
- $candidates = $this->resolveCandidates(filters: $filters);
-
- if ($excludeDisabled === true) {
- $candidates = array_values(
- array: array_filter(
- array: $candidates,
- callback: static fn(IUser $candidate): bool => $candidate->isEnabled() === true
- )
- );
- }
-
- $candidates = $this->sortCandidates(users: $candidates, sortBy: $sortBy);
-
- $total = count(value: $candidates);
- $page = array_slice(
- array: $candidates,
- offset: $offset,
- length: $limit
- );
-
- $users = [];
- foreach ($page as $user) {
- $users[] = $this->buildUserProfile(
- user: $user,
- showBirthdays: $showBirthdays
- );
- }
-
- return [
- 'users' => $users,
- 'total' => $total,
- 'hasMore' => ($offset + count(value: $page)) < $total,
- ];
- }//end listUsers()
-
- /**
- * Compute the number of days between today (UTC) and the user's next
- * birthday. Returns `null` for invalid / missing input.
- *
- * Wraps to next year when the birthday already passed this year. Feb-29
- * birthdays in non-leap years substitute Feb-28 to avoid throwing.
- *
- * @param string|null $birthdate ISO-8601 date string (e.g. "1990-06-10"),
- * locale-formatted (e.g. "10-06-1990"), or
- * null when the user has no birthdate.
- *
- * @return int|null Days until next birthday, or null when input is
- * invalid.
- *
- * @spec openspec/specs/people-widget/spec.md
- */
- public static function computeDaysToBirthday(?string $birthdate): ?int
- {
- $iso = self::normaliseToIsoDate(raw: $birthdate);
- if ($iso === null) {
- return null;
- }
-
- try {
- $birth = new DateTimeImmutable(datetime: $iso);
- } catch (Exception $e) {
- return null;
- }
-
- $today = new DateTimeImmutable(datetime: 'today');
-
- $monthDay = $birth->format(format: 'm-d');
- // Feb-29 guard: in non-leap target years, fall back to Feb-28.
- $candidate = self::buildBirthdayInYear(
- year: (int) $today->format(format: 'Y'),
- monthDay: $monthDay
- );
-
- if ($candidate < $today) {
- $candidate = self::buildBirthdayInYear(
- year: ((int) $today->format(format: 'Y')) + 1,
- monthDay: $monthDay
- );
- }
-
- $diff = $today->diff(targetObject: $candidate);
- return (int) $diff->days;
- }//end computeDaysToBirthday()
-
- /**
- * Resolve the candidate pool based on the configured filters.
- *
- * When the filter set contains a `group` filter we use
- * `IGroupManager::get($gid)->getUsers()` to avoid scanning the full
- * user table. Without a group filter we fall back to
- * `IUserManager::search('')`. Group-value union is deduplicated by
- * UID via a `$seen` map.
- *
- * @param array $filters Filter list (entries shaped like
- * `{fieldName, operator, values}`).
- *
- * @return IUser[]
- */
- private function resolveCandidates(array $filters): array
- {
- $groupFilter = null;
- foreach ($filters as $filter) {
- if (($filter['fieldName'] ?? null) === 'group') {
- $groupFilter = $filter;
- break;
- }
- }
-
- if ($groupFilter === null) {
- return $this->userManager->search(pattern: '');
- }
-
- $values = $groupFilter['values'] ?? [];
- if (is_array(value: $values) === false || $values === []) {
- return [];
- }
-
- $seen = [];
- $result = [];
- foreach ($values as $groupId) {
- if (is_string(value: $groupId) === false || $groupId === '') {
- continue;
- }
-
- $group = $this->groupManager->get(gid: $groupId);
- if ($group === null) {
- // Unknown group MUST yield zero users for that value
- // without raising — REQ-PPL-006 scenario "Unknown group
- // name handled gracefully".
- continue;
- }
-
- foreach ($group->getUsers() as $user) {
- $uid = $user->getUID();
- if (isset($seen[$uid]) === true) {
- continue;
- }
-
- $seen[$uid] = true;
- $result[] = $user;
- }
- }//end foreach
-
- return $result;
- }//end resolveCandidates()
-
- /**
- * Stable ordering helper.
- *
- * @param IUser[] $users Candidate list.
- * @param string $sortBy One of `displayName` (default) or `group`.
- *
- * @return IUser[]
- */
- private function sortCandidates(array $users, string $sortBy): array
- {
- if ($sortBy === 'group') {
- usort(
- array: $users,
- callback: function (IUser $left, IUser $right): int {
- $leftGroup = $this->primaryGroup(user: $left);
- $rightGroup = $this->primaryGroup(user: $right);
- if ($leftGroup === $rightGroup) {
- return strcasecmp(
- string1: $left->getDisplayName(),
- string2: $right->getDisplayName()
- );
- }
-
- return strcasecmp(string1: $leftGroup, string2: $rightGroup);
- }
- );
-
- return $users;
- }
-
- // Default: displayName, case-insensitive ASCII collation.
- usort(
- array: $users,
- callback: static fn(IUser $left, IUser $right): int => strcasecmp(
- string1: $left->getDisplayName(),
- string2: $right->getDisplayName()
- )
- );
-
- return $users;
- }//end sortCandidates()
-
- /**
- * First (alphabetical) group id the user belongs to, or empty string.
- *
- * @param IUser $user The user.
- *
- * @return string Lowercased group id used as the sort bucket.
- */
- private function primaryGroup(IUser $user): string
- {
- $groups = $this->adminTemplateService->getUserGroupIdsFor(
- userId: $user->getUID()
- );
- if ($groups === []) {
- return '';
- }
-
- sort(array: $groups, flags: SORT_NATURAL | SORT_FLAG_CASE);
- return strtolower(string: $groups[0]);
- }//end primaryGroup()
-
- /**
- * Project a single user to the response shape.
- *
- * @param IUser $user The user to project.
- * @param bool $showBirthdays When false, omits the birthdate field
- * unconditionally (REQ-PPL-005).
- *
- * @return array
- */
- private function buildUserProfile(IUser $user, bool $showBirthdays): array
- {
- $uid = $user->getUID();
-
- $profile = [
- 'uid' => $uid,
- 'displayName' => $user->getDisplayName(),
- 'avatarUrl' => $this->buildAvatarUrl(uid: $uid),
- 'groups' => array_values(
- array: $this->adminTemplateService->getUserGroupIdsFor(userId: $uid)
- ),
- ];
-
- $email = $user->getEMailAddress();
- if (is_string(value: $email) === true && $email !== '') {
- $profile['email'] = $email;
- }
-
- $accountFields = $this->buildAccountFields(
- user: $user,
- showBirthdays: $showBirthdays
- );
-
- return array_merge($profile, $accountFields);
- }//end buildUserProfile()
-
- /**
- * Read the standard `IAccountManager` properties for a user, omitting
- * empty values. Birthdate is normalised to ISO-8601.
- *
- * @param IUser $user The user.
- * @param bool $showBirthdays When false, the birthdate property is
- * skipped even if set.
- *
- * @return array
- */
- private function buildAccountFields(IUser $user, bool $showBirthdays): array
- {
- try {
- $account = $this->accountManager->getAccount(user: $user);
- } catch (Exception $e) {
- return [];
- }
-
- $fields = [];
- foreach (self::STANDARD_PROPERTIES as $property) {
- if ($property === IAccountManager::PROPERTY_BIRTHDATE
- && $showBirthdays === false
- ) {
- continue;
- }
-
- try {
- $prop = $account->getProperty(property: $property);
- $value = $prop->getValue();
- } catch (Exception $e) {
- continue;
- }
-
- if ($value === '') {
- continue;
- }
-
- if ($property === IAccountManager::PROPERTY_BIRTHDATE) {
- $iso = self::normaliseToIsoDate(raw: $value);
- if ($iso === null) {
- continue;
- }
-
- $fields['birthdate'] = $iso;
- continue;
- }
-
- $fields[$property] = $value;
- }//end foreach
-
- return $fields;
- }//end buildAccountFields()
-
- /**
- * Build an absolute avatar URL pointing at the standard NC route.
- *
- * @param string $uid The user id.
- *
- * @return string Absolute URL to the 128 px avatar endpoint.
- */
- private function buildAvatarUrl(string $uid): string
- {
- return $this->urlGenerator->linkToRouteAbsolute(
- routeName: 'core.avatar.getAvatar',
- arguments: [
- 'userId' => $uid,
- 'size' => self::AVATAR_SIZE_PX,
- ]
- );
- }//end buildAvatarUrl()
-
- /**
- * Best-effort conversion of a stored birthdate string to ISO-8601
- * (`YYYY-MM-DD`). Accepts common locale formats (`DD-MM-YYYY`,
- * `DD/MM/YYYY`, `DD.MM.YYYY`, `YYYY-MM-DD`). Returns null on failure
- * so callers can omit the field cleanly.
- *
- * @param string|null $raw Stored birthdate value.
- *
- * @return string|null ISO date string or null when the input cannot
- * be parsed.
- */
- private static function normaliseToIsoDate(?string $raw): ?string
- {
- if ($raw === null) {
- return null;
- }
-
- $trimmed = trim(string: $raw);
- if ($trimmed === '') {
- return null;
- }
-
- // Already ISO?
- if (preg_match(pattern: '/^\d{4}-\d{2}-\d{2}$/', subject: $trimmed) === 1) {
- return $trimmed;
- }
-
- $separators = ['-', '/', '.'];
- foreach ($separators as $sep) {
- $parts = explode(separator: $sep, string: $trimmed);
- if (count(value: $parts) !== 3) {
- continue;
- }
-
- // Heuristic: 4-digit segment is the year. Position determines
- // whether DMY or YMD ordering applies.
- if (strlen(string: $parts[2]) !== 4 && strlen(string: $parts[0]) !== 4) {
- continue;
- }
-
- $day = '';
- $month = '';
- $year = '';
- if (strlen(string: $parts[2]) === 4) {
- $day = $parts[0];
- $month = $parts[1];
- $year = $parts[2];
- }
-
- if (strlen(string: $parts[0]) === 4) {
- $year = $parts[0];
- $month = $parts[1];
- $day = $parts[2];
- }
-
- if (ctype_digit(text: $year) === false
- || ctype_digit(text: $month) === false
- || ctype_digit(text: $day) === false
- ) {
- continue;
- }
-
- $iso = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day);
- if (checkdate(month: (int) $month, day: (int) $day, year: (int) $year) === false) {
- continue;
- }
-
- return $iso;
- }//end foreach
-
- return null;
- }//end normaliseToIsoDate()
-
- /**
- * Construct a `DateTimeImmutable` for the given month-day in the
- * supplied year, falling back to Feb-28 when the requested year is
- * not a leap year and the input is Feb-29.
- *
- * @param int $year Target year.
- * @param string $monthDay `MM-DD` slug (no validation; trusted caller).
- *
- * @return DateTimeImmutable
- */
- private static function buildBirthdayInYear(
- int $year,
- string $monthDay
- ): DateTimeImmutable {
- [$month, $day] = explode(separator: '-', string: $monthDay);
- $monthInt = (int) $month;
- $dayInt = (int) $day;
-
- if ($monthInt === 2 && $dayInt === 29
- && checkdate(month: 2, day: 29, year: $year) === false
- ) {
- $dayInt = 28;
- }
-
- return new DateTimeImmutable(
- datetime: sprintf('%04d-%02d-%02d', $year, $monthInt, $dayInt)
- );
- }//end buildBirthdayInYear()
+class PeopleWidgetService {
+ /**
+ * Hard ceiling on the `limit` query parameter (REQ-PPL-003).
+ *
+ * The shipping reference allows max 100 per page; we keep that.
+ *
+ * @var int
+ */
+ public const MAX_LIMIT = 100;
+
+ /**
+ * Default page size when the caller omits `limit`.
+ *
+ * @var int
+ */
+ public const DEFAULT_LIMIT = 50;
+
+ /**
+ * Avatar fetch resolution (REQ-PPL-007). Display size is layout-driven
+ * client-side; the URL we return always points at the 128 px endpoint
+ * so the same URL works for card / grid / list at retina densities.
+ *
+ * @var int
+ */
+ public const AVATAR_SIZE_PX = 128;
+
+ /**
+ * Standard `IAccountManager` properties projected into the response.
+ *
+ * Order is irrelevant — the response is a flat object — but the
+ * constant doubles as the iteration list inside
+ * {@see self::buildAccountFields()} so we keep it tightly scoped to
+ * properties the widget actually surfaces.
+ *
+ * @var string[]
+ */
+ private const STANDARD_PROPERTIES = [
+ IAccountManager::PROPERTY_PHONE,
+ IAccountManager::PROPERTY_ADDRESS,
+ IAccountManager::PROPERTY_WEBSITE,
+ IAccountManager::PROPERTY_TWITTER,
+ IAccountManager::PROPERTY_FEDIVERSE,
+ IAccountManager::PROPERTY_ORGANISATION,
+ IAccountManager::PROPERTY_ROLE,
+ IAccountManager::PROPERTY_HEADLINE,
+ IAccountManager::PROPERTY_BIOGRAPHY,
+ IAccountManager::PROPERTY_PRONOUNS,
+ IAccountManager::PROPERTY_BIRTHDATE,
+ ];
+
+ /**
+ * Constructor.
+ *
+ * @param IUserManager $userManager Nextcloud user manager.
+ * @param IGroupManager $groupManager Nextcloud group manager.
+ * @param IAccountManager $accountManager Nextcloud account / profile manager.
+ * @param IURLGenerator $urlGenerator Absolute-URL helper for avatars.
+ * @param AdminTemplateService $adminTemplateService Single-source-of-truth wrapper around
+ * `IGroupManager::getUserGroupIds`
+ * (REQ-TMPL-013 grep guard).
+ */
+ public function __construct(
+ private readonly IUserManager $userManager,
+ private readonly IGroupManager $groupManager,
+ private readonly IAccountManager $accountManager,
+ private readonly IURLGenerator $urlGenerator,
+ private readonly AdminTemplateService $adminTemplateService,
+ ) {
+ }//end __construct()
+
+ /**
+ * List visible users matching the placement configuration.
+ *
+ * REQ-PPL-003: offset-based pagination, capped at {@see self::MAX_LIMIT}.
+ * REQ-PPL-006: `filters` array supports `{fieldName: 'group', operator:
+ * 'in', values: [...]}` to restrict the candidate pool to the union of
+ * the listed group memberships.
+ *
+ * Response shape: `{users: [...], total: int, hasMore: bool}`.
+ *
+ * @param array $filters Filter predicate list. Each entry has
+ * the shape `{fieldName, operator, values}`.
+ * @param bool $excludeDisabled True (default) excludes disabled NC
+ * users from the candidate pool.
+ * @param bool $showBirthdays False strips the birthdate field from
+ * every result row.
+ * @param string $sortBy One of `displayName` (default),
+ * `group`, or `recent-activity`.
+ * @param int $limit Page size (1..MAX_LIMIT).
+ * @param int $offset Page offset (>=0).
+ *
+ * @return array Pagination envelope with keys `users`, `total`, `hasMore`.
+ *
+ * @throws InvalidArgumentException When `limit` exceeds MAX_LIMIT, is
+ * non-positive, or `offset` is negative;
+ * or when `sortBy` is `recent-activity`.
+ *
+ * @spec openspec/specs/people-widget/spec.md
+ */
+ public function listUsers(
+ array $filters = [],
+ bool $excludeDisabled = true,
+ bool $showBirthdays = true,
+ string $sortBy = 'displayName',
+ int $limit = self::DEFAULT_LIMIT,
+ int $offset = 0,
+ ): array {
+ if ($limit < 1 || $limit > self::MAX_LIMIT) {
+ throw new InvalidArgumentException(
+ message: 'limit must be between 1 and ' . self::MAX_LIMIT
+ );
+ }
+
+ if ($offset < 0) {
+ throw new InvalidArgumentException(message: 'offset must be >= 0');
+ }
+
+ if ($sortBy === 'recent-activity') {
+ throw new InvalidArgumentException(
+ message: 'sortBy=recent-activity is not yet implemented'
+ );
+ }
+
+ $groupFilter = $this->extractGroupFilter(filters: $filters);
+
+ // No `group` filter AND the default `displayName` sort: page
+ // directly from the backend in display-name order so we never
+ // materialize more IUser objects than the requested window
+ // (plus any disabled users skipped inside it). This avoids the
+ // former full-directory `IUserManager::search('')` scan on every
+ // People-widget render. REQ-PPL-003.
+ if ($groupFilter === null && $sortBy !== 'group') {
+ return $this->listDirectoryPageByDisplayName(
+ excludeDisabled: $excludeDisabled,
+ showBirthdays: $showBirthdays,
+ limit: $limit,
+ offset: $offset
+ );
+ }
+
+ // Bounded candidate pool: either the union of the filtered group
+ // memberships, or — only for the rare admin-selected `group` sort
+ // with no group filter — the full directory, since ordering by
+ // each user's primary-group membership is inherently a
+ // full-directory read (see resolveCandidates()).
+ $candidates = $this->resolveCandidates(
+ filters: $filters,
+ groupFilter: $groupFilter
+ );
+
+ return $this->paginateCandidates(
+ candidates: $candidates,
+ excludeDisabled: $excludeDisabled,
+ showBirthdays: $showBirthdays,
+ sortBy: $sortBy,
+ limit: $limit,
+ offset: $offset
+ );
+ }//end listUsers()
+
+ /**
+ * Page the directory directly from the backend in display-name order,
+ * bounded to the requested window.
+ *
+ * `IUserManager::searchDisplayName('', limit, offset)` returns users
+ * already sorted by display name with backend-level pagination, so a
+ * bounded fetch is the correct global-order window (unlike
+ * `search('')`, which returns backend/UID order). Disabled users are
+ * skipped inside the streamed window when `excludeDisabled` is true;
+ * the exact `total` is derived from `countUsersTotal()` (minus
+ * `countDisabledUsers()`) so no full scan is needed to size it.
+ *
+ * @param bool $excludeDisabled Whether disabled users are excluded.
+ * @param bool $showBirthdays Whether birthdate is projected.
+ * @param int $limit Page size (already validated 1..MAX_LIMIT).
+ * @param int $offset Page offset (already validated >= 0).
+ *
+ * @return array Pagination envelope with keys `users`, `total`, `hasMore`.
+ */
+ private function listDirectoryPageByDisplayName(
+ bool $excludeDisabled,
+ bool $showBirthdays,
+ int $limit,
+ int $offset,
+ ): array {
+ $page = $this->collectDisplayNamePage(
+ excludeDisabled: $excludeDisabled,
+ limit: $limit,
+ offset: $offset
+ );
+ $total = $this->countDirectory(excludeDisabled: $excludeDisabled);
+
+ $users = [];
+ foreach ($page as $user) {
+ $users[] = $this->buildUserProfile(
+ user: $user,
+ showBirthdays: $showBirthdays
+ );
+ }
+
+ return [
+ 'users' => $users,
+ 'total' => $total,
+ 'hasMore' => ($offset + count(value: $page)) < $total,
+ ];
+ }//end listDirectoryPageByDisplayName()
+
+ /**
+ * Stream `searchDisplayName` in bounded chunks, skipping disabled
+ * users (when requested) and the first `$offset` matches, until
+ * `$limit` users are collected or the backend is exhausted.
+ *
+ * Reads at most `offset + limit` matches (plus any disabled users
+ * interleaved in that window) — never the full user table.
+ *
+ * @param bool $excludeDisabled Whether disabled users are skipped.
+ * @param int $limit Number of users to collect.
+ * @param int $offset Number of matching users to skip first.
+ *
+ * @return IUser[] The requested page in display-name order.
+ */
+ private function collectDisplayNamePage(
+ bool $excludeDisabled,
+ int $limit,
+ int $offset,
+ ): array {
+ $chunkSize = max($limit, self::DEFAULT_LIMIT);
+ $page = [];
+ $toSkip = $offset;
+ $backendOffset = 0;
+
+ $pageCount = 0;
+ while ($pageCount < $limit) {
+ $batch = $this->userManager->searchDisplayName(
+ pattern: '',
+ limit: $chunkSize,
+ offset: $backendOffset
+ );
+ $batchCount = count(value: $batch);
+ if ($batchCount === 0) {
+ break;
+ }
+
+ foreach ($batch as $user) {
+ if ($excludeDisabled === true && $user->isEnabled() === false) {
+ continue;
+ }
+
+ if ($toSkip > 0) {
+ $toSkip--;
+ continue;
+ }
+
+ $page[] = $user;
+ $pageCount++;
+ if ($pageCount >= $limit) {
+ break;
+ }
+ }
+
+ $backendOffset += $batchCount;
+ if ($batchCount < $chunkSize) {
+ // Backend exhausted — no more pages to stream.
+ break;
+ }
+ }//end while
+
+ return $page;
+ }//end collectDisplayNamePage()
+
+ /**
+ * Exact directory size for the pagination envelope, computed from the
+ * backend counters rather than by materializing every user.
+ *
+ * @param bool $excludeDisabled When true, disabled users are excluded
+ * from the count.
+ *
+ * @return int The total candidate count.
+ */
+ private function countDirectory(bool $excludeDisabled): int {
+ $total = $this->userManager->countUsersTotal();
+ if (is_int(value: $total) === false) {
+ // Some backends cannot report a total (countUsersTotal()
+ // returns false); fall back to zero so the envelope stays
+ // finite and non-negative.
+ $total = 0;
+ }
+
+ if ($excludeDisabled === true) {
+ $total -= (int)$this->userManager->countDisabledUsers();
+ }
+
+ return max(0, $total);
+ }//end countDirectory()
+
+ /**
+ * Filter → sort → slice → project an in-memory candidate pool.
+ *
+ * Used for the group-filtered path (pool already bounded to group
+ * membership) and the rare no-filter `group` sort path.
+ *
+ * @param IUser[] $candidates The resolved candidate pool.
+ * @param bool $excludeDisabled Whether disabled users are excluded.
+ * @param bool $showBirthdays Whether birthdate is projected.
+ * @param string $sortBy Sort mode (`displayName` or `group`).
+ * @param int $limit Page size.
+ * @param int $offset Page offset.
+ *
+ * @return array Pagination envelope with keys `users`, `total`, `hasMore`.
+ */
+ private function paginateCandidates(
+ array $candidates,
+ bool $excludeDisabled,
+ bool $showBirthdays,
+ string $sortBy,
+ int $limit,
+ int $offset,
+ ): array {
+ if ($excludeDisabled === true) {
+ $candidates = array_values(
+ array: array_filter(
+ array: $candidates,
+ callback: static fn (IUser $candidate): bool => $candidate->isEnabled() === true
+ )
+ );
+ }
+
+ $candidates = $this->sortCandidates(users: $candidates, sortBy: $sortBy);
+
+ $total = count(value: $candidates);
+ $page = array_slice(
+ array: $candidates,
+ offset: $offset,
+ length: $limit
+ );
+
+ $users = [];
+ foreach ($page as $user) {
+ $users[] = $this->buildUserProfile(
+ user: $user,
+ showBirthdays: $showBirthdays
+ );
+ }
+
+ return [
+ 'users' => $users,
+ 'total' => $total,
+ 'hasMore' => ($offset + count(value: $page)) < $total,
+ ];
+ }//end paginateCandidates()
+
+ /**
+ * Compute the number of days between today (UTC) and the user's next
+ * birthday. Returns `null` for invalid / missing input.
+ *
+ * Wraps to next year when the birthday already passed this year. Feb-29
+ * birthdays in non-leap years substitute Feb-28 to avoid throwing.
+ *
+ * @param string|null $birthdate ISO-8601 date string (e.g. "1990-06-10"),
+ * locale-formatted (e.g. "10-06-1990"), or
+ * null when the user has no birthdate.
+ *
+ * @return int|null Days until next birthday, or null when input is
+ * invalid.
+ *
+ * @spec openspec/specs/people-widget/spec.md
+ */
+ public static function computeDaysToBirthday(?string $birthdate): ?int {
+ $iso = self::normaliseToIsoDate(raw: $birthdate);
+ if ($iso === null) {
+ return null;
+ }
+
+ try {
+ $birth = new DateTimeImmutable(datetime: $iso);
+ } catch (Exception $e) {
+ return null;
+ }
+
+ $today = new DateTimeImmutable(datetime: 'today');
+
+ $monthDay = $birth->format(format: 'm-d');
+ // Feb-29 guard: in non-leap target years, fall back to Feb-28.
+ $candidate = self::buildBirthdayInYear(
+ year: (int)$today->format(format: 'Y'),
+ monthDay: $monthDay
+ );
+
+ if ($candidate < $today) {
+ $candidate = self::buildBirthdayInYear(
+ year: ((int)$today->format(format: 'Y')) + 1,
+ monthDay: $monthDay
+ );
+ }
+
+ $diff = $today->diff(targetObject: $candidate);
+ return (int)$diff->days;
+ }//end computeDaysToBirthday()
+
+ /**
+ * Extract the first `group` filter from the filter list, if present.
+ *
+ * @param array $filters Filter list (entries shaped like
+ * `{fieldName, operator, values}`).
+ *
+ * @return array|null The `group` filter entry, or null when absent.
+ */
+ private function extractGroupFilter(array $filters): ?array {
+ foreach ($filters as $filter) {
+ if (($filter['fieldName'] ?? null) === 'group') {
+ return $filter;
+ }
+ }
+
+ return null;
+ }//end extractGroupFilter()
+
+ /**
+ * Resolve the candidate pool based on the configured filters.
+ *
+ * When a `group` filter is present we use
+ * `IGroupManager::get($gid)->getUsers()` to bound the pool to the
+ * union of the listed group memberships (deduplicated by UID via a
+ * `$seen` map) — never scanning the full user table.
+ *
+ * Without a group filter this method is reached ONLY for the rare
+ * admin-selected `group` sort mode; the common `displayName` path is
+ * served by the bounded backend pagination in
+ * {@see self::listDirectoryPageByDisplayName()} and never calls here.
+ * Ordering the whole directory by each user's primary-group
+ * membership is inherently a full-directory read, so this path falls
+ * back to `IUserManager::search('')` by design.
+ *
+ * @param array $filters Filter list (entries shaped like
+ * `{fieldName, operator, values}`).
+ * @param array|null $groupFilter The pre-extracted `group` filter, or
+ * null when none is configured.
+ *
+ * @return IUser[]
+ */
+ private function resolveCandidates(array $filters, ?array $groupFilter = null): array {
+ if ($groupFilter === null) {
+ $groupFilter = $this->extractGroupFilter(filters: $filters);
+ }
+
+ if ($groupFilter === null) {
+ return $this->userManager->search(pattern: '');
+ }
+
+ $values = $groupFilter['values'] ?? [];
+ if (is_array(value: $values) === false || $values === []) {
+ return [];
+ }
+
+ return $this->collectGroupMembers(groupIds: $values);
+ }//end resolveCandidates()
+
+ /**
+ * Union of the members of every named group, deduplicated by UID.
+ *
+ * Non-string and empty group ids are skipped, and an id that does not
+ * resolve to a real group contributes zero users rather than raising —
+ * REQ-PPL-006 scenario "Unknown group name handled gracefully".
+ * Ordering follows the group list, then each group's own member order;
+ * a user in several groups keeps the position of its first appearance.
+ *
+ * @param array $groupIds Raw `values` entries from the `group` filter.
+ *
+ * @return IUser[] Deduplicated member list.
+ *
+ * @spec openspec/specs/people-widget/spec.md
+ */
+ private function collectGroupMembers(array $groupIds): array {
+ $seen = [];
+ $result = [];
+ foreach ($groupIds as $groupId) {
+ if (is_string(value: $groupId) === false || $groupId === '') {
+ continue;
+ }
+
+ $group = $this->groupManager->get(gid: $groupId);
+ if ($group === null) {
+ continue;
+ }
+
+ foreach ($group->getUsers() as $user) {
+ $uid = $user->getUID();
+ if (isset($seen[$uid]) === true) {
+ continue;
+ }
+
+ $seen[$uid] = true;
+ $result[] = $user;
+ }
+ }//end foreach
+
+ return $result;
+ }//end collectGroupMembers()
+
+ /**
+ * Stable ordering helper.
+ *
+ * @param IUser[] $users Candidate list.
+ * @param string $sortBy One of `displayName` (default) or `group`.
+ *
+ * @return IUser[]
+ */
+ private function sortCandidates(array $users, string $sortBy): array {
+ if ($sortBy === 'group') {
+ usort(
+ array: $users,
+ callback: function (IUser $left, IUser $right): int {
+ $leftGroup = $this->primaryGroup(user: $left);
+ $rightGroup = $this->primaryGroup(user: $right);
+ if ($leftGroup === $rightGroup) {
+ return strcasecmp(
+ string1: $left->getDisplayName(),
+ string2: $right->getDisplayName()
+ );
+ }
+
+ return strcasecmp(string1: $leftGroup, string2: $rightGroup);
+ }
+ );
+
+ return $users;
+ }
+
+ // Default: displayName, case-insensitive ASCII collation.
+ usort(
+ array: $users,
+ callback: static fn (IUser $left, IUser $right): int => strcasecmp(
+ string1: $left->getDisplayName(),
+ string2: $right->getDisplayName()
+ )
+ );
+
+ return $users;
+ }//end sortCandidates()
+
+ /**
+ * First (alphabetical) group id the user belongs to, or empty string.
+ *
+ * @param IUser $user The user.
+ *
+ * @return string Lowercased group id used as the sort bucket.
+ */
+ private function primaryGroup(IUser $user): string {
+ $groups = $this->adminTemplateService->getUserGroupIdsFor(
+ userId: $user->getUID()
+ );
+ if ($groups === []) {
+ return '';
+ }
+
+ sort(array: $groups, flags: SORT_NATURAL | SORT_FLAG_CASE);
+ return strtolower(string: $groups[0]);
+ }//end primaryGroup()
+
+ /**
+ * Project a single user to the response shape.
+ *
+ * @param IUser $user The user to project.
+ * @param bool $showBirthdays When false, omits the birthdate field
+ * unconditionally (REQ-PPL-005).
+ *
+ * @return array
+ */
+ private function buildUserProfile(IUser $user, bool $showBirthdays): array {
+ $uid = $user->getUID();
+
+ $profile = [
+ 'uid' => $uid,
+ 'displayName' => $user->getDisplayName(),
+ 'avatarUrl' => $this->buildAvatarUrl(uid: $uid),
+ 'groups' => array_values(
+ array: $this->adminTemplateService->getUserGroupIdsFor(userId: $uid)
+ ),
+ ];
+
+ $email = $user->getEMailAddress();
+ if (is_string(value: $email) === true && $email !== '') {
+ $profile['email'] = $email;
+ }
+
+ $accountFields = $this->buildAccountFields(
+ user: $user,
+ showBirthdays: $showBirthdays
+ );
+
+ return array_merge($profile, $accountFields);
+ }//end buildUserProfile()
+
+ /**
+ * Read the standard `IAccountManager` properties for a user, omitting
+ * empty values. Birthdate is normalised to ISO-8601.
+ *
+ * @param IUser $user The user.
+ * @param bool $showBirthdays When false, the birthdate property is
+ * skipped even if set.
+ *
+ * @return array
+ */
+ private function buildAccountFields(IUser $user, bool $showBirthdays): array {
+ try {
+ $account = $this->accountManager->getAccount(user: $user);
+ } catch (Exception $e) {
+ return [];
+ }
+
+ $fields = [];
+ foreach (self::STANDARD_PROPERTIES as $property) {
+ if ($property === IAccountManager::PROPERTY_BIRTHDATE
+ && $showBirthdays === false
+ ) {
+ continue;
+ }
+
+ try {
+ $prop = $account->getProperty(property: $property);
+ $value = $prop->getValue();
+ } catch (Exception $e) {
+ continue;
+ }
+
+ if ($value === '') {
+ continue;
+ }
+
+ if ($property === IAccountManager::PROPERTY_BIRTHDATE) {
+ $iso = self::normaliseToIsoDate(raw: $value);
+ if ($iso === null) {
+ continue;
+ }
+
+ $fields['birthdate'] = $iso;
+ continue;
+ }
+
+ $fields[$property] = $value;
+ }//end foreach
+
+ return $fields;
+ }//end buildAccountFields()
+
+ /**
+ * Build an absolute avatar URL pointing at the standard NC route.
+ *
+ * @param string $uid The user id.
+ *
+ * @return string Absolute URL to the 128 px avatar endpoint.
+ */
+ private function buildAvatarUrl(string $uid): string {
+ return $this->urlGenerator->linkToRouteAbsolute(
+ routeName: 'core.avatar.getAvatar',
+ arguments: [
+ 'userId' => $uid,
+ 'size' => self::AVATAR_SIZE_PX,
+ ]
+ );
+ }//end buildAvatarUrl()
+
+ /**
+ * Best-effort conversion of a stored birthdate string to ISO-8601
+ * (`YYYY-MM-DD`). Accepts common locale formats (`DD-MM-YYYY`,
+ * `DD/MM/YYYY`, `DD.MM.YYYY`, `YYYY-MM-DD`). Returns null on failure
+ * so callers can omit the field cleanly.
+ *
+ * @param string|null $raw Stored birthdate value.
+ *
+ * @return string|null ISO date string or null when the input cannot
+ * be parsed.
+ */
+ private static function normaliseToIsoDate(?string $raw): ?string {
+ if ($raw === null) {
+ return null;
+ }
+
+ $trimmed = trim(string: $raw);
+ if ($trimmed === '') {
+ return null;
+ }
+
+ // Already ISO?
+ if (preg_match(pattern: '/^\d{4}-\d{2}-\d{2}$/', subject: $trimmed) === 1) {
+ return $trimmed;
+ }
+
+ $separators = ['-', '/', '.'];
+ foreach ($separators as $sep) {
+ $parts = explode(separator: $sep, string: $trimmed);
+ if (count(value: $parts) !== 3) {
+ continue;
+ }
+
+ $iso = self::isoFromDateParts(parts: $parts);
+ if ($iso !== null) {
+ return $iso;
+ }
+ }//end foreach
+
+ return null;
+ }//end normaliseToIsoDate()
+
+ /**
+ * Assemble an ISO-8601 date from three already-split date segments.
+ *
+ * Heuristic: the 4-digit segment is the year, and its position decides
+ * whether the input is DMY (`DD-MM-YYYY`) or YMD (`YYYY-MM-DD`). Any
+ * triple that carries no 4-digit year, holds a non-numeric segment, or
+ * does not denote a real calendar date yields null so the caller can
+ * try the next separator.
+ *
+ * @param array $parts Exactly three segments from a single `explode()`.
+ *
+ * @return string|null `YYYY-MM-DD`, or null when the triple is not a
+ * valid date.
+ */
+ private static function isoFromDateParts(array $parts): ?string {
+ if (strlen(string: $parts[2]) !== 4 && strlen(string: $parts[0]) !== 4) {
+ return null;
+ }
+
+ $day = '';
+ $month = '';
+ $year = '';
+ if (strlen(string: $parts[2]) === 4) {
+ $day = $parts[0];
+ $month = $parts[1];
+ $year = $parts[2];
+ }
+
+ if (strlen(string: $parts[0]) === 4) {
+ $year = $parts[0];
+ $month = $parts[1];
+ $day = $parts[2];
+ }
+
+ if (ctype_digit(text: $year) === false
+ || ctype_digit(text: $month) === false
+ || ctype_digit(text: $day) === false
+ ) {
+ return null;
+ }
+
+ $iso = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day);
+ if (checkdate(month: (int)$month, day: (int)$day, year: (int)$year) === false) {
+ return null;
+ }
+
+ return $iso;
+ }//end isoFromDateParts()
+
+ /**
+ * Construct a `DateTimeImmutable` for the given month-day in the
+ * supplied year, falling back to Feb-28 when the requested year is
+ * not a leap year and the input is Feb-29.
+ *
+ * @param int $year Target year.
+ * @param string $monthDay `MM-DD` slug (no validation; trusted caller).
+ *
+ * @return DateTimeImmutable
+ */
+ private static function buildBirthdayInYear(
+ int $year,
+ string $monthDay,
+ ): DateTimeImmutable {
+ [$month, $day] = explode(separator: '-', string: $monthDay);
+ $monthInt = (int)$month;
+ $dayInt = (int)$day;
+
+ if ($monthInt === 2 && $dayInt === 29
+ && checkdate(month: 2, day: 29, year: $year) === false
+ ) {
+ $dayInt = 28;
+ }
+
+ return new DateTimeImmutable(
+ datetime: sprintf('%04d-%02d-%02d', $year, $monthInt, $dayInt)
+ );
+ }//end buildBirthdayInYear()
}//end class
diff --git a/lib/Service/PermissionDeniedException.php b/lib/Service/PermissionDeniedException.php
index 152a0ea3a..d23d45e7f 100644
--- a/lib/Service/PermissionDeniedException.php
+++ b/lib/Service/PermissionDeniedException.php
@@ -19,8 +19,8 @@
* @version GIT:auto
* @link https://conduction.nl
*
- * SPDX-FileCopyrightText: 2026 LaunchPad Contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2024 Conduction B.V.