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

Latest release - License + License Code quality Documentation

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 ![Dashboard with Tiles](/screenshots/launchpad-dashboard-overview.png) + +## 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 -![Add dashboard button](/screenshots/tutorials/user/02-create-add-button.png) +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: +![Add dashboard button](/screenshots/tutorials/user/02-create-add-button.png) -- **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. -![Create dashboard modal](/screenshots/tutorials/user/02-create-modal.png) +### 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). +![Dashboard configuration modal](/screenshots/tutorials/user/10-config-modal.png) -![New dashboard with default bundle](/screenshots/tutorials/user/02-create-success.png) +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: -![Right-click context menu](/screenshots/tutorials/user/05-context-menu.png) +![Widget menu](/screenshots/tutorials/user/05-context-menu.png) 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, …). ![Edit content modal](/screenshots/tutorials/user/05-edit-content.png) -### 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. -![Style editor](/screenshots/tutorials/user/05-style-editor.png) +![Appearance section](/screenshots/tutorials/user/05-style-editor.png) -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. -![Right-click context menu](/screenshots/tutorials/user/05-context-menu.png) +![Widget menu](/screenshots/tutorials/user/05-context-menu.png) -### 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. ![After remove](/screenshots/tutorials/user/06-after-remove.png) :::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 ''; - } - - $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 ''; + } + + $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. + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -32,33 +32,31 @@ /** * Caller cannot VIEW / mutate the targeted dashboard(s). */ -class PermissionDeniedException extends RuntimeException -{ - /** - * Constructor. - * - * @param string $message The error message surfaced to the - * caller. - * @param string[] $deniedUuids The UUIDs the caller could not act on. - * Empty when the caller is not an admin - * at all (no per-uuid breakdown applies) - * or when the caller is a single-dashboard - * reaction path that has no batch context. - */ - public function __construct( - string $message='', - private readonly array $deniedUuids=[] - ) { - parent::__construct(message: $message); - }//end __construct() +class PermissionDeniedException extends RuntimeException { + /** + * Constructor. + * + * @param string $message The error message surfaced to the + * caller. + * @param string[] $deniedUuids The UUIDs the caller could not act on. + * Empty when the caller is not an admin + * at all (no per-uuid breakdown applies) + * or when the caller is a single-dashboard + * reaction path that has no batch context. + */ + public function __construct( + string $message = '', + private readonly array $deniedUuids = [], + ) { + parent::__construct(message: $message); + }//end __construct() - /** - * The UUIDs that caused the permission denial. - * - * @return string[] The denied UUID list. - */ - public function getDeniedUuids(): array - { - return $this->deniedUuids; - }//end getDeniedUuids() + /** + * The UUIDs that caused the permission denial. + * + * @return string[] The denied UUID list. + */ + public function getDeniedUuids(): array { + return $this->deniedUuids; + }//end getDeniedUuids() }//end class diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 92152e861..266a9c001 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -13,8 +13,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); @@ -22,13 +22,12 @@ namespace OCA\LaunchPad\Service; use Exception; +use OCA\LaunchPad\Db\AdminSetting; +use OCA\LaunchPad\Db\AdminSettingMapper; use OCA\LaunchPad\Db\Dashboard; use OCA\LaunchPad\Db\DashboardMapper; -use OCA\LaunchPad\Db\RoleAssignment; use OCA\LaunchPad\Db\WidgetPlacement; use OCA\LaunchPad\Db\WidgetPlacementMapper; -use OCA\LaunchPad\Db\AdminSettingMapper; -use OCA\LaunchPad\Db\AdminSetting; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IGroupManager; @@ -52,532 +51,560 @@ * score over the * default 50. */ -class PermissionService -{ - /** - * Constructor - * - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param WidgetPlacementMapper $placementMapper Widget placement mapper. - * @param AdminSettingMapper $settingMapper Admin setting mapper. - * @param DashboardShareService $shareService Share resolution service. - * @param IGroupManager $groupManager Group manager for the - * `isAdmin` check (group - * membership lookups go - * through the routing - * resolver — REQ-TMPL-013). - * @param AdminTemplateService $adminTemplateService Routing resolver — single - * source of truth for - * `IGroupManager::getUserGroupIds` - * (REQ-TMPL-013). - * @param RoleService $roleService Effective-role resolver - * layered on top of the - * permissions capability - * (REQ-ROLE-007, REQ-ROLE-008). - */ - public function __construct( - private readonly DashboardMapper $dashboardMapper, - private readonly WidgetPlacementMapper $placementMapper, - private readonly AdminSettingMapper $settingMapper, - private readonly DashboardShareService $shareService, - private readonly IGroupManager $groupManager, - private readonly AdminTemplateService $adminTemplateService, - private readonly RoleService $roleService, - ) { - }//end __construct() - - /** - * Whether the user can see a dashboard at all (owner OR has any share). - * - * @param string $userId The acting user ID. - * @param int $dashboardId The dashboard ID. - * - * @return bool True when the dashboard is visible to the user. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canViewDashboard(string $userId, int $dashboardId): bool - { - return $this->resolveAccessLevel(userId: $userId, dashboardId: $dashboardId) !== null; - }//end canViewDashboard() - - /** - * Check if user can edit a dashboard (widgets, tiles, layout). - * - * @param string $userId The user ID. - * @param int $dashboardId The dashboard ID. - * - * @return bool Whether the user can edit the dashboard. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-22 - */ - public function canEditDashboard(string $userId, int $dashboardId): bool - { - try { - $dashboard = $this->dashboardMapper->find(id: $dashboardId); - } catch (DoesNotExistException) { - return false; - } - - // REQ-ROLE-008: Viewer role blocks any mutation. - if ($this->roleService->isViewer(userId: $userId) === true) { - return false; - } - - // REQ-ROLE-001 / REQ-ROLE-007: LaunchPad Admin can edit any - // dashboard except for the admin-template type which is gated - // by Nextcloud admin status only (REQ-PERM-011). - if ($this->roleService->isAdmin(userId: $userId) === true - && $dashboard->getType() !== Dashboard::TYPE_ADMIN_TEMPLATE - ) { - return true; - } - - // Admin templates can only be edited by admins. - if ($dashboard->getType() === Dashboard::TYPE_ADMIN_TEMPLATE) { - return false; - } - - $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); - if ($level === null) { - return false; - } - - return in_array( - needle: $level, - haystack: [ - Dashboard::PERMISSION_ADD_ONLY, - Dashboard::PERMISSION_FULL, - ] - ); - }//end canEditDashboard() - - /** - * Check if user can edit dashboard metadata (name, description). Owner only. - * - * @param string $userId The user ID. - * @param int $dashboardId The dashboard ID. - * - * @return bool Whether the user can edit the dashboard metadata. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canEditDashboardMetadata( - string $userId, - int $dashboardId - ): bool { - try { - $dashboard = $this->dashboardMapper->find(id: $dashboardId); - } catch (DoesNotExistException) { - return false; - } - - // L4: admin-template owners retain full metadata-edit rights - // (REQ-PERM-011). An admin who owns a template must not be blocked - // from renaming it. Non-admin users can never edit admin templates. - if ($dashboard->getType() === Dashboard::TYPE_ADMIN_TEMPLATE) { - return $dashboard->getUserId() === $userId - && $this->groupManager->isAdmin(userId: $userId); - } - - // Only owner can rename / change description. - return $dashboard->getUserId() === $userId; - }//end canEditDashboardMetadata() - - /** - * Check if user can add widgets to a dashboard. - * - * @param string $userId The user ID. - * @param int $dashboardId The dashboard ID. - * - * @return bool Whether the user can add widgets. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-23 - */ - public function canAddWidget(string $userId, int $dashboardId): bool - { - // REQ-ROLE-008: Viewer role blocks any mutation, including - // widget additions. Admin role grants by virtue of resolving - // access level (admin override is in `resolveAccessLevel`). - if ($this->roleService->isViewer(userId: $userId) === true) { - return false; - } - - $level = $this->resolveAccessLevel(userId: $userId, dashboardId: $dashboardId); - if ($level === null) { - return false; - } - - return in_array( - needle: $level, - haystack: [ - Dashboard::PERMISSION_ADD_ONLY, - Dashboard::PERMISSION_FULL, - ] - ); - }//end canAddWidget() - - /** - * Check if user can remove a widget. - * - * @param string $userId The user ID. - * @param int $placementId The placement ID. - * - * @return bool Whether the user can remove the widget. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-22 - */ - public function canRemoveWidget(string $userId, int $placementId): bool - { - // REQ-ROLE-008: Viewer role blocks any mutation. - if ($this->roleService->isViewer(userId: $userId) === true) { - return false; - } - - try { - $placement = $this->placementMapper->find(id: $placementId); - $dashboard = $this->dashboardMapper->find( - id: $placement->getDashboardId() - ); - } catch (DoesNotExistException) { - return false; - } - - $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); - if ($level === null) { - return false; - } - - if ($level === Dashboard::PERMISSION_VIEW_ONLY) { - return false; - } - - if ($level === Dashboard::PERMISSION_FULL) { - return true; - } - - if ($level === Dashboard::PERMISSION_ADD_ONLY) { - return $placement->getIsCompulsory() === 0; - } - - return false; - }//end canRemoveWidget() - - /** - * Check if user can view a widget's data (read-path). - * - * Data-fetch endpoints (newsItems, calendarEvents) only require view - * permission on the underlying dashboard — not write/style permission. - * This is distinct from `canStyleWidget` which gates mutations. - * M1: fixes 403 for VIEW_ONLY users on data-fetch endpoints. - * - * @param string $userId The user ID. - * @param int $placementId The placement ID. - * - * @return bool Whether the user can view the widget's data. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canViewPlacement(string $userId, int $placementId): bool - { - try { - $placement = $this->placementMapper->find(id: $placementId); - } catch (DoesNotExistException) { - return false; - } - - return $this->canViewDashboard( - userId: $userId, - dashboardId: $placement->getDashboardId() - ); - }//end canViewPlacement() - - /** - * Check if user can style a widget. - * - * @param string $userId The user ID. - * @param int $placementId The placement ID. - * - * @return bool Whether the user can style the widget. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canStyleWidget(string $userId, int $placementId): bool - { - // REQ-ROLE-008: Viewer role blocks any mutation. - if ($this->roleService->isViewer(userId: $userId) === true) { - return false; - } - - try { - $placement = $this->placementMapper->find(id: $placementId); - $dashboard = $this->dashboardMapper->find( - id: $placement->getDashboardId() - ); - } catch (DoesNotExistException) { - return false; - } - - $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); - if ($level === null) { - return false; - } - - return in_array( - needle: $level, - haystack: [ - Dashboard::PERMISSION_ADD_ONLY, - Dashboard::PERMISSION_FULL, - ] - ); - }//end canStyleWidget() - - /** - * Check if user can create dashboards. - * - * @param string $userId The user ID. - * - * @return bool Whether the user can create dashboards. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canCreateDashboard(string $userId): bool - { - // REQ-ROLE-008: Viewer role explicitly blocks dashboard creation - // including personal dashboards (REQ-ROLE-003 scenario). - if ($this->roleService->isViewer(userId: $userId) === true) { - return false; - } - - // REQ-ROLE-001 / REQ-ROLE-002: Admin and Editor may always - // create personal dashboards regardless of the - // `allow_user_dashboards` admin flag — the role is the - // canonical override. - if ($this->roleService->isEditorOrHigher(userId: $userId) === true) { - return true; - } - - // REQ-ASET-003 (extended): default `false` — when no row exists, - // personal dashboard creation MUST be blocked. Defense-in-depth - // companion to DashboardService::assertPersonalDashboardsAllowed(). - return (bool) $this->settingMapper->getValue( - key: AdminSetting::KEY_ALLOW_USER_DASHBOARDS, - default: false - ); - }//end canCreateDashboard() - - /** - * Check if multiple dashboards are allowed (global admin setting). - * - * This is a global configuration flag, not per-user — the admin setting - * `allow_multiple_dashboards` either permits or blocks all users from - * owning more than one dashboard. Call-sites already hold the user's - * dashboard list; they only need this flag to decide whether to allow - * the creation of an additional one. - * - * @return bool Whether multiple dashboards are allowed. - * - * @spec openspec/specs/permissions/spec.md - */ - public function canHaveMultipleDashboards(): bool - { - return (bool) $this->settingMapper->getValue( - key: AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS, - default: true - ); - }//end canHaveMultipleDashboards() - - /** - * Get the effective permission level for a dashboard, ignoring sharing. - * - * For `group_shared` dashboards (REQ-DASH-014): the resolved level is - * always `view_only` for non-admin members and `full` for admins — - * the row's own `permissionLevel` field is intentionally ignored so - * the read-only-for-members rule lives in one place. Pass `$userId` - * to enable the admin override; omit it to keep the legacy "ignore - * sharing, return record level" behaviour. - * - * @param Dashboard $dashboard The dashboard. - * @param string|null $userId The acting user (enables the - * group-shared admin override). Pass - * null to fall back to the record's - * own permission level. - * - * @return string The effective permission level. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-24 - */ - public function getEffectivePermissionLevel( - Dashboard $dashboard, - ?string $userId=null - ): string { - // REQ-DASH-014: group-shared dashboards are read-only for - // non-admin members and fully editable for admins, regardless of - // the row's persisted `permissionLevel` field (which is kept on - // the row for forward-compat with future per-tile editing). - if ($dashboard->getType() === Dashboard::TYPE_GROUP_SHARED) { - // REQ-ROLE-001 / REQ-ROLE-007: NC admin OR a LaunchPad Admin - // role grants full permissions on group_shared dashboards. - // Editors get full access only when the underlying group - // membership check (in `resolveAccessLevel`) has already - // succeeded — they edit through the membership path, not - // an unconditional override. - if ($userId !== null - && ($this->groupManager->isAdmin(userId: $userId) === true - || $this->roleService->isAdmin(userId: $userId) === true - || $this->roleService->isEditorOrHigher(userId: $userId) === true) - ) { - return Dashboard::PERMISSION_FULL; - } - - return Dashboard::PERMISSION_VIEW_ONLY; - } - - // If based on a template, use template's permission level. - if ($dashboard->getBasedOnTemplate() !== null) { - try { - $template = $this->dashboardMapper->find( - id: $dashboard->getBasedOnTemplate() - ); - return $template->getPermissionLevel(); - } catch (DoesNotExistException) { - // Template deleted, use dashboard's level. - } - } - - // Use dashboard's permission level or default. - $level = $dashboard->getPermissionLevel(); - if (empty($level) === false) { - return $level; - } - - return $this->settingMapper->getValue( - key: AdminSetting::KEY_DEFAULT_PERMISSION_LEVEL, - default: Dashboard::PERMISSION_FULL - ); - }//end getEffectivePermissionLevel() - - /** - * Resolve the effective permission level a user has on a dashboard: - * - If the user is the owner, returns the dashboard's effective level. - * - If a share applies (direct or via group), returns that share's level. - * - Otherwise returns null (no access). - * - * Pass either a dashboard id or an already-loaded dashboard entity. - * - * @param string $userId The user id. - * @param int|null $dashboardId The dashboard id (optional if $dashboard given). - * @param Dashboard|null $dashboard The dashboard entity (optional if $dashboardId given). - * - * @return string|null The permission level or null when no access. - * - * @spec openspec/specs/permissions/spec.md - */ - public function resolveAccessLevel( - string $userId, - ?int $dashboardId=null, - ?Dashboard $dashboard=null - ): ?string { - if ($dashboard === null) { - try { - $dashboard = $this->dashboardMapper->find(id: $dashboardId); - } catch (DoesNotExistException) { - return null; - } - } - - // Group-shared dashboards bypass the ownership-vs-share path: - // visibility is by group membership, not by per-row sharing. - // REQ-DASH-014. - if ($dashboard->getType() === Dashboard::TYPE_GROUP_SHARED) { - $groupId = (string) $dashboard->getGroupId(); - if ($groupId === Dashboard::DEFAULT_GROUP_ID) { - return $this->getEffectivePermissionLevel( - dashboard: $dashboard, - userId: $userId - ); - } - - $userGroupIds = $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ); - if (in_array(needle: $groupId, haystack: $userGroupIds, strict: true) === true - || $this->groupManager->isAdmin(userId: $userId) === true - || $this->roleService->isAdmin(userId: $userId) === true - ) { - return $this->getEffectivePermissionLevel( - dashboard: $dashboard, - userId: $userId - ); - }//end if - - return null; - }//end if - - if ($dashboard->getUserId() === $userId) { - return $this->getEffectivePermissionLevel( - dashboard: $dashboard, - userId: $userId - ); - } - - $shares = $this->shareService->resolveSharedDashboards( - userId: $userId, - groupIds: $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ) - ); - - return $shares[$dashboard->getId()] ?? null; - }//end resolveAccessLevel() - - /** - * Verify user owns a dashboard. - * - * @param string $userId The user ID. - * @param int $dashboardId The dashboard ID. - * - * @return Dashboard The verified dashboard. - * - * @throws \Exception If access is denied. - * - * @spec openspec/specs/permissions/spec.md - */ - public function verifyDashboardOwnership( - string $userId, - int $dashboardId - ): Dashboard { - $dashboard = $this->dashboardMapper->find(id: $dashboardId); - - if ($dashboard->getUserId() !== $userId) { - throw new Exception(message: 'Access denied'); - } - - return $dashboard; - }//end verifyDashboardOwnership() - - /** - * Verify user owns a placement's dashboard. - * - * @param string $userId The user ID. - * @param int $placementId The placement ID. - * - * @return WidgetPlacement The verified placement. - * - * @throws \Exception If access is denied. - * - * @spec openspec/specs/permissions/spec.md - */ - public function verifyPlacementOwnership( - string $userId, - int $placementId - ): WidgetPlacement { - $placement = $this->placementMapper->find(id: $placementId); - $this->verifyDashboardOwnership( - userId: $userId, - dashboardId: $placement->getDashboardId() - ); - - return $placement; - }//end verifyPlacementOwnership() +class PermissionService { + /** + * Constructor + * + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param WidgetPlacementMapper $placementMapper Widget placement mapper. + * @param AdminSettingMapper $settingMapper Admin setting mapper. + * @param DashboardShareService $shareService Share resolution service. + * @param IGroupManager $groupManager Group manager for the + * `isAdmin` check (group + * membership lookups go + * through the routing + * resolver — REQ-TMPL-013). + * @param AdminTemplateService $adminTemplateService Routing resolver — single + * source of truth for + * `IGroupManager::getUserGroupIds` + * (REQ-TMPL-013). + * @param RoleService $roleService Effective-role resolver + * layered on top of the + * permissions capability + * (REQ-ROLE-007, REQ-ROLE-008). + */ + public function __construct( + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly AdminSettingMapper $settingMapper, + private readonly DashboardShareService $shareService, + private readonly IGroupManager $groupManager, + private readonly AdminTemplateService $adminTemplateService, + private readonly RoleService $roleService, + ) { + }//end __construct() + + /** + * Whether the user can see a dashboard at all (owner OR has any share). + * + * @param string $userId The acting user ID. + * @param int $dashboardId The dashboard ID. + * + * @return bool True when the dashboard is visible to the user. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canViewDashboard(string $userId, int $dashboardId): bool { + return $this->resolveAccessLevel(userId: $userId, dashboardId: $dashboardId) !== null; + }//end canViewDashboard() + + /** + * Check if user can edit a dashboard (widgets, tiles, layout). + * + * @param string $userId The user ID. + * @param int $dashboardId The dashboard ID. + * + * @return bool Whether the user can edit the dashboard. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-22 + */ + public function canEditDashboard(string $userId, int $dashboardId): bool { + try { + $dashboard = $this->dashboardMapper->find(id: $dashboardId); + } catch (DoesNotExistException) { + return false; + } + + // REQ-ROLE-008: Viewer role blocks any mutation. + if ($this->roleService->isViewer(userId: $userId) === true) { + return false; + } + + // REQ-ROLE-001 / REQ-ROLE-007: LaunchPad Admin can edit any + // dashboard except for the admin-template type which is gated + // by Nextcloud admin status only (REQ-PERM-011). + if ($this->roleService->isAdmin(userId: $userId) === true + && $dashboard->getType() !== Dashboard::TYPE_ADMIN_TEMPLATE + ) { + return true; + } + + // Admin templates can only be edited by admins. + if ($dashboard->getType() === Dashboard::TYPE_ADMIN_TEMPLATE) { + return false; + } + + $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); + if ($level === null) { + return false; + } + + return in_array( + needle: $level, + haystack: [ + Dashboard::PERMISSION_ADD_ONLY, + Dashboard::PERMISSION_FULL, + ] + ); + }//end canEditDashboard() + + /** + * Check if user can edit dashboard metadata (name, description). Owner only. + * + * @param string $userId The user ID. + * @param int $dashboardId The dashboard ID. + * + * @return bool Whether the user can edit the dashboard metadata. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canEditDashboardMetadata( + string $userId, + int $dashboardId, + ): bool { + try { + $dashboard = $this->dashboardMapper->find(id: $dashboardId); + } catch (DoesNotExistException) { + return false; + } + + // L4: admin-template owners retain full metadata-edit rights + // (REQ-PERM-011). An admin who owns a template must not be blocked + // from renaming it. Non-admin users can never edit admin templates. + if ($dashboard->getType() === Dashboard::TYPE_ADMIN_TEMPLATE) { + return $dashboard->getUserId() === $userId + && $this->groupManager->isAdmin(userId: $userId); + } + + // Only owner can rename / change description. + return $dashboard->getUserId() === $userId; + }//end canEditDashboardMetadata() + + /** + * Check if user can add widgets to a dashboard. + * + * @param string $userId The user ID. + * @param int $dashboardId The dashboard ID. + * + * @return bool Whether the user can add widgets. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-23 + */ + public function canAddWidget(string $userId, int $dashboardId): bool { + // REQ-ROLE-008: Viewer role blocks any mutation, including + // widget additions. Admin role grants by virtue of resolving + // access level (admin override is in `resolveAccessLevel`). + if ($this->roleService->isViewer(userId: $userId) === true) { + return false; + } + + $level = $this->resolveAccessLevel(userId: $userId, dashboardId: $dashboardId); + if ($level === null) { + return false; + } + + return in_array( + needle: $level, + haystack: [ + Dashboard::PERMISSION_ADD_ONLY, + Dashboard::PERMISSION_FULL, + ] + ); + }//end canAddWidget() + + /** + * Check if user can remove a widget. + * + * @param string $userId The user ID. + * @param int $placementId The placement ID. + * + * @return bool Whether the user can remove the widget. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-22 + */ + public function canRemoveWidget(string $userId, int $placementId): bool { + // REQ-ROLE-008: Viewer role blocks any mutation. + if ($this->roleService->isViewer(userId: $userId) === true) { + return false; + } + + try { + $placement = $this->placementMapper->find(id: $placementId); + $dashboard = $this->dashboardMapper->find( + id: $placement->getDashboardId() + ); + } catch (DoesNotExistException) { + return false; + } + + $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); + if ($level === null) { + return false; + } + + if ($level === Dashboard::PERMISSION_VIEW_ONLY) { + return false; + } + + if ($level === Dashboard::PERMISSION_FULL) { + return true; + } + + if ($level === Dashboard::PERMISSION_ADD_ONLY) { + return $placement->getIsCompulsory() === 0; + } + + return false; + }//end canRemoveWidget() + + /** + * Check if user can view a widget's data (read-path). + * + * Data-fetch endpoints (newsItems, calendarEvents) only require view + * permission on the underlying dashboard — not write/style permission. + * This is distinct from `canStyleWidget` which gates mutations. + * M1: fixes 403 for VIEW_ONLY users on data-fetch endpoints. + * + * @param string $userId The user ID. + * @param int $placementId The placement ID. + * + * @return bool Whether the user can view the widget's data. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canViewPlacement(string $userId, int $placementId): bool { + try { + $placement = $this->placementMapper->find(id: $placementId); + } catch (DoesNotExistException) { + return false; + } + + return $this->canViewDashboard( + userId: $userId, + dashboardId: $placement->getDashboardId() + ); + }//end canViewPlacement() + + /** + * Check if user can style a widget. + * + * @param string $userId The user ID. + * @param int $placementId The placement ID. + * + * @return bool Whether the user can style the widget. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canStyleWidget(string $userId, int $placementId): bool { + // REQ-ROLE-008: Viewer role blocks any mutation. + if ($this->roleService->isViewer(userId: $userId) === true) { + return false; + } + + try { + $placement = $this->placementMapper->find(id: $placementId); + $dashboard = $this->dashboardMapper->find( + id: $placement->getDashboardId() + ); + } catch (DoesNotExistException) { + return false; + } + + $level = $this->resolveAccessLevel(userId: $userId, dashboard: $dashboard); + if ($level === null) { + return false; + } + + return in_array( + needle: $level, + haystack: [ + Dashboard::PERMISSION_ADD_ONLY, + Dashboard::PERMISSION_FULL, + ] + ); + }//end canStyleWidget() + + /** + * Check if user can create dashboards. + * + * @param string $userId The user ID. + * + * @return bool Whether the user can create dashboards. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canCreateDashboard(string $userId): bool { + // REQ-ROLE-008: Viewer role explicitly blocks dashboard creation + // including personal dashboards (REQ-ROLE-003 scenario). + if ($this->roleService->isViewer(userId: $userId) === true) { + return false; + } + + // REQ-ROLE-001 / REQ-ROLE-002: Admin and Editor may always + // create personal dashboards regardless of the + // `allow_user_dashboards` admin flag — the role is the + // canonical override. + if ($this->roleService->isEditorOrHigher(userId: $userId) === true) { + return true; + } + + // REQ-ASET-003 (extended): default `false` — when no row exists, + // personal dashboard creation MUST be blocked. Defense-in-depth + // companion to DashboardService::assertPersonalDashboardsAllowed(). + return (bool)$this->settingMapper->getValue( + key: AdminSetting::KEY_ALLOW_USER_DASHBOARDS, + default: false + ); + }//end canCreateDashboard() + + /** + * Check if multiple dashboards are allowed (global admin setting). + * + * This is a global configuration flag, not per-user — the admin setting + * `allow_multiple_dashboards` either permits or blocks all users from + * owning more than one dashboard. Call-sites already hold the user's + * dashboard list; they only need this flag to decide whether to allow + * the creation of an additional one. + * + * @return bool Whether multiple dashboards are allowed. + * + * @spec openspec/specs/permissions/spec.md + */ + public function canHaveMultipleDashboards(): bool { + return (bool)$this->settingMapper->getValue( + key: AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS, + default: true + ); + }//end canHaveMultipleDashboards() + + /** + * Get the effective permission level for a dashboard, ignoring sharing. + * + * For `group_shared` dashboards (REQ-DASH-014): the resolved level is + * always `view_only` for non-admin members and `full` for admins — + * the row's own `permissionLevel` field is intentionally ignored so + * the read-only-for-members rule lives in one place. Pass `$userId` + * to enable the admin override; omit it to keep the legacy "ignore + * sharing, return record level" behaviour. + * + * @param Dashboard $dashboard The dashboard. + * @param string|null $userId The acting user (enables the + * group-shared admin override). Pass + * null to fall back to the record's + * own permission level. + * + * @return string The effective permission level. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-24 + */ + public function getEffectivePermissionLevel( + Dashboard $dashboard, + ?string $userId = null, + ): string { + // REQ-DASH-014: group-shared dashboards are read-only for + // non-admin members and fully editable for admins, regardless of + // the row's persisted `permissionLevel` field (which is kept on + // the row for forward-compat with future per-tile editing). + if ($dashboard->getType() === Dashboard::TYPE_GROUP_SHARED) { + // REQ-ROLE-001 / REQ-ROLE-007: NC admin OR a LaunchPad Admin + // role grants full permissions on group_shared dashboards. + // Editors get full access only when the underlying group + // membership check (in `resolveAccessLevel`) has already + // succeeded — they edit through the membership path, not + // an unconditional override. + if ($userId !== null + && ($this->groupManager->isAdmin(userId: $userId) === true + || $this->roleService->isAdmin(userId: $userId) === true + || $this->roleService->isEditorOrHigher(userId: $userId) === true) + ) { + return Dashboard::PERMISSION_FULL; + } + + return Dashboard::PERMISSION_VIEW_ONLY; + } + + // If based on a template, use template's permission level. + if ($dashboard->getBasedOnTemplate() !== null) { + try { + $template = $this->dashboardMapper->find( + id: $dashboard->getBasedOnTemplate() + ); + return $template->getPermissionLevel(); + } catch (DoesNotExistException) { + // Template deleted, use dashboard's level. + } + } + + // Use dashboard's permission level or default. + $level = $dashboard->getPermissionLevel(); + if (empty($level) === false) { + return $level; + } + + return $this->settingMapper->getValue( + key: AdminSetting::KEY_DEFAULT_PERMISSION_LEVEL, + default: Dashboard::PERMISSION_FULL + ); + }//end getEffectivePermissionLevel() + + /** + * Resolve the effective permission level a user has on a dashboard: + * - If the user is the owner, returns the dashboard's effective level. + * - If a share applies (direct or via group), returns that share's level. + * - Otherwise returns null (no access). + * + * Pass either a dashboard id or an already-loaded dashboard entity. + * + * @param string $userId The user id. + * @param int|null $dashboardId The dashboard id (optional if $dashboard given). + * @param Dashboard|null $dashboard The dashboard entity (optional if $dashboardId given). + * + * @return string|null The permission level or null when no access. + * + * @spec openspec/specs/permissions/spec.md + */ + public function resolveAccessLevel( + string $userId, + ?int $dashboardId = null, + ?Dashboard $dashboard = null, + ): ?string { + if ($dashboard === null) { + try { + $dashboard = $this->dashboardMapper->find(id: $dashboardId); + } catch (DoesNotExistException) { + return null; + } + } + + // Group-shared dashboards bypass the ownership-vs-share path: + // visibility is by group membership, not by per-row sharing. + // REQ-DASH-014. + if ($dashboard->getType() === Dashboard::TYPE_GROUP_SHARED) { + $groupId = (string)$dashboard->getGroupId(); + if ($groupId === Dashboard::DEFAULT_GROUP_ID) { + return $this->getEffectivePermissionLevel( + dashboard: $dashboard, + userId: $userId + ); + } + + $userGroupIds = $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ); + if (in_array(needle: $groupId, haystack: $userGroupIds, strict: true) === true + || $this->groupManager->isAdmin(userId: $userId) === true + || $this->roleService->isAdmin(userId: $userId) === true + ) { + return $this->getEffectivePermissionLevel( + dashboard: $dashboard, + userId: $userId + ); + }//end if + + return null; + }//end if + + if ($dashboard->getUserId() === $userId) { + return $this->getEffectivePermissionLevel( + dashboard: $dashboard, + userId: $userId + ); + } + + $shares = $this->shareService->resolveSharedDashboards( + userId: $userId, + groupIds: $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ) + ); + + return $shares[$dashboard->getId()] ?? null; + }//end resolveAccessLevel() + + /** + * Verify user owns a dashboard. + * + * @param string $userId The user ID. + * @param int $dashboardId The dashboard ID. + * + * @return Dashboard The verified dashboard. + * + * @throws \Exception If access is denied. + * + * @spec openspec/specs/permissions/spec.md + */ + public function verifyDashboardOwnership( + string $userId, + int $dashboardId, + ): Dashboard { + $dashboard = $this->dashboardMapper->find(id: $dashboardId); + + if ($dashboard->getUserId() !== $userId) { + throw new Exception(message: 'Access denied'); + } + + return $dashboard; + }//end verifyDashboardOwnership() + + /** + * Whether the user may set/change/clear the mandatory-read + * acknowledgement requirement on a placement — a Nextcloud admin, a + * LaunchPad admin, or the owner of the placement's dashboard (the + * template author). A non-author MUST be rejected (REQ-ACK-001, + * ADR-005). Returns false when the placement or its dashboard is + * missing. + * + * @param string $userId The acting user ID. + * @param int $placementId The placement ID. + * + * @return bool True when the user may manage the requirement. + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + public function canManageAcknowledgement( + string $userId, + int $placementId, + ): bool { + if ($this->groupManager->isAdmin(userId: $userId) === true + || $this->roleService->isAdmin(userId: $userId) === true + ) { + return true; + } + + try { + $placement = $this->placementMapper->find(id: $placementId); + $dashboard = $this->dashboardMapper->find( + id: $placement->getDashboardId() + ); + } catch (DoesNotExistException) { + return false; + } + + return $dashboard->getUserId() === $userId; + }//end canManageAcknowledgement() + + /** + * Verify user owns a placement's dashboard. + * + * @param string $userId The user ID. + * @param int $placementId The placement ID. + * + * @return WidgetPlacement The verified placement. + * + * @throws \Exception If access is denied. + * + * @spec openspec/specs/permissions/spec.md + */ + public function verifyPlacementOwnership( + string $userId, + int $placementId, + ): WidgetPlacement { + $placement = $this->placementMapper->find(id: $placementId); + $this->verifyDashboardOwnership( + userId: $userId, + dashboardId: $placement->getDashboardId() + ); + + return $placement; + }//end verifyPlacementOwnership() }//end class diff --git a/lib/Service/PlacementService.php b/lib/Service/PlacementService.php index cfb5b5a07..b4f90dadb 100644 --- a/lib/Service/PlacementService.php +++ b/lib/Service/PlacementService.php @@ -24,225 +24,227 @@ /** * Service for managing widget placement CRUD operations. + * + * @spec openspec/specs/widgets/spec.md */ -class PlacementService -{ - /** - * Constructor - * - * @param WidgetPlacementMapper $placementMapper Widget placement mapper. - * @param TileUpdater $tileUpdater Tile updater service. - * @param PlacementUpdater $placementUpdater Placement updater service. - * @param PublicShareContext|null $publicShareContext Public-share bearer - * guard (nullable for - * legacy test doubles). - * @param QuotaService|null $quotaService Widget-quota enforcer - * (dashboard-quota-limits - * REQ-QUOTA-003). - * Nullable to keep - * existing test doubles - * working — when - * absent no quota is - * enforced. - */ - public function __construct( - private readonly WidgetPlacementMapper $placementMapper, - private readonly TileUpdater $tileUpdater, - private readonly PlacementUpdater $placementUpdater, - private readonly ?PublicShareContext $publicShareContext=null, - private readonly ?QuotaService $quotaService=null, - ) { - }//end __construct() - - /** - * Add a widget to a dashboard. - * - * @param int $dashboardId Dashboard ID. - * @param string $widgetId Widget ID. - * @param int $gridX Grid X position. - * @param int $gridY Grid Y position. - * @param int $gridWidth Grid width. - * @param int $gridHeight Grid height. - * @param array|null $content Optional per-type content payload for - * custom widgets (registry-driven types - * like `label`, `text`, etc. — shape - * matches the type's `defaultContent`). - * - * @return WidgetPlacement The created widget placement. - * - * @throws \OCA\LaunchPad\Exception\QuotaExceededException When the - * dashboard is at the configured widget limit - * (dashboard-quota-limits REQ-QUOTA-003). - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement - */ - public function addWidget( - int $dashboardId, - string $widgetId, - int $gridX=0, - int $gridY=0, - int $gridWidth=4, - int $gridHeight=4, - ?array $content=null - ): WidgetPlacement { - // Task-7 of dashboard-public-share — bearer cannot mutate placements. - $this->publicShareContext?->requireMutable(); - // Dashboard-quota-limits REQ-QUOTA-003: enforce the per-dashboard - // widget quota at the single placement-creation choke point so the - // REST add-widget, add-tile, and store paths are all bound. - // Admin compulsory-widget pushes wrap their call in - // QuotaService::runProvisioning() to bypass this (REQ-QUOTA-004). - $this->quotaService?->assertCanAddPlacement(dashboardId: $dashboardId); - $placement = new WidgetPlacement(); - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - $placement->setDashboardId($dashboardId); - $placement->setWidgetId($widgetId); - $placement->setGridX($gridX); - $placement->setGridY($gridY); - $placement->setGridWidth($gridWidth); - $placement->setGridHeight($gridHeight); - $placement->setIsCompulsory(0); - $placement->setIsVisible(1); - $placement->setShowTitle(1); - - if ($content !== null) { - $placement->setContentArray($content); - } - - $placement->setCreatedAt($now); - $placement->setUpdatedAt($now); - - return $this->placementMapper->insert(entity: $placement); - }//end addWidget() - - /** - * Add a tile to a dashboard using an array of tile data. - * - * @param int $dashboardId Dashboard ID. - * @param array $tileData Tile configuration data array. - * - * @return WidgetPlacement The created tile placement. - * - * @throws \OCA\LaunchPad\Exception\QuotaExceededException When the - * dashboard is at the configured widget limit - * (dashboard-quota-limits REQ-QUOTA-003). - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement - */ - public function addTileFromArray( - int $dashboardId, - array $tileData - ): WidgetPlacement { - // Task-7 of dashboard-public-share — bearer cannot mutate placements. - $this->publicShareContext?->requireMutable(); - // Dashboard-quota-limits REQ-QUOTA-003: tiles are placements too — - // bind them to the same per-dashboard widget quota choke point. - $this->quotaService?->assertCanAddPlacement(dashboardId: $dashboardId); - $placement = new WidgetPlacement(); - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - $placement->setDashboardId($dashboardId); - $placement->setWidgetId('tile-'.uniqid()); - $placement->setGridX($tileData['gridX'] ?? 0); - $placement->setGridY($tileData['gridY'] ?? 0); - $placement->setGridWidth($tileData['gridWidth'] ?? 2); - $placement->setGridHeight( - $tileData['gridHeight'] ?? 2 - ); - $placement->setIsCompulsory(0); - $placement->setIsVisible(1); - $placement->setShowTitle(1); - - $this->tileUpdater->applyTileConfig( - placement: $placement, - tileData: $tileData - ); - - $placement->setCreatedAt($now); - $placement->setUpdatedAt($now); - - return $this->placementMapper->insert(entity: $placement); - }//end addTileFromArray() - - /** - * Update a widget placement. - * - * @param int $placementId The placement ID. - * @param array $data The data to update. - * - * @return WidgetPlacement The updated widget placement. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-35 - */ - public function updatePlacement( - int $placementId, - array $data - ): WidgetPlacement { - // Task-7 of dashboard-public-share — bearer cannot mutate placements. - $this->publicShareContext?->requireMutable(); - $placement = $this->placementMapper->find(id: $placementId); - - $this->placementUpdater->applyGridUpdates( - placement: $placement, - data: $data - ); - $this->placementUpdater->applyDisplayUpdates( - placement: $placement, - data: $data - ); - $this->tileUpdater->applyTileUpdates( - placement: $placement, - data: $data - ); - - $placement->setUpdatedAt( - (new DateTime())->format(format: 'Y-m-d H:i:s') - ); - - return $this->placementMapper->update(entity: $placement); - }//end updatePlacement() - - /** - * Remove a widget placement. - * - * @param int $placementId The placement ID. - * - * @return void - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-36 - */ - public function removePlacement(int $placementId): void - { - // Task-7 of dashboard-public-share — bearer cannot mutate placements. - $this->publicShareContext?->requireMutable(); - $placement = $this->placementMapper->find(id: $placementId); - $this->placementMapper->delete(entity: $placement); - }//end removePlacement() - - /** - * Get placement by ID. - * - * @param int $placementId The placement ID. - * - * @return WidgetPlacement The widget placement. - */ - public function getPlacement(int $placementId): WidgetPlacement - { - return $this->placementMapper->find(id: $placementId); - }//end getPlacement() - - /** - * Get all placements for a dashboard. - * - * @param int $dashboardId The dashboard ID. - * - * @return WidgetPlacement[] The list of placements. - * - * @spec openspec/specs/widgets/spec.md - */ - public function getDashboardPlacements(int $dashboardId): array - { - return $this->placementMapper->findByDashboardId( - dashboardId: $dashboardId - ); - }//end getDashboardPlacements() +class PlacementService { + /** + * Constructor + * + * @param WidgetPlacementMapper $placementMapper Widget placement mapper. + * @param TileUpdater $tileUpdater Tile updater service. + * @param PlacementUpdater $placementUpdater Placement updater service. + * @param PublicShareContext|null $publicShareContext Public-share bearer + * guard (nullable for + * legacy test doubles). + * @param QuotaService|null $quotaService Widget-quota enforcer + * (dashboard-quota-limits + * REQ-QUOTA-003). + * Nullable to keep + * existing test doubles + * working — when + * absent no quota is + * enforced. + */ + public function __construct( + private readonly WidgetPlacementMapper $placementMapper, + private readonly TileUpdater $tileUpdater, + private readonly PlacementUpdater $placementUpdater, + private readonly ?PublicShareContext $publicShareContext = null, + private readonly ?QuotaService $quotaService = null, + ) { + }//end __construct() + + /** + * Add a widget to a dashboard. + * + * @param int $dashboardId Dashboard ID. + * @param string $widgetId Widget ID. + * @param int $gridX Grid X position. + * @param int $gridY Grid Y position. + * @param int $gridWidth Grid width. + * @param int $gridHeight Grid height. + * @param array|null $content Optional per-type content payload for + * custom widgets (registry-driven types + * like `label`, `text`, etc. — shape + * matches the type's `defaultContent`). + * + * @return WidgetPlacement The created widget placement. + * + * @throws \OCA\LaunchPad\Exception\QuotaExceededException When the + * dashboard is at the configured widget limit + * (dashboard-quota-limits REQ-QUOTA-003). + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement + */ + public function addWidget( + int $dashboardId, + string $widgetId, + int $gridX = 0, + int $gridY = 0, + int $gridWidth = 4, + int $gridHeight = 4, + ?array $content = null, + ): WidgetPlacement { + // Task-7 of dashboard-public-share — bearer cannot mutate placements. + $this->publicShareContext?->requireMutable(); + // Dashboard-quota-limits REQ-QUOTA-003: enforce the per-dashboard + // widget quota at the single placement-creation choke point so the + // REST add-widget, add-tile, and store paths are all bound. + // Admin compulsory-widget pushes wrap their call in + // QuotaService::runProvisioning() to bypass this (REQ-QUOTA-004). + $this->quotaService?->assertCanAddPlacement(dashboardId: $dashboardId); + $placement = new WidgetPlacement(); + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $placement->setDashboardId($dashboardId); + $placement->setWidgetId($widgetId); + $placement->setGridX($gridX); + $placement->setGridY($gridY); + $placement->setGridWidth($gridWidth); + $placement->setGridHeight($gridHeight); + $placement->setIsCompulsory(0); + $placement->setIsVisible(1); + $placement->setShowTitle(1); + + if ($content !== null) { + $placement->setContentArray($content); + } + + $placement->setCreatedAt($now); + $placement->setUpdatedAt($now); + + return $this->placementMapper->insert(entity: $placement); + }//end addWidget() + + /** + * Add a tile to a dashboard using an array of tile data. + * + * @param int $dashboardId Dashboard ID. + * @param array $tileData Tile configuration data array. + * + * @return WidgetPlacement The created tile placement. + * + * @throws \OCA\LaunchPad\Exception\QuotaExceededException When the + * dashboard is at the configured widget limit + * (dashboard-quota-limits REQ-QUOTA-003). + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement + */ + public function addTileFromArray( + int $dashboardId, + array $tileData, + ): WidgetPlacement { + // Task-7 of dashboard-public-share — bearer cannot mutate placements. + $this->publicShareContext?->requireMutable(); + // Dashboard-quota-limits REQ-QUOTA-003: tiles are placements too — + // bind them to the same per-dashboard widget quota choke point. + $this->quotaService?->assertCanAddPlacement(dashboardId: $dashboardId); + $placement = new WidgetPlacement(); + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $placement->setDashboardId($dashboardId); + $placement->setWidgetId('tile-' . uniqid()); + $placement->setGridX($tileData['gridX'] ?? 0); + $placement->setGridY($tileData['gridY'] ?? 0); + $placement->setGridWidth($tileData['gridWidth'] ?? 2); + $placement->setGridHeight( + $tileData['gridHeight'] ?? 2 + ); + $placement->setIsCompulsory(0); + $placement->setIsVisible(1); + $placement->setShowTitle(1); + + $this->tileUpdater->applyTileConfig( + placement: $placement, + tileData: $tileData + ); + + $placement->setCreatedAt($now); + $placement->setUpdatedAt($now); + + return $this->placementMapper->insert(entity: $placement); + }//end addTileFromArray() + + /** + * Update a widget placement. + * + * @param int $placementId The placement ID. + * @param array $data The data to update. + * + * @return WidgetPlacement The updated widget placement. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-35 + */ + public function updatePlacement( + int $placementId, + array $data, + ): WidgetPlacement { + // Task-7 of dashboard-public-share — bearer cannot mutate placements. + $this->publicShareContext?->requireMutable(); + $placement = $this->placementMapper->find(id: $placementId); + + $this->placementUpdater->applyGridUpdates( + placement: $placement, + data: $data + ); + $this->placementUpdater->applyDisplayUpdates( + placement: $placement, + data: $data + ); + $this->tileUpdater->applyTileUpdates( + placement: $placement, + data: $data + ); + $this->placementUpdater->applyAcknowledgementUpdates( + placement: $placement, + data: $data + ); + + $placement->setUpdatedAt( + (new DateTime())->format(format: 'Y-m-d H:i:s') + ); + + return $this->placementMapper->update(entity: $placement); + }//end updatePlacement() + + /** + * Remove a widget placement. + * + * @param int $placementId The placement ID. + * + * @return void + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-36 + */ + public function removePlacement(int $placementId): void { + // Task-7 of dashboard-public-share — bearer cannot mutate placements. + $this->publicShareContext?->requireMutable(); + $placement = $this->placementMapper->find(id: $placementId); + $this->placementMapper->delete(entity: $placement); + }//end removePlacement() + + /** + * Get placement by ID. + * + * @param int $placementId The placement ID. + * + * @return WidgetPlacement The widget placement. + */ + public function getPlacement(int $placementId): WidgetPlacement { + return $this->placementMapper->find(id: $placementId); + }//end getPlacement() + + /** + * Get all placements for a dashboard. + * + * @param int $dashboardId The dashboard ID. + * + * @return WidgetPlacement[] The list of placements. + * + * @spec openspec/specs/widgets/spec.md + */ + public function getDashboardPlacements(int $dashboardId): array { + return $this->placementMapper->findByDashboardId( + dashboardId: $dashboardId + ); + }//end getDashboardPlacements() }//end class diff --git a/lib/Service/PlacementUpdater.php b/lib/Service/PlacementUpdater.php index e1fea3bcd..36e15ac79 100644 --- a/lib/Service/PlacementUpdater.php +++ b/lib/Service/PlacementUpdater.php @@ -22,86 +22,241 @@ /** * Service for applying grid and display updates to widget placements. + * + * @spec openspec/specs/widgets/spec.md */ -class PlacementUpdater -{ - /** - * Apply grid position and size updates to a placement. - * - * @param WidgetPlacement $placement The placement entity. - * @param array $data The update data. - * - * @return void - * - * @spec openspec/specs/widgets/spec.md - */ - public function applyGridUpdates( - WidgetPlacement $placement, - array $data - ): void { - if (isset($data['gridX']) === true) { - $placement->setGridX($data['gridX']); - } - - if (isset($data['gridY']) === true) { - $placement->setGridY($data['gridY']); - } - - if (isset($data['gridWidth']) === true) { - $placement->setGridWidth($data['gridWidth']); - } - - if (isset($data['gridHeight']) === true) { - $placement->setGridHeight( - $data['gridHeight'] - ); - } - }//end applyGridUpdates() - - /** - * Apply display and style updates to a placement. - * - * @param WidgetPlacement $placement The placement entity. - * @param array $data The update data. - * - * @return void - * - * @spec openspec/specs/widgets/spec.md - */ - public function applyDisplayUpdates( - WidgetPlacement $placement, - array $data - ): void { - if (isset($data['isVisible']) === true) { - $placement->setIsVisible($data['isVisible']); - } - - if (isset($data['showTitle']) === true) { - $placement->setShowTitle($data['showTitle']); - } - - if (isset($data['customTitle']) === true) { - $placement->setCustomTitle( - $data['customTitle'] - ); - } - - if (isset($data['customIcon']) === true) { - $placement->setCustomIcon( - $data['customIcon'] - ); - } - - if (isset($data['styleConfig']) === true) { - $placement->setStyleConfigArray( - $data['styleConfig'] - ); - } - - if (isset($data['content']) === true && is_array($data['content']) === true) { - $placement->setContentArray( - $data['content'] - ); - } - }//end applyDisplayUpdates() +class PlacementUpdater { + /** + * Apply grid position and size updates to a placement. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + * + * @spec openspec/specs/widgets/spec.md + */ + public function applyGridUpdates( + WidgetPlacement $placement, + array $data, + ): void { + if (isset($data['gridX']) === true) { + $placement->setGridX($data['gridX']); + } + + if (isset($data['gridY']) === true) { + $placement->setGridY($data['gridY']); + } + + if (isset($data['gridWidth']) === true) { + $placement->setGridWidth($data['gridWidth']); + } + + if (isset($data['gridHeight']) === true) { + $placement->setGridHeight( + $data['gridHeight'] + ); + } + }//end applyGridUpdates() + + /** + * Apply display and style updates to a placement. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + * + * @spec openspec/specs/widgets/spec.md + */ + public function applyDisplayUpdates( + WidgetPlacement $placement, + array $data, + ): void { + if (isset($data['isVisible']) === true) { + $placement->setIsVisible($data['isVisible']); + } + + if (isset($data['showTitle']) === true) { + $placement->setShowTitle($data['showTitle']); + } + + if (isset($data['customTitle']) === true) { + $placement->setCustomTitle( + $data['customTitle'] + ); + } + + if (isset($data['customIcon']) === true) { + $placement->setCustomIcon( + $data['customIcon'] + ); + } + + if (isset($data['styleConfig']) === true) { + $placement->setStyleConfigArray( + $data['styleConfig'] + ); + } + + if (isset($data['content']) === true && is_array($data['content']) === true) { + $placement->setContentArray( + $data['content'] + ); + } + }//end applyDisplayUpdates() + + /** + * Apply mandatory-read acknowledgement updates to a placement and mint + * the stable `announcementKey` the first time acknowledgement is + * required. REQ-ACK-001. + * + * When `requiresAcknowledgement` is set to `1` and the placement does + * not yet carry an `announcementKey`, a fresh v4 UUID is minted so all + * recipients cloned from this (blueprint) placement share one identity + * (design D2). Clearing the requirement (`requiresAcknowledgement = 0`) + * does not delete the key or any receipts — receipts are retained as + * history (REQ-ACK-001 scenario "Clearing the requirement..."). + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + public function applyAcknowledgementUpdates( + WidgetPlacement $placement, + array $data, + ): void { + $this->applyAcknowledgementRequirement(placement: $placement, data: $data); + $this->applyAcknowledgementCopy(placement: $placement, data: $data); + $this->applyAcknowledgementCadence(placement: $placement, data: $data); + $this->mintAnnouncementKey(placement: $placement); + }//end applyAcknowledgementUpdates() + + /** + * Apply the `requiresAcknowledgement` toggle when present in the payload. + * + * The value is coerced through bool then int so any truthy shape the + * client sends ("1", true, 1) lands as the canonical 0/1 column value. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + */ + private function applyAcknowledgementRequirement( + WidgetPlacement $placement, + array $data, + ): void { + if (isset($data['requiresAcknowledgement']) === true) { + $placement->setRequiresAcknowledgement( + (int)((bool)$data['requiresAcknowledgement']) + ); + } + }//end applyAcknowledgementRequirement() + + /** + * Apply the human-facing acknowledgement copy — prompt and deadline. + * + * Both use `array_key_exists` rather than `isset` so an explicit null + * clears the stored value. An empty-string deadline is normalised to + * null (no deadline); an empty-string prompt is kept verbatim. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + */ + private function applyAcknowledgementCopy( + WidgetPlacement $placement, + array $data, + ): void { + if (array_key_exists('acknowledgementPrompt', $data) === true) { + $prompt = $data['acknowledgementPrompt']; + $promptValue = null; + if ($prompt !== null) { + $promptValue = (string)$prompt; + } + + $placement->setAcknowledgementPrompt($promptValue); + } + + if (array_key_exists('acknowledgementDeadline', $data) === true) { + $deadline = $data['acknowledgementDeadline']; + $deadlineValue = null; + if ($deadline !== null && $deadline !== '') { + $deadlineValue = (string)$deadline; + } + + $placement->setAcknowledgementDeadline($deadlineValue); + } + }//end applyAcknowledgementCopy() + + /** + * Apply the re-acknowledgement cadence controls. + * + * `reacknowledgeOnChange` is a 0/1 flag; `acknowledgementContentVersion` + * is only accepted when it is a positive integer so a malformed payload + * can never rewind the version and mass-invalidate existing receipts. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + */ + private function applyAcknowledgementCadence( + WidgetPlacement $placement, + array $data, + ): void { + if (isset($data['reacknowledgeOnChange']) === true) { + $placement->setReacknowledgeOnChange( + (int)((bool)$data['reacknowledgeOnChange']) + ); + } + + if (isset($data['acknowledgementContentVersion']) === true) { + $version = (int)$data['acknowledgementContentVersion']; + if ($version >= 1) { + $placement->setAcknowledgementContentVersion($version); + } + } + }//end applyAcknowledgementCadence() + + /** + * Mint the stable announcement identity the first time the requirement + * is enabled (REQ-ACK-001 / design D2). + * + * Once minted the key is never rotated — clearing the requirement keeps + * both the key and the receipts so history survives a toggle. + * + * @param WidgetPlacement $placement The placement entity. + * + * @return void + */ + private function mintAnnouncementKey(WidgetPlacement $placement): void { + if ($placement->getRequiresAcknowledgement() === 1 + && ($placement->getAnnouncementKey() === null + || $placement->getAnnouncementKey() === '') + ) { + $placement->setAnnouncementKey($this->generateUuid()); + } + }//end mintAnnouncementKey() + + /** + * Generate a v4 UUID using random_bytes (no external dependency). + * Mirrors `TemplateService::generateUuid()`. + * + * @return string A v4 UUID. + */ + private function generateUuid(): string { + $data = random_bytes(length: 16); + $data[6] = chr((ord($data[6]) & 0x0F) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3F) | 0x80); + return vsprintf( + format: '%s%s-%s-%s-%s-%s%s%s', + values: str_split(string: bin2hex(string: $data), length: 4) + ); + }//end generateUuid() }//end class diff --git a/lib/Service/PublicShareContext.php b/lib/Service/PublicShareContext.php index 3c556a279..6f5232961 100644 --- a/lib/Service/PublicShareContext.php +++ b/lib/Service/PublicShareContext.php @@ -43,67 +43,76 @@ * * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 */ -class PublicShareContext -{ +class PublicShareContext { - /** - * Whether the current request is a public-share bearer. - * - * @var boolean - */ - private bool $isBearer = false; + /** + * Whether the current request is a public-share bearer. + * + * @var boolean + */ + private bool $isBearer = false; - /** - * The bearer token (kept for audit logging only, never echoed back). - * - * @var string|null - */ - private ?string $token = null; + /** + * The bearer token (kept for audit logging only, never echoed back). + * + * @var string|null + */ + private ?string $token = null; - /** - * Mark the current request as authenticated via a public-share bearer. - * - * Called exactly once by `PublicShareController::renderShare()` after - * `PublicShareService::renderShareContent()` returns successfully. - * - * @param string $token The verified bearer token. - * - * @return void - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 - */ - public function markBearer(string $token): void - { - $this->isBearer = true; - $this->token = $token; - }//end markBearer() + /** + * Mark the current request as authenticated via a public-share bearer. + * + * Called exactly once by `PublicShareController::renderShare()` after + * `PublicShareService::renderShareContent()` returns successfully. + * + * @param string $token The verified bearer token. + * + * @return void + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 + */ + public function markBearer(string $token): void { + $this->isBearer = true; + $this->token = $token; + }//end markBearer() - /** - * The verified bearer token, or null when not in a bearer context. - * - * @return string|null - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 - */ - public function getToken(): ?string - { - return $this->token; - }//end getToken() + /** + * Whether the current request was authenticated via a public-share + * bearer token (read-only context). + * + * @return boolean True when `markBearer()` has been called for this request + * (the request is a public-share bearer). + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 + */ + public function isBearer(): bool { + return $this->isBearer; + }//end isBearer() - /** - * Guard a mutation path. Throws ShareReadOnlyException when the - * current request is a public-share bearer. - * - * @return void - * - * @throws ShareReadOnlyException When the request is a bearer (HTTP 403). - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 - */ - public function requireMutable(): void - { - if ($this->isBearer === true) { - throw new ShareReadOnlyException(); - } - }//end requireMutable() + /** + * The verified bearer token, or null when not in a bearer context. + * + * @return string|null + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 + */ + public function getToken(): ?string { + return $this->token; + }//end getToken() + + /** + * Guard a mutation path. Throws ShareReadOnlyException when the + * current request is a public-share bearer. + * + * @return void + * + * @throws ShareReadOnlyException When the request is a bearer (HTTP 403). + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-7 + */ + public function requireMutable(): void { + if ($this->isBearer === true) { + throw new ShareReadOnlyException(); + } + }//end requireMutable() }//end class diff --git a/lib/Service/PublicShareService.php b/lib/Service/PublicShareService.php index 57b847fa0..88a69037c 100644 --- a/lib/Service/PublicShareService.php +++ b/lib/Service/PublicShareService.php @@ -30,7 +30,8 @@ use OCA\LaunchPad\Db\DashboardMapper; use OCA\LaunchPad\Db\PublicShare; use OCA\LaunchPad\Db\PublicShareMapper; -use OCA\LaunchPad\Exception\ShareExpiredException; +use OCA\LaunchPad\Db\WidgetPlacement; +use OCA\LaunchPad\Db\WidgetPlacementMapper; use OCA\LaunchPad\Exception\ShareNotFoundException; use OCA\LaunchPad\Exception\SharePasswordRequiredException; use OCP\AppFramework\Db\DoesNotExistException; @@ -46,370 +47,383 @@ * Service for public-share lifecycle management. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * Constructor wiring only. A share is a security boundary, so this one + * service must hold the three mappers it reads plus the four NC security + * collaborators the flow requires — IThrottler and MaxDelayReached for + * brute-force control, IHasher for the share password, ISecureRandom for + * the token — alongside IGroupManager and the logger. * * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 */ -class PublicShareService -{ - - /** - * Brute-force action name for general share-page access failures. - * - * @var string - */ - public const ACTION_SHARE_ACCESS = 'launchpad_share_access'; - - /** - * Brute-force action name for wrong-password unlock attempts. - * - * @var string - */ - public const ACTION_SHARE_PASSWORD = 'launchpad_share_password'; - - /** - * Constructor. - * - * @param PublicShareMapper $shareMapper Mapper for public shares. - * @param DashboardMapper $dashMapper Dashboard mapper for ownership checks. - * @param IGroupManager $groupManager NC group manager for admin checks. - * @param IHasher $hasher NC BCrypt hasher. - * @param ISecureRandom $secureRandom CSPRNG for token generation. - * @param IThrottler $throttler NC brute-force throttler. - * @param LoggerInterface $logger PSR-3 logger. - */ - public function __construct( - private readonly PublicShareMapper $shareMapper, - private readonly DashboardMapper $dashMapper, - private readonly IGroupManager $groupManager, - private readonly IHasher $hasher, - private readonly ISecureRandom $secureRandom, - private readonly IThrottler $throttler, - private readonly LoggerInterface $logger, - ) { - }//end __construct() - - /** - * Create a new public share for a dashboard. - * - * Only the dashboard owner or a NC admin may create shares (REQ-PSHR-001). - * - * @param string $dashboardUuid Dashboard UUID. - * @param string $callerId User ID of the creating user. - * @param string|null $password Optional plaintext password (BCrypt-hashed on store). - * @param string|null $expiresAt Optional ISO 8601 expiry timestamp. - * - * @return PublicShare The new share with URL populated. - * - * @throws DoesNotExistException When dashboard not found. - * @throws OCSForbiddenException Via 403 on auth failure. - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - * - * @SuppressWarnings(PHPMD.StaticAccess) - */ - public function createPublicShare( - string $dashboardUuid, - string $callerId, - ?string $password=null, - ?string $expiresAt=null - ): PublicShare { - $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); - $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); - - $share = new PublicShare(); - // phpcs:disable CustomSn.Functions.NamedParameters -- Entity magic __call breaks with named args. - $share->setDashboardUuid($dashboardUuid); - $share->setToken( - $this->secureRandom->generate( - length: 64, - characters: ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS - ) - ); - $share->setCreatedBy($callerId); - $share->setCreatedAt((new DateTime())->format('Y-m-d H:i:s')); - $share->setViewCount(0); - // phpcs:enable CustomSn.Functions.NamedParameters - - if ($password !== null && $password !== '') { - // phpcs:disable CustomSn.Functions.NamedParameters - $share->setPasswordHash($this->hasher->hash(message: $password)); - // phpcs:enable CustomSn.Functions.NamedParameters - } - - if ($expiresAt !== null && $expiresAt !== '') { - $parsed = DateTime::createFromFormat(DateTime::ATOM, $expiresAt); - if ($parsed === false) { - $parsed = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $expiresAt); - } - - if ($parsed === false) { - $parsed = DateTime::createFromFormat('Y-m-d H:i:s', $expiresAt); - } - - if ($parsed !== false) { - // phpcs:disable CustomSn.Functions.NamedParameters - $share->setExpiresAt($parsed->format('Y-m-d H:i:s')); - // phpcs:enable CustomSn.Functions.NamedParameters - } - } - - $saved = $this->shareMapper->insert(entity: $share); - - $this->logger->debug( - message: sprintf('launchpad: public share created for dashboard %s', $dashboardUuid), - context: ['app' => 'launchpad'] - ); - - return $saved; - }//end createPublicShare() - - /** - * List all active (non-revoked, non-expired) shares for a dashboard. - * - * @param string $dashboardUuid Dashboard UUID. - * @param string $callerId Caller user ID. - * - * @return PublicShare[] - * - * @throws DoesNotExistException When dashboard not found. - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - */ - public function listActiveShares(string $dashboardUuid, string $callerId): array - { - $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); - $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); - - return $this->shareMapper->findActiveByDashboardUuid( - dashboardUuid: $dashboardUuid - ); - }//end listActiveShares() - - /** - * Soft-revoke a public share by ID. - * - * Idempotent: revoking an already-revoked share returns normally. - * - * @param string $dashboardUuid Dashboard UUID. - * @param int $shareId Share primary key. - * @param string $callerId Caller user ID. - * - * @return void - * - * @throws DoesNotExistException When dashboard not found. - * @throws ShareNotFoundException When the share does not belong to this dashboard. - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - */ - public function revokeShare( - string $dashboardUuid, - int $shareId, - string $callerId - ): void { - $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); - $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); - - // Verify the share belongs to this dashboard. - $shares = $this->shareMapper->findByDashboardUuid( - dashboardUuid: $dashboardUuid - ); - - $found = false; - foreach ($shares as $s) { - if ($s->getId() === $shareId) { - $found = true; - break; - } - } - - if ($found === false) { - throw new ShareNotFoundException(); - } - - $this->shareMapper->softRevoke(id: $shareId); - }//end revokeShare() - - /** - * Render a share's dashboard content for anonymous access. - * - * Validates token, expiry, revocation, and optional password. - * Increments view count (debounced by IP within 60-second window). - * - * @param string $token The share token. - * @param string $ip Client IP address for debouncing and throttling. - * @param string|null $password Plaintext password supplied by the client. - * - * @return array{share: PublicShare, dashboard: Dashboard} - * - * @throws ShareNotFoundException When token invalid, revoked, or expired. - * @throws SharePasswordRequiredException When password is required but not supplied. - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - * - * @SuppressWarnings(PHPMD.ShortVariable) - */ - public function renderShareContent( - string $token, - string $ip, - ?string $password=null - ): array { - $share = $this->resolveActiveShare(token: $token, ip: $ip); - - // Password gate. - if ($share->getPasswordHash() !== null) { - if ($password === null || $password === '') { - throw new SharePasswordRequiredException(); - } - - if ($this->hasher->verify( - message: $password, - hash: (string) $share->getPasswordHash() - ) === false - ) { - $this->throttler->registerAttempt( - action: self::ACTION_SHARE_PASSWORD, - ip: $ip - ); - throw new SharePasswordRequiredException(); - } - } - - $dashboard = $this->dashMapper->findByUuid(uuid: (string) $share->getDashboardUuid()); - - $this->shareMapper->incrementViewCount( - id: (int) $share->getId(), - token: $token, - ip: $ip - ); - - return ['share' => $share, 'dashboard' => $dashboard]; - }//end renderShareContent() - - /** - * Verify a password for a password-protected share (unlock endpoint). - * - * Applies `launchpad_share_password` throttle before verification. - * - * @param string $token The share token. - * @param string $password The supplied plaintext password. - * @param string $ip Client IP address. - * - * @return bool True on success; false on wrong password. - * - * @throws ShareNotFoundException When token does not exist or is inactive. - * @throws MaxDelayReached When throttle limit is exceeded (429). - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - * - * @SuppressWarnings(PHPMD.ShortVariable) - */ - public function unlockShare( - string $token, - string $password, - string $ip - ): bool { - // Throttle check before any DB query to prevent enumeration timing attacks. - $this->throttler->sleepDelayOrThrowOnMax( - ip: $ip, - action: self::ACTION_SHARE_PASSWORD - ); - - $share = $this->resolveActiveShare(token: $token, ip: $ip); - - if ($share->getPasswordHash() === null) { - // No password on this share — unlock is trivially successful. - return true; - } - - $isValid = $this->hasher->verify( - message: $password, - hash: (string) $share->getPasswordHash() - ); - - if ($isValid === false) { - $this->throttler->registerAttempt( - action: self::ACTION_SHARE_PASSWORD, - ip: $ip - ); - } - - return $isValid; - }//end unlockShare() - - /** - * Resolve a share that is active (not revoked, not expired). - * - * Throws ShareNotFoundException for any failure to avoid information leakage. - * - * @param string $token The share token. - * @param string $ip Client IP (registered on access failure for D1 throttling). - * - * @return PublicShare - * - * @throws ShareNotFoundException - * - * @SuppressWarnings(PHPMD.ShortVariable) - * @SuppressWarnings(PHPMD.StaticAccess) - */ - private function resolveActiveShare(string $token, string $ip): PublicShare - { - try { - $share = $this->shareMapper->findByToken(token: $token); - } catch (DoesNotExistException) { - $this->throttler->registerAttempt( - action: self::ACTION_SHARE_ACCESS, - ip: $ip - ); - throw new ShareNotFoundException(); - } - - if ($share->getRevokedAt() !== null) { - $this->throttler->registerAttempt( - action: self::ACTION_SHARE_ACCESS, - ip: $ip - ); - throw new ShareNotFoundException(); - } - - if ($share->getExpiresAt() !== null) { - $expiry = DateTime::createFromFormat('Y-m-d H:i:s', (string) $share->getExpiresAt()); - if ($expiry === false) { - $expiry = new DateTime((string) $share->getExpiresAt()); - } - - if ($expiry < new DateTime()) { - $this->throttler->registerAttempt( - action: self::ACTION_SHARE_ACCESS, - ip: $ip - ); - throw new ShareNotFoundException(); - } - } - - return $share; - }//end resolveActiveShare() - - /** - * Assert that the calling user may create/revoke shares for a dashboard. - * - * Passes for the dashboard owner or any NC admin. - * - * @param Dashboard $dashboard The dashboard to check. - * @param string $userId The calling user ID. - * - * @return void - * - * @throws \OCP\AppFramework\OCS\OCSForbiddenException Via 403. - * - * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 - */ - public function authorizeShareMutation(Dashboard $dashboard, string $userId): void - { - $isOwner = ($dashboard->getUserId() === $userId); - $isAdmin = $this->groupManager->isAdmin(userId: $userId); - - if ($isOwner === false && $isAdmin === false) { - throw new OCSForbiddenException('Not authorized'); - } - }//end authorizeShareMutation() +class PublicShareService { + + /** + * Brute-force action name for general share-page access failures. + * + * @var string + */ + public const ACTION_SHARE_ACCESS = 'launchpad_share_access'; + + /** + * Brute-force action name for wrong-password unlock attempts. + * + * @var string + */ + public const ACTION_SHARE_PASSWORD = 'launchpad_share_password'; + + /** + * Constructor. + * + * @param PublicShareMapper $shareMapper Mapper for public shares. + * @param DashboardMapper $dashMapper Dashboard mapper for ownership checks. + * @param IGroupManager $groupManager NC group manager for admin checks. + * @param IHasher $hasher NC BCrypt hasher. + * @param ISecureRandom $secureRandom CSPRNG for token generation. + * @param IThrottler $throttler NC brute-force throttler. + * @param LoggerInterface $logger PSR-3 logger. + * @param WidgetPlacementMapper $placementMapper Widget placement mapper (public render). + */ + public function __construct( + private readonly PublicShareMapper $shareMapper, + private readonly DashboardMapper $dashMapper, + private readonly IGroupManager $groupManager, + private readonly IHasher $hasher, + private readonly ISecureRandom $secureRandom, + private readonly IThrottler $throttler, + private readonly LoggerInterface $logger, + private readonly WidgetPlacementMapper $placementMapper, + ) { + }//end __construct() + + /** + * Create a new public share for a dashboard. + * + * Only the dashboard owner or a NC admin may create shares (REQ-PSHR-001). + * + * @param string $dashboardUuid Dashboard UUID. + * @param string $callerId User ID of the creating user. + * @param string|null $password Optional plaintext password (BCrypt-hashed on store). + * @param string|null $expiresAt Optional ISO 8601 expiry timestamp. + * + * @return PublicShare The new share with URL populated. + * + * @throws DoesNotExistException When dashboard not found. + * @throws OCSForbiddenException Via 403 on auth failure. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + * + * @SuppressWarnings(PHPMD.StaticAccess) + * `DateTime::createFromFormat()` is a PHP built-in named constructor, + * called three times here to try the ATOM, `Y-m-d\TH:i:s\Z` and + * `Y-m-d H:i:s` expiry formats in turn. There is no instance-method + * equivalent and no collaborator to inject in its place. + */ + public function createPublicShare( + string $dashboardUuid, + string $callerId, + ?string $password = null, + ?string $expiresAt = null, + ): PublicShare { + $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); + $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); + + $share = new PublicShare(); + // phpcs:disable CustomSn.Functions.NamedParameters -- Entity magic __call breaks with named args. + $share->setDashboardUuid($dashboardUuid); + $share->setToken( + $this->secureRandom->generate( + length: 64, + characters: ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS + ) + ); + $share->setCreatedBy($callerId); + $share->setCreatedAt((new DateTime())->format('Y-m-d H:i:s')); + $share->setViewCount(0); + // phpcs:enable CustomSn.Functions.NamedParameters + + if ($password !== null && $password !== '') { + // phpcs:disable CustomSn.Functions.NamedParameters + $share->setPasswordHash($this->hasher->hash(message: $password)); + // phpcs:enable CustomSn.Functions.NamedParameters + } + + if ($expiresAt !== null && $expiresAt !== '') { + $parsed = DateTime::createFromFormat(DateTime::ATOM, $expiresAt); + if ($parsed === false) { + $parsed = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $expiresAt); + } + + if ($parsed === false) { + $parsed = DateTime::createFromFormat('Y-m-d H:i:s', $expiresAt); + } + + if ($parsed !== false) { + // phpcs:disable CustomSn.Functions.NamedParameters + $share->setExpiresAt($parsed->format('Y-m-d H:i:s')); + // phpcs:enable CustomSn.Functions.NamedParameters + } + } + + $saved = $this->shareMapper->insert(entity: $share); + + $this->logger->debug( + message: sprintf('launchpad: public share created for dashboard %s', $dashboardUuid), + context: ['app' => 'launchpad'] + ); + + return $saved; + }//end createPublicShare() + + /** + * List all active (non-revoked, non-expired) shares for a dashboard. + * + * @param string $dashboardUuid Dashboard UUID. + * @param string $callerId Caller user ID. + * + * @return PublicShare[] + * + * @throws DoesNotExistException When dashboard not found. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + */ + public function listActiveShares(string $dashboardUuid, string $callerId): array { + $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); + $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); + + return $this->shareMapper->findActiveByDashboardUuid( + dashboardUuid: $dashboardUuid + ); + }//end listActiveShares() + + /** + * Soft-revoke a public share by ID. + * + * Idempotent: revoking an already-revoked share returns normally. + * + * @param string $dashboardUuid Dashboard UUID. + * @param int $shareId Share primary key. + * @param string $callerId Caller user ID. + * + * @return void + * + * @throws DoesNotExistException When dashboard not found. + * @throws ShareNotFoundException When the share does not belong to this dashboard. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + */ + public function revokeShare( + string $dashboardUuid, + int $shareId, + string $callerId, + ): void { + $dashboard = $this->dashMapper->findByUuid(uuid: $dashboardUuid); + $this->authorizeShareMutation(dashboard: $dashboard, userId: $callerId); + + // Verify the share belongs to this dashboard. + $shares = $this->shareMapper->findByDashboardUuid( + dashboardUuid: $dashboardUuid + ); + + $found = false; + foreach ($shares as $s) { + if ($s->getId() === $shareId) { + $found = true; + break; + } + } + + if ($found === false) { + throw new ShareNotFoundException(); + } + + $this->shareMapper->softRevoke(id: $shareId); + }//end revokeShare() + + /** + * Render a share's dashboard content for anonymous access. + * + * Validates token, expiry, revocation, and optional password. + * Increments view count (debounced by IP within 60-second window). + * + * @param string $token The share token. + * @param string $ipAddress Client IP address for debouncing and throttling. + * @param string|null $password Plaintext password supplied by the client. + * + * @return array{share: PublicShare, dashboard: Dashboard, placements: WidgetPlacement[]} + * + * @throws ShareNotFoundException When token invalid, revoked, or expired. + * @throws SharePasswordRequiredException When password is required but not supplied. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + */ + public function renderShareContent( + string $token, + string $ipAddress, + ?string $password = null, + ): array { + $share = $this->resolveActiveShare(token: $token, ipAddress: $ipAddress); + + // Password gate. + if ($share->getPasswordHash() !== null) { + if ($password === null || $password === '') { + throw new SharePasswordRequiredException(); + } + + if ($this->hasher->verify( + message: $password, + hash: (string)$share->getPasswordHash() + ) === false + ) { + $this->throttler->registerAttempt( + action: self::ACTION_SHARE_PASSWORD, + ip: $ipAddress + ); + throw new SharePasswordRequiredException(); + } + } + + $dashboard = $this->dashMapper->findByUuid(uuid: (string)$share->getDashboardUuid()); + + $this->shareMapper->incrementViewCount( + id: (int)$share->getId(), + token: $token, + ipAddress: $ipAddress + ); + + $placements = $this->placementMapper->findByDashboardId( + dashboardId: (int)$dashboard->getId() + ); + + return [ + 'share' => $share, + 'dashboard' => $dashboard, + 'placements' => $placements, + ]; + }//end renderShareContent() + + /** + * Verify a password for a password-protected share (unlock endpoint). + * + * Applies `launchpad_share_password` throttle before verification. + * + * @param string $token The share token. + * @param string $password The supplied plaintext password. + * @param string $ipAddress Client IP address. + * + * @return bool True on success; false on wrong password. + * + * @throws ShareNotFoundException When token does not exist or is inactive. + * @throws MaxDelayReached When throttle limit is exceeded (429). + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + */ + public function unlockShare( + string $token, + string $password, + string $ipAddress, + ): bool { + // Throttle check before any DB query to prevent enumeration timing attacks. + $this->throttler->sleepDelayOrThrowOnMax( + ip: $ipAddress, + action: self::ACTION_SHARE_PASSWORD + ); + + $share = $this->resolveActiveShare(token: $token, ipAddress: $ipAddress); + + if ($share->getPasswordHash() === null) { + // No password on this share — unlock is trivially successful. + return true; + } + + $isValid = $this->hasher->verify( + message: $password, + hash: (string)$share->getPasswordHash() + ); + + if ($isValid === false) { + $this->throttler->registerAttempt( + action: self::ACTION_SHARE_PASSWORD, + ip: $ipAddress + ); + } + + return $isValid; + }//end unlockShare() + + /** + * Resolve a share that is active (not revoked, not expired). + * + * Throws ShareNotFoundException for any failure to avoid information leakage. + * + * @param string $token The share token. + * @param string $ipAddress Client IP (registered on access failure for D1 throttling). + * + * @return PublicShare + * + * @throws ShareNotFoundException + * + * @SuppressWarnings(PHPMD.StaticAccess) + * `DateTime::createFromFormat()` is a PHP built-in named constructor; + * there is no instance-method equivalent to call and no collaborator + * to inject in its place. + */ + private function resolveActiveShare(string $token, string $ipAddress): PublicShare { + try { + $share = $this->shareMapper->findByToken(token: $token); + } catch (DoesNotExistException) { + $this->throttler->registerAttempt( + action: self::ACTION_SHARE_ACCESS, + ip: $ipAddress + ); + throw new ShareNotFoundException(); + } + + if ($share->getRevokedAt() !== null) { + $this->throttler->registerAttempt( + action: self::ACTION_SHARE_ACCESS, + ip: $ipAddress + ); + throw new ShareNotFoundException(); + } + + if ($share->getExpiresAt() !== null) { + $expiry = DateTime::createFromFormat('Y-m-d H:i:s', (string)$share->getExpiresAt()); + if ($expiry === false) { + $expiry = new DateTime((string)$share->getExpiresAt()); + } + + if ($expiry < new DateTime()) { + $this->throttler->registerAttempt( + action: self::ACTION_SHARE_ACCESS, + ip: $ipAddress + ); + throw new ShareNotFoundException(); + } + } + + return $share; + }//end resolveActiveShare() + + /** + * Assert that the calling user may create/revoke shares for a dashboard. + * + * Passes for the dashboard owner or any NC admin. + * + * @param Dashboard $dashboard The dashboard to check. + * @param string $userId The calling user ID. + * + * @return void + * + * @throws \OCP\AppFramework\OCS\OCSForbiddenException Via 403. + * + * @spec openspec/changes/dashboard-public-share/tasks.md#task-5 + */ + public function authorizeShareMutation(Dashboard $dashboard, string $userId): void { + $isOwner = ($dashboard->getUserId() === $userId); + $isAdmin = $this->groupManager->isAdmin(userId: $userId); + + if ($isOwner === false && $isAdmin === false) { + throw new OCSForbiddenException('Not authorized'); + } + }//end authorizeShareMutation() }//end class diff --git a/lib/Service/QuotaService.php b/lib/Service/QuotaService.php index 684779e38..44ef587a8 100644 --- a/lib/Service/QuotaService.php +++ b/lib/Service/QuotaService.php @@ -43,277 +43,268 @@ * * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md */ -class QuotaService -{ +class QuotaService { - /** - * Provisioning-context depth. Greater than zero means the current call - * stack is inside an admin provisioning path and quota asserts are - * bypassed (REQ-QUOTA-004). A counter (not a bool) so nested - * provisioning calls compose correctly. - * - * @var integer - */ - private int $provisioningDepth = 0; + /** + * Provisioning-context depth. Greater than zero means the current call + * stack is inside an admin provisioning path and quota asserts are + * bypassed (REQ-QUOTA-004). A counter (not a bool) so nested + * provisioning calls compose correctly. + * + * @var integer + */ + private int $provisioningDepth = 0; - /** - * Constructor. - * - * @param AdminSettingMapper $settingMapper Admin setting mapper — - * source of the two quota - * values and the - * `allow_multiple_dashboards` - * flag. - * @param DashboardMapper $dashboardMapper Dashboard mapper — live - * personal-dashboard count. - * @param WidgetPlacementMapper $placementMapper Placement mapper — live - * per-dashboard placement - * count. - */ - public function __construct( - private readonly AdminSettingMapper $settingMapper, - private readonly DashboardMapper $dashboardMapper, - private readonly WidgetPlacementMapper $placementMapper, - ) { - }//end __construct() + /** + * Constructor. + * + * @param AdminSettingMapper $settingMapper Admin setting mapper — + * source of the two quota + * values and the + * `allow_multiple_dashboards` + * flag. + * @param DashboardMapper $dashboardMapper Dashboard mapper — live + * personal-dashboard count. + * @param WidgetPlacementMapper $placementMapper Placement mapper — live + * per-dashboard placement + * count. + */ + public function __construct( + private readonly AdminSettingMapper $settingMapper, + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + ) { + }//end __construct() - /** - * Run an admin-provisioning callable with user quotas bypassed - * (REQ-QUOTA-004). - * - * The bypass is tied to this call path — template rollout, compulsory - * widget pushes, admin-on-behalf provisioning — NOT to the acting - * user's admin group membership. An admin creating their own personal - * dashboard through the normal user flow does NOT go through here and - * therefore stays subject to the quota. - * - * The depth counter is always decremented (even on throw) so a failing - * provisioning call can never leave the service permanently bypassed. - * - * @param callable():T $work The provisioning work to run unguarded. - * - * @template T - * - * @return T The callable's return value. - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-004-admin-provisioning-exemption - */ - public function runProvisioning(callable $work): mixed - { - $this->provisioningDepth++; - try { - return $work(); - } finally { - $this->provisioningDepth--; - if ($this->provisioningDepth < 0) { - $this->provisioningDepth = 0; - } - } - }//end runProvisioning() + /** + * Run an admin-provisioning callable with user quotas bypassed + * (REQ-QUOTA-004). + * + * The bypass is tied to this call path — template rollout, compulsory + * widget pushes, admin-on-behalf provisioning — NOT to the acting + * user's admin group membership. An admin creating their own personal + * dashboard through the normal user flow does NOT go through here and + * therefore stays subject to the quota. + * + * The depth counter is always decremented (even on throw) so a failing + * provisioning call can never leave the service permanently bypassed. + * + * @param callable():T $work The provisioning work to run unguarded. + * + * @template T + * + * @return T The callable's return value. + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-004-admin-provisioning-exemption + */ + public function runProvisioning(callable $work): mixed { + $this->provisioningDepth++; + try { + return $work(); + } finally { + $this->provisioningDepth--; + if ($this->provisioningDepth < 0) { + $this->provisioningDepth = 0; + } + } + }//end runProvisioning() - /** - * Whether the current call stack is inside a provisioning bypass. - * - * @return bool True when quotas are currently bypassed. - */ - public function isProvisioning(): bool - { - return $this->provisioningDepth > 0; - }//end isProvisioning() + /** + * Whether the current call stack is inside a provisioning bypass. + * + * @return bool True when quotas are currently bypassed. + */ + public function isProvisioning(): bool { + return $this->provisioningDepth > 0; + }//end isProvisioning() - /** - * Assert that the user may create one more personal dashboard - * (REQ-QUOTA-002). - * - * Live `COUNT(*)` on the user's personal-scope dashboards (group- and - * admin-scope dashboards never count). Most-restrictive-wins with the - * `allow_multiple_dashboards` flag (REQ-QUOTA-002 / design D6): when - * multiple dashboards are disallowed the effective limit is `1`, - * regardless of the numeric setting. A numeric quota never loosens the - * boolean restriction. No enforcement when the effective limit is `0` - * (unlimited). - * - * Bypassed entirely inside {@see self::runProvisioning()}. - * - * @param string $userId The acting user ID. - * - * @return void - * - * @throws QuotaExceededException When the user is at or over the - * effective dashboard limit. - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-002-dashboard-count-enforcement - */ - public function assertCanCreateDashboard(string $userId): void - { - if ($this->isProvisioning() === true) { - return; - } + /** + * Assert that the user may create one more personal dashboard + * (REQ-QUOTA-002). + * + * Live `COUNT(*)` on the user's personal-scope dashboards (group- and + * admin-scope dashboards never count). Most-restrictive-wins with the + * `allow_multiple_dashboards` flag (REQ-QUOTA-002 / design D6): when + * multiple dashboards are disallowed the effective limit is `1`, + * regardless of the numeric setting. A numeric quota never loosens the + * boolean restriction. No enforcement when the effective limit is `0` + * (unlimited). + * + * Bypassed entirely inside {@see self::runProvisioning()}. + * + * @param string $userId The acting user ID. + * + * @return void + * + * @throws QuotaExceededException When the user is at or over the + * effective dashboard limit. + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-002-dashboard-count-enforcement + */ + public function assertCanCreateDashboard(string $userId): void { + if ($this->isProvisioning() === true) { + return; + } - $limit = $this->effectiveDashboardLimit(); - if ($limit === 0) { - return; - } + $limit = $this->effectiveDashboardLimit(); + if ($limit === 0) { + return; + } - $current = $this->dashboardMapper->countPersonalByUserId( - userId: $userId - ); - if ($current >= $limit) { - throw new QuotaExceededException( - quota: QuotaExceededException::QUOTA_DASHBOARDS, - limit: $limit, - current: $current - ); - } - }//end assertCanCreateDashboard() + $current = $this->dashboardMapper->countPersonalByUserId( + userId: $userId + ); + if ($current >= $limit) { + throw new QuotaExceededException( + quota: QuotaExceededException::QUOTA_DASHBOARDS, + limit: $limit, + current: $current + ); + } + }//end assertCanCreateDashboard() - /** - * Assert that one more placement may be added to a dashboard - * (REQ-QUOTA-003). - * - * Live `COUNT(*)` on the dashboard's placements. No enforcement when - * `max_widgets_per_dashboard` is `0` (unlimited). Bypassed entirely - * inside {@see self::runProvisioning()} so a compulsory-widget push may - * legitimately push a dashboard over the limit (REQ-QUOTA-004); the - * resulting over-quota state still blocks the user's own next - * placement (REQ-QUOTA-005). - * - * @param int $dashboardId The target dashboard ID (the placement- - * creation choke point in - * {@see PlacementService} works in numeric IDs). - * - * @return void - * - * @throws QuotaExceededException When the dashboard is at or over the - * widget limit. - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement - */ - public function assertCanAddPlacement(int $dashboardId): void - { - if ($this->provisioningDepth > 0) { - return; - } + /** + * Assert that one more placement may be added to a dashboard + * (REQ-QUOTA-003). + * + * Live `COUNT(*)` on the dashboard's placements. No enforcement when + * `max_widgets_per_dashboard` is `0` (unlimited). Bypassed entirely + * inside {@see self::runProvisioning()} so a compulsory-widget push may + * legitimately push a dashboard over the limit (REQ-QUOTA-004); the + * resulting over-quota state still blocks the user's own next + * placement (REQ-QUOTA-005). + * + * @param int $dashboardId The target dashboard ID (the placement- + * creation choke point in + * {@see PlacementService} works in numeric IDs). + * + * @return void + * + * @throws QuotaExceededException When the dashboard is at or over the + * widget limit. + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-003-widget-count-enforcement + */ + public function assertCanAddPlacement(int $dashboardId): void { + if ($this->provisioningDepth > 0) { + return; + } - $limit = $this->maxWidgetsPerDashboard(); - if ($limit === 0) { - return; - } + $limit = $this->maxWidgetsPerDashboard(); + if ($limit === 0) { + return; + } - $current = $this->placementMapper->countByDashboardId( - dashboardId: $dashboardId - ); - if ($current >= $limit) { - throw new QuotaExceededException( - quota: QuotaExceededException::QUOTA_WIDGETS, - limit: $limit, - current: $current - ); - } - }//end assertCanAddPlacement() + $current = $this->placementMapper->countByDashboardId( + dashboardId: $dashboardId + ); + if ($current >= $limit) { + throw new QuotaExceededException( + quota: QuotaExceededException::QUOTA_WIDGETS, + limit: $limit, + current: $current + ); + } + }//end assertCanAddPlacement() - /** - * Build the additive quota-status envelope for the dashboards list - * response (REQ-QUOTA-006). - * - * `maxDashboards` reflects the EFFECTIVE dashboard limit (so - * `allow_multiple_dashboards = false` surfaces as `1`), `dashboardsUsed` - * is the user's live personal-dashboard count, and - * `maxWidgetsPerDashboard` is the configured per-dashboard widget limit. - * `0` means unlimited for both `max*` fields. - * - * @param string $userId The acting user ID. - * - * @return array{maxDashboards: int, dashboardsUsed: int, maxWidgetsPerDashboard: int} - * The quota-status envelope. - * - * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-006-quota-status-surfacing-in-ui - */ - public function getQuotaStatus(string $userId): array - { - return [ - 'maxDashboards' => $this->effectiveDashboardLimit(), - 'dashboardsUsed' => $this->dashboardMapper->countPersonalByUserId( - userId: $userId - ), - 'maxWidgetsPerDashboard' => $this->maxWidgetsPerDashboard(), - ]; - }//end getQuotaStatus() + /** + * Build the additive quota-status envelope for the dashboards list + * response (REQ-QUOTA-006). + * + * `maxDashboards` reflects the EFFECTIVE dashboard limit (so + * `allow_multiple_dashboards = false` surfaces as `1`), `dashboardsUsed` + * is the user's live personal-dashboard count, and + * `maxWidgetsPerDashboard` is the configured per-dashboard widget limit. + * `0` means unlimited for both `max*` fields. + * + * @param string $userId The acting user ID. + * + * @return array{maxDashboards: int, dashboardsUsed: int, maxWidgetsPerDashboard: int} + * The quota-status envelope. + * + * @spec openspec/changes/dashboard-quota-limits/specs/dashboard-quota-limits/spec.md#req-quota-006-quota-status-surfacing-in-ui + */ + public function getQuotaStatus(string $userId): array { + return [ + 'maxDashboards' => $this->effectiveDashboardLimit(), + 'dashboardsUsed' => $this->dashboardMapper->countPersonalByUserId( + userId: $userId + ), + 'maxWidgetsPerDashboard' => $this->maxWidgetsPerDashboard(), + ]; + }//end getQuotaStatus() - /** - * Resolve the effective per-user dashboard limit, applying - * most-restrictive-wins against `allow_multiple_dashboards` - * (REQ-QUOTA-002 / design D6). - * - * Rules: - * - `allow_multiple_dashboards = false` ⇒ effective limit `1`, - * regardless of the numeric setting (the numeric quota MUST NOT - * loosen the boolean restriction). - * - otherwise the configured numeric value (`0` = unlimited). - * - * `allow_user_dashboards` is enforced separately upstream - * ({@see DashboardService::assertPersonalDashboardsAllowed()}); the - * quota never grants what that boolean denies. - * - * @return int The effective dashboard limit (`0` = unlimited). - */ - private function effectiveDashboardLimit(): int - { - $numeric = $this->readQuota( - key: AdminSetting::KEY_MAX_DASHBOARDS_PER_USER - ); + /** + * Resolve the effective per-user dashboard limit, applying + * most-restrictive-wins against `allow_multiple_dashboards` + * (REQ-QUOTA-002 / design D6). + * + * Rules: + * - `allow_multiple_dashboards = false` ⇒ effective limit `1`, + * regardless of the numeric setting (the numeric quota MUST NOT + * loosen the boolean restriction). + * - otherwise the configured numeric value (`0` = unlimited). + * + * `allow_user_dashboards` is enforced separately upstream + * ({@see DashboardService::assertPersonalDashboardsAllowed()}); the + * quota never grants what that boolean denies. + * + * @return int The effective dashboard limit (`0` = unlimited). + */ + private function effectiveDashboardLimit(): int { + $numeric = $this->readQuota( + key: AdminSetting::KEY_MAX_DASHBOARDS_PER_USER + ); - $allowMultiple = (bool) $this->settingMapper->getValue( - key: AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS, - default: true - ); + $allowMultiple = (bool)$this->settingMapper->getValue( + key: AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS, + default: true + ); - if ($allowMultiple === false) { - return 1; - } + if ($allowMultiple === false) { + return 1; + } - return $numeric; - }//end effectiveDashboardLimit() + return $numeric; + }//end effectiveDashboardLimit() - /** - * Read the configured per-dashboard widget limit (`0` = unlimited). - * - * @return int The widget limit. - */ - private function maxWidgetsPerDashboard(): int - { - return $this->readQuota( - key: AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD - ); - }//end maxWidgetsPerDashboard() + /** + * Read the configured per-dashboard widget limit (`0` = unlimited). + * + * @return int The widget limit. + */ + private function maxWidgetsPerDashboard(): int { + return $this->readQuota( + key: AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD + ); + }//end maxWidgetsPerDashboard() - /** - * Read a stored numeric quota, defensively coercing to a non-negative - * integer. A missing row, a non-numeric value, or any read failure - * resolves to `0` (unlimited) so a corrupt setting can never block - * legitimate creations. - * - * @param string $key The admin-setting key. - * - * @return int The stored quota, or `0` on absence / corruption. - */ - private function readQuota(string $key): int - { - try { - $raw = $this->settingMapper->getValue(key: $key, default: 0); - } catch (Throwable) { - return 0; - } + /** + * Read a stored numeric quota, defensively coercing to a non-negative + * integer. A missing row, a non-numeric value, or any read failure + * resolves to `0` (unlimited) so a corrupt setting can never block + * legitimate creations. + * + * @param string $key The admin-setting key. + * + * @return int The stored quota, or `0` on absence / corruption. + */ + private function readQuota(string $key): int { + try { + $raw = $this->settingMapper->getValue(key: $key, default: 0); + } catch (Throwable) { + return 0; + } - if (is_int($raw) === false && is_numeric($raw) === false) { - return 0; - } + if (is_int($raw) === false && is_numeric($raw) === false) { + return 0; + } - $value = (int) $raw; - if ($value < 0) { - return 0; - } + $value = (int)$raw; + if ($value < 0) { + return 0; + } - return $value; - }//end readQuota() + return $value; + }//end readQuota() }//end class diff --git a/lib/Service/ReactionService.php b/lib/Service/ReactionService.php index 5df6880f7..bc32a0eed 100644 --- a/lib/Service/ReactionService.php +++ b/lib/Service/ReactionService.php @@ -16,8 +16,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); @@ -28,7 +28,6 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\Dashboard; use OCA\LaunchPad\Db\DashboardMapper; -use OCA\LaunchPad\Db\DashboardReaction; use OCA\LaunchPad\Db\DashboardReactionMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\DB\Exception as DbException; @@ -38,449 +37,442 @@ /** * Service for managing dashboard emoji reactions. */ -class ReactionService -{ - /** - * Admin setting key — global on/off toggle. Default: true. - * - * @var string - */ - public const KEY_ENABLED_DEFAULT = 'reactions_enabled_default'; - - /** - * Admin setting key — JSON array of allowed emoji. - * - * @var string - */ - public const KEY_ALLOWED_EMOJIS = 'reactions_allowed_emojis'; - - /** - * Default reactor-pagination cap (REQ-RXN-004 — 100-item ceiling). - * - * @var integer - */ - public const REACTORS_PAGE_SIZE = 100; - - /** - * Factory default whitelist applied when the admin has not stored - * a custom value. Matches the proposal default exactly. - * - * @var array - */ - public const DEFAULT_ALLOWED_EMOJIS = [ - '👍', - '❤️', - '🎉', - '😂', - '🤔', - '😢', - ]; - - /** - * Constructor - * - * @param DashboardReactionMapper $reactionMapper The reaction mapper. - * @param DashboardMapper $dashboardMapper Dashboard lookups - * (toggle resolution). - * @param PermissionService $permissionService VIEW permission gate. - * @param IAppConfig $appConfig App config — admin - * settings. - * @param IUserManager $userManager Display name - * resolution for the - * reactors-by-emoji - * endpoint. - */ - public function __construct( - private readonly DashboardReactionMapper $reactionMapper, - private readonly DashboardMapper $dashboardMapper, - private readonly PermissionService $permissionService, - private readonly IAppConfig $appConfig, - private readonly IUserManager $userManager, - ) { - }//end __construct() - - /** - * Resolve the effective reactions-enabled state for a dashboard. - * - * Resolution rules (REQ-RXN-005, REQ-RXN-006): - * - dashboard.reactionsEnabled === 1 → true - * - dashboard.reactionsEnabled === 0 → false - * - dashboard.reactionsEnabled === null → follow global setting - * - * @param Dashboard $dashboard The dashboard. - * - * @return bool True when reactions are effectively enabled. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function isReactionsEnabled(Dashboard $dashboard): bool - { - $perDash = $dashboard->getReactionsEnabled(); - if ($perDash === 1) { - return true; - } - - if ($perDash === 0) { - return false; - } - - return $this->isReactionsEnabledByDefault(); - }//end isReactionsEnabled() - - /** - * Read the admin global on/off toggle. Default: true. - * - * @return bool True when the global default is on. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function isReactionsEnabledByDefault(): bool - { - return $this->appConfig->getValueBool( - app: Application::APP_ID, - key: self::KEY_ENABLED_DEFAULT, - default: true - ); - }//end isReactionsEnabledByDefault() - - /** - * Read the admin emoji whitelist. Falls back to the factory default - * when the setting is missing or corrupt. - * - * @return array The allowed emoji list. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function getAllowedEmojis(): array - { - $raw = $this->appConfig->getValueString( - app: Application::APP_ID, - key: self::KEY_ALLOWED_EMOJIS, - default: '' - ); - - if ($raw === '') { - return self::DEFAULT_ALLOWED_EMOJIS; - } - - $decoded = json_decode(json: $raw, associative: true); - if (is_array($decoded) === false) { - return self::DEFAULT_ALLOWED_EMOJIS; - } - - $cleaned = []; - foreach ($decoded as $entry) { - if (is_string($entry) === true && $entry !== '') { - $cleaned[] = $entry; - } - } - - // An admin-set empty list is intentional (per REQ-RXN-007 - // scenario "Empty emoji in whitelist") — surface the empty - // list as-is rather than falling back to the default. - return $cleaned; - }//end getAllowedEmojis() - - /** - * Throw when the supplied emoji is not in the allowed list. - * - * @param string $emoji The emoji to validate. - * - * @return void - * - * @throws InvalidArgumentException When the emoji is empty or not - * whitelisted. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function validateEmoji(string $emoji): void - { - if ($emoji === '') { - throw new InvalidArgumentException(message: 'Emoji not allowed'); - } - - $allowed = $this->getAllowedEmojis(); - if (in_array(needle: $emoji, haystack: $allowed, strict: true) === false) { - throw new InvalidArgumentException(message: 'Emoji not allowed'); - } - }//end validateEmoji() - - /** - * Look up a dashboard by UUID and enforce the calling user's VIEW - * permission. Used as the gate for every reaction endpoint. - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $userId The acting user ID. - * - * @return Dashboard The resolved dashboard. - * - * @throws DoesNotExistException When the dashboard does not exist. - * @throws PermissionDeniedException When the user cannot VIEW it. - */ - private function loadAndAuthorise( - string $dashboardUuid, - string $userId - ): Dashboard { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $dashboardUuid); - if ($this->permissionService->canViewDashboard( - userId: $userId, - dashboardId: $dashboard->getId() - ) === false - ) { - throw new PermissionDeniedException( - message: 'Permission denied' - ); - } - - return $dashboard; - }//end loadAndAuthorise() - - /** - * Add a reaction. Idempotent — re-posting the same emoji is a no-op - * that returns the existing summary (REQ-RXN-001 scenario "User - * re-posts the same emoji"). - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $userId The acting user ID. - * @param string $emoji The emoji to add. - * - * @return array The updated reactions summary. - * - * @throws DoesNotExistException When the dashboard is missing. - * @throws PermissionDeniedException When the user cannot VIEW. - * @throws ReactionsDisabledException When reactions are off. - * @throws InvalidArgumentException When the emoji is not whitelisted. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function addReaction( - string $dashboardUuid, - string $userId, - string $emoji - ): array { - $dashboard = $this->loadAndAuthorise( - dashboardUuid: $dashboardUuid, - userId: $userId - ); - - if ($this->isReactionsEnabled(dashboard: $dashboard) === false) { - throw new ReactionsDisabledException( - message: 'Reactions are disabled' - ); - } - - $this->validateEmoji(emoji: $emoji); - - try { - $this->reactionMapper->addReaction( - dashboardUuid: $dashboardUuid, - userId: $userId, - emoji: $emoji - ); - } catch (DbException $exception) { - // Unique-constraint hit (REASON_UNIQUE_CONSTRAINT_VIOLATION = 4) - // — swallow for idempotent semantics; any other DB failure - // bubbles up so the controller can report 500. - if ($exception->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { - throw $exception; - } - } - - return $this->buildSummary(dashboard: $dashboard, userId: $userId); - }//end addReaction() - - /** - * Remove a reaction. Idempotent — if no row matches, silently - * succeeds (REQ-RXN-002 scenario "User attempts to remove a - * reaction they did not make"). - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $userId The acting user ID. - * @param string $emoji The emoji to remove. - * - * @return bool True when a row was deleted, false when none matched. - * - * @throws DoesNotExistException When the dashboard is missing. - * @throws PermissionDeniedException When the user cannot VIEW. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function removeReaction( - string $dashboardUuid, - string $userId, - string $emoji - ): bool { - $this->loadAndAuthorise( - dashboardUuid: $dashboardUuid, - userId: $userId - ); - - return $this->reactionMapper->removeReaction( - dashboardUuid: $dashboardUuid, - userId: $userId, - emoji: $emoji - ); - }//end removeReaction() - - /** - * Build the `{counts, mine, enabled}` summary for a dashboard. - * - * When reactions are disabled (globally or per-dashboard) returns - * `{counts: {}, mine: [], enabled: false}` regardless of stored - * rows — REQ-RXN-003 scenario "Reactions disabled on dashboard". - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $userId The acting user ID. - * - * @return array The summary. - * - * @throws DoesNotExistException When the dashboard is missing. - * @throws PermissionDeniedException When the user cannot VIEW. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function getReactionsSummary( - string $dashboardUuid, - string $userId - ): array { - $dashboard = $this->loadAndAuthorise( - dashboardUuid: $dashboardUuid, - userId: $userId - ); - - return $this->buildSummary(dashboard: $dashboard, userId: $userId); - }//end getReactionsSummary() - - /** - * Build the summary from a pre-resolved Dashboard entity. Internal - * helper — every public path resolves through `loadAndAuthorise` - * first. - * - * @param Dashboard $dashboard The dashboard. - * @param string $userId The acting user ID. - * - * @return array The summary. - */ - private function buildSummary(Dashboard $dashboard, string $userId): array - { - $enabled = $this->isReactionsEnabled(dashboard: $dashboard); - if ($enabled === false) { - return [ - 'counts' => (object) [], - 'mine' => [], - 'enabled' => false, - ]; - } - - $uuid = (string) $dashboard->getUuid(); - $counts = $this->reactionMapper->countByEmoji(dashboardUuid: $uuid); - $mine = []; - foreach ($this->reactionMapper->findByUser( - userId: $userId, - dashboardUuid: $uuid - ) as $reaction - ) { - $emoji = $reaction->getEmoji(); - if ($emoji !== null) { - $mine[] = $emoji; - } - } - - return [ - 'counts' => (object) $counts, - 'mine' => $mine, - 'enabled' => true, - ]; - }//end buildSummary() - - /** - * List reactors for a single emoji on a dashboard, capped at - * {@see self::REACTORS_PAGE_SIZE} per response with simple - * offset cursor pagination (REQ-RXN-004). - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $emoji The emoji. - * @param string $userId The acting user ID. - * @param string|null $cursor Optional opaque offset cursor. - * - * @return array Page payload with `items`, `nextCursor`, `total`. The - * `items` entries are `{userId, displayName, reactedAt}` - * associative arrays. - * - * @throws DoesNotExistException When the dashboard is missing. - * @throws PermissionDeniedException When the user cannot VIEW. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function getReactorsByEmoji( - string $dashboardUuid, - string $emoji, - string $userId, - ?string $cursor=null - ): array { - $this->loadAndAuthorise( - dashboardUuid: $dashboardUuid, - userId: $userId - ); - - $offset = 0; - if ($cursor !== null && $cursor !== '') { - $candidate = (int) $cursor; - if ($candidate > 0) { - $offset = $candidate; - } - } - - $rows = $this->reactionMapper->findByEmoji( - dashboardUuid: $dashboardUuid, - emoji: $emoji, - limit: self::REACTORS_PAGE_SIZE, - offset: $offset - ); - $total = $this->reactionMapper->countReactorsByEmoji( - dashboardUuid: $dashboardUuid, - emoji: $emoji - ); - - $items = []; - foreach ($rows as $reaction) { - $reactorId = (string) $reaction->getUserId(); - $user = $this->userManager->get(uid: $reactorId); - $items[] = [ - 'userId' => $reactorId, - 'displayName' => $user?->getDisplayName() ?? $reactorId, - 'reactedAt' => $reaction->getReactedAtFormatted(), - ]; - } - - $nextOffset = ($offset + count($items)); - $nextCursor = null; - if ($nextOffset < $total) { - $nextCursor = (string) $nextOffset; - } - - return [ - 'items' => $items, - 'nextCursor' => $nextCursor, - 'total' => $total, - ]; - }//end getReactorsByEmoji() - - /** - * Cascade-delete every reaction for a dashboard. Called by the - * `ReactionsListener` (REQ-CSC-003) on `DashboardDeletedEvent`. - * Returns the number of rows removed for log/observability. - * - * @param string $dashboardUuid The dashboard UUID. - * - * @return int The number of rows deleted. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - public function deleteReactionsByDashboard(string $dashboardUuid): int - { - return $this->reactionMapper->deleteByDashboardUuid( - dashboardUuid: $dashboardUuid - ); - }//end deleteReactionsByDashboard() +class ReactionService { + /** + * Admin setting key — global on/off toggle. Default: true. + * + * @var string + */ + public const KEY_ENABLED_DEFAULT = 'reactions_enabled_default'; + + /** + * Admin setting key — JSON array of allowed emoji. + * + * @var string + */ + public const KEY_ALLOWED_EMOJIS = 'reactions_allowed_emojis'; + + /** + * Default reactor-pagination cap (REQ-RXN-004 — 100-item ceiling). + * + * @var integer + */ + public const REACTORS_PAGE_SIZE = 100; + + /** + * Factory default whitelist applied when the admin has not stored + * a custom value. Matches the proposal default exactly. + * + * @var array + */ + public const DEFAULT_ALLOWED_EMOJIS = [ + '👍', + '❤️', + '🎉', + '😂', + '🤔', + '😢', + ]; + + /** + * Constructor + * + * @param DashboardReactionMapper $reactionMapper The reaction mapper. + * @param DashboardMapper $dashboardMapper Dashboard lookups + * (toggle resolution). + * @param PermissionService $permissionService VIEW permission gate. + * @param IAppConfig $appConfig App config — admin + * settings. + * @param IUserManager $userManager Display name + * resolution for the + * reactors-by-emoji + * endpoint. + */ + public function __construct( + private readonly DashboardReactionMapper $reactionMapper, + private readonly DashboardMapper $dashboardMapper, + private readonly PermissionService $permissionService, + private readonly IAppConfig $appConfig, + private readonly IUserManager $userManager, + ) { + }//end __construct() + + /** + * Resolve the effective reactions-enabled state for a dashboard. + * + * Resolution rules (REQ-RXN-005, REQ-RXN-006): + * - dashboard.reactionsEnabled === 1 → true + * - dashboard.reactionsEnabled === 0 → false + * - dashboard.reactionsEnabled === null → follow global setting + * + * @param Dashboard $dashboard The dashboard. + * + * @return bool True when reactions are effectively enabled. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function isReactionsEnabled(Dashboard $dashboard): bool { + $perDash = $dashboard->getReactionsEnabled(); + if ($perDash === 1) { + return true; + } + + if ($perDash === 0) { + return false; + } + + return $this->isReactionsEnabledByDefault(); + }//end isReactionsEnabled() + + /** + * Read the admin global on/off toggle. Default: true. + * + * @return bool True when the global default is on. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function isReactionsEnabledByDefault(): bool { + return $this->appConfig->getValueBool( + app: Application::APP_ID, + key: self::KEY_ENABLED_DEFAULT, + default: true + ); + }//end isReactionsEnabledByDefault() + + /** + * Read the admin emoji whitelist. Falls back to the factory default + * when the setting is missing or corrupt. + * + * @return array The allowed emoji list. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function getAllowedEmojis(): array { + $raw = $this->appConfig->getValueString( + app: Application::APP_ID, + key: self::KEY_ALLOWED_EMOJIS, + default: '' + ); + + if ($raw === '') { + return self::DEFAULT_ALLOWED_EMOJIS; + } + + $decoded = json_decode(json: $raw, associative: true); + if (is_array($decoded) === false) { + return self::DEFAULT_ALLOWED_EMOJIS; + } + + $cleaned = []; + foreach ($decoded as $entry) { + if (is_string($entry) === true && $entry !== '') { + $cleaned[] = $entry; + } + } + + // An admin-set empty list is intentional (per REQ-RXN-007 + // scenario "Empty emoji in whitelist") — surface the empty + // list as-is rather than falling back to the default. + return $cleaned; + }//end getAllowedEmojis() + + /** + * Throw when the supplied emoji is not in the allowed list. + * + * @param string $emoji The emoji to validate. + * + * @return void + * + * @throws InvalidArgumentException When the emoji is empty or not + * whitelisted. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function validateEmoji(string $emoji): void { + if ($emoji === '') { + throw new InvalidArgumentException(message: 'Emoji not allowed'); + } + + $allowed = $this->getAllowedEmojis(); + if (in_array(needle: $emoji, haystack: $allowed, strict: true) === false) { + throw new InvalidArgumentException(message: 'Emoji not allowed'); + } + }//end validateEmoji() + + /** + * Look up a dashboard by UUID and enforce the calling user's VIEW + * permission. Used as the gate for every reaction endpoint. + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $userId The acting user ID. + * + * @return Dashboard The resolved dashboard. + * + * @throws DoesNotExistException When the dashboard does not exist. + * @throws PermissionDeniedException When the user cannot VIEW it. + */ + private function loadAndAuthorise( + string $dashboardUuid, + string $userId, + ): Dashboard { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $dashboardUuid); + if ($this->permissionService->canViewDashboard( + userId: $userId, + dashboardId: $dashboard->getId() + ) === false + ) { + throw new PermissionDeniedException( + message: 'Permission denied' + ); + } + + return $dashboard; + }//end loadAndAuthorise() + + /** + * Add a reaction. Idempotent — re-posting the same emoji is a no-op + * that returns the existing summary (REQ-RXN-001 scenario "User + * re-posts the same emoji"). + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $userId The acting user ID. + * @param string $emoji The emoji to add. + * + * @return array The updated reactions summary. + * + * @throws DoesNotExistException When the dashboard is missing. + * @throws PermissionDeniedException When the user cannot VIEW. + * @throws ReactionsDisabledException When reactions are off. + * @throws InvalidArgumentException When the emoji is not whitelisted. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function addReaction( + string $dashboardUuid, + string $userId, + string $emoji, + ): array { + $dashboard = $this->loadAndAuthorise( + dashboardUuid: $dashboardUuid, + userId: $userId + ); + + if ($this->isReactionsEnabled(dashboard: $dashboard) === false) { + throw new ReactionsDisabledException( + message: 'Reactions are disabled' + ); + } + + $this->validateEmoji(emoji: $emoji); + + try { + $this->reactionMapper->addReaction( + dashboardUuid: $dashboardUuid, + userId: $userId, + emoji: $emoji + ); + } catch (DbException $exception) { + // Unique-constraint hit (REASON_UNIQUE_CONSTRAINT_VIOLATION = 4) + // — swallow for idempotent semantics; any other DB failure + // bubbles up so the controller can report 500. + if ($exception->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $exception; + } + } + + return $this->buildSummary(dashboard: $dashboard, userId: $userId); + }//end addReaction() + + /** + * Remove a reaction. Idempotent — if no row matches, silently + * succeeds (REQ-RXN-002 scenario "User attempts to remove a + * reaction they did not make"). + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $userId The acting user ID. + * @param string $emoji The emoji to remove. + * + * @return bool True when a row was deleted, false when none matched. + * + * @throws DoesNotExistException When the dashboard is missing. + * @throws PermissionDeniedException When the user cannot VIEW. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function removeReaction( + string $dashboardUuid, + string $userId, + string $emoji, + ): bool { + $this->loadAndAuthorise( + dashboardUuid: $dashboardUuid, + userId: $userId + ); + + return $this->reactionMapper->removeReaction( + dashboardUuid: $dashboardUuid, + userId: $userId, + emoji: $emoji + ); + }//end removeReaction() + + /** + * Build the `{counts, mine, enabled}` summary for a dashboard. + * + * When reactions are disabled (globally or per-dashboard) returns + * `{counts: {}, mine: [], enabled: false}` regardless of stored + * rows — REQ-RXN-003 scenario "Reactions disabled on dashboard". + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $userId The acting user ID. + * + * @return array The summary. + * + * @throws DoesNotExistException When the dashboard is missing. + * @throws PermissionDeniedException When the user cannot VIEW. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function getReactionsSummary( + string $dashboardUuid, + string $userId, + ): array { + $dashboard = $this->loadAndAuthorise( + dashboardUuid: $dashboardUuid, + userId: $userId + ); + + return $this->buildSummary(dashboard: $dashboard, userId: $userId); + }//end getReactionsSummary() + + /** + * Build the summary from a pre-resolved Dashboard entity. Internal + * helper — every public path resolves through `loadAndAuthorise` + * first. + * + * @param Dashboard $dashboard The dashboard. + * @param string $userId The acting user ID. + * + * @return array The summary. + */ + private function buildSummary(Dashboard $dashboard, string $userId): array { + $enabled = $this->isReactionsEnabled(dashboard: $dashboard); + if ($enabled === false) { + return [ + 'counts' => (object)[], + 'mine' => [], + 'enabled' => false, + ]; + } + + $uuid = (string)$dashboard->getUuid(); + $counts = $this->reactionMapper->countByEmoji(dashboardUuid: $uuid); + $mine = []; + foreach ($this->reactionMapper->findByUser( + userId: $userId, + dashboardUuid: $uuid + ) as $reaction + ) { + $emoji = $reaction->getEmoji(); + if ($emoji !== null) { + $mine[] = $emoji; + } + } + + return [ + 'counts' => (object)$counts, + 'mine' => $mine, + 'enabled' => true, + ]; + }//end buildSummary() + + /** + * List reactors for a single emoji on a dashboard, capped at + * {@see self::REACTORS_PAGE_SIZE} per response with simple + * offset cursor pagination (REQ-RXN-004). + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $emoji The emoji. + * @param string $userId The acting user ID. + * @param string|null $cursor Optional opaque offset cursor. + * + * @return array Page payload with `items`, `nextCursor`, `total`. The + * `items` entries are `{userId, displayName, reactedAt}` + * associative arrays. + * + * @throws DoesNotExistException When the dashboard is missing. + * @throws PermissionDeniedException When the user cannot VIEW. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function getReactorsByEmoji( + string $dashboardUuid, + string $emoji, + string $userId, + ?string $cursor = null, + ): array { + $this->loadAndAuthorise( + dashboardUuid: $dashboardUuid, + userId: $userId + ); + + $offset = 0; + if ($cursor !== null && $cursor !== '') { + $candidate = (int)$cursor; + if ($candidate > 0) { + $offset = $candidate; + } + } + + $rows = $this->reactionMapper->findByEmoji( + dashboardUuid: $dashboardUuid, + emoji: $emoji, + limit: self::REACTORS_PAGE_SIZE, + offset: $offset + ); + $total = $this->reactionMapper->countReactorsByEmoji( + dashboardUuid: $dashboardUuid, + emoji: $emoji + ); + + $items = []; + foreach ($rows as $reaction) { + $reactorId = (string)$reaction->getUserId(); + $user = $this->userManager->get(uid: $reactorId); + $items[] = [ + 'userId' => $reactorId, + 'displayName' => $user?->getDisplayName() ?? $reactorId, + 'reactedAt' => $reaction->getReactedAtFormatted(), + ]; + } + + $nextOffset = ($offset + count($items)); + $nextCursor = null; + if ($nextOffset < $total) { + $nextCursor = (string)$nextOffset; + } + + return [ + 'items' => $items, + 'nextCursor' => $nextCursor, + 'total' => $total, + ]; + }//end getReactorsByEmoji() + + /** + * Cascade-delete every reaction for a dashboard. Called by the + * `ReactionsListener` (REQ-CSC-003) on `DashboardDeletedEvent`. + * Returns the number of rows removed for log/observability. + * + * @param string $dashboardUuid The dashboard UUID. + * + * @return int The number of rows deleted. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + public function deleteReactionsByDashboard(string $dashboardUuid): int { + return $this->reactionMapper->deleteByDashboardUuid( + dashboardUuid: $dashboardUuid + ); + }//end deleteReactionsByDashboard() }//end class diff --git a/lib/Service/ReactionsDisabledException.php b/lib/Service/ReactionsDisabledException.php index 75e7605c4..046c88490 100644 --- a/lib/Service/ReactionsDisabledException.php +++ b/lib/Service/ReactionsDisabledException.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); @@ -28,6 +28,5 @@ /** * Reactions are disabled (globally or per-dashboard). */ -class ReactionsDisabledException extends RuntimeException -{ +class ReactionsDisabledException extends RuntimeException { }//end class diff --git a/lib/Service/ResourceServeService.php b/lib/Service/ResourceServeService.php index 80f75bc5d..f93f3d60a 100644 --- a/lib/Service/ResourceServeService.php +++ b/lib/Service/ResourceServeService.php @@ -48,132 +48,127 @@ * * @spec openspec/changes/resource-serving/tasks.md */ -class ResourceServeService -{ - /** - * Map of file extension → Content-Type for the public serve route. - * - * Anything not in this map falls back to `application/octet-stream` - * — see REQ-RES-006 for the canonical mapping. - * - * @var array - */ - private const CONTENT_TYPE_MAP = [ - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'png' => 'image/png', - 'gif' => 'image/gif', - 'svg' => 'image/svg+xml', - 'webp' => 'image/webp', - ]; +class ResourceServeService { + /** + * Map of file extension → Content-Type for the public serve route. + * + * Anything not in this map falls back to `application/octet-stream` + * — see REQ-RES-006 for the canonical mapping. + * + * @var array + */ + private const CONTENT_TYPE_MAP = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + ]; - /** - * Constructor. - * - * @param IAppData $appData App-data accessor. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - private readonly IAppData $appData, - private readonly LoggerInterface $logger, - ) { - }//end __construct() + /** + * Constructor. + * + * @param IAppData $appData App-data accessor. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + private readonly IAppData $appData, + private readonly LoggerInterface $logger, + ) { + }//end __construct() - /** - * Resolve a filename to an ISimpleFile, or null on miss. - * - * @param string $filename The leaf filename. - * - * @return ISimpleFile|null The file, or null if absent / unreadable. - * - * @spec openspec/changes/resource-serving/tasks.md#task-1 - */ - public function findFile(string $filename): ?ISimpleFile - { - try { - $folder = $this->appData->getFolder(name: ResourceService::FOLDER); - return $folder->getFile(name: $filename); - } catch (NotFoundException $e) { - return null; - } catch (Throwable $e) { - $this->logger->warning( - message: 'Resource serve failed to open file', - context: ['exception' => $e->getMessage()] - ); - return null; - } - }//end findFile() + /** + * Resolve a filename to an ISimpleFile, or null on miss. + * + * @param string $filename The leaf filename. + * + * @return ISimpleFile|null The file, or null if absent / unreadable. + * + * @spec openspec/changes/resource-serving/tasks.md#task-1 + */ + public function findFile(string $filename): ?ISimpleFile { + try { + $folder = $this->appData->getFolder(name: ResourceService::FOLDER); + return $folder->getFile(name: $filename); + } catch (NotFoundException $e) { + return null; + } catch (Throwable $e) { + $this->logger->warning( + message: 'Resource serve failed to open file', + context: ['exception' => $e->getMessage()] + ); + return null; + } + }//end findFile() - /** - * Load the resources directory listing as ISimpleFile entries. - * - * Returns an empty array when the folder does not yet exist — - * matching REQ-RES-007's "never a 404" contract. - * - * @return array The file entries. - * - * @spec openspec/changes/resource-serving/tasks.md#task-3 - */ - public function listFiles(): array - { - try { - $folder = $this->appData->getFolder(name: ResourceService::FOLDER); - } catch (NotFoundException $e) { - return []; - } catch (Throwable $e) { - $this->logger->warning( - message: 'Resource list failed to open folder', - context: ['exception' => $e->getMessage()] - ); - return []; - } + /** + * Load the resources directory listing as ISimpleFile entries. + * + * Returns an empty array when the folder does not yet exist — + * matching REQ-RES-007's "never a 404" contract. + * + * @return array The file entries. + * + * @spec openspec/changes/resource-serving/tasks.md#task-3 + */ + public function listFiles(): array { + try { + $folder = $this->appData->getFolder(name: ResourceService::FOLDER); + } catch (NotFoundException $e) { + return []; + } catch (Throwable $e) { + $this->logger->warning( + message: 'Resource list failed to open folder', + context: ['exception' => $e->getMessage()] + ); + return []; + } - $entries = []; - foreach ($folder->getDirectoryListing() as $entry) { - if (($entry instanceof ISimpleFile) === true) { - $entries[] = $entry; - } - } + $entries = []; + foreach ($folder->getDirectoryListing() as $entry) { + if (($entry instanceof ISimpleFile) === true) { + $entries[] = $entry; + } + } - return $entries; - }//end listFiles() + return $entries; + }//end listFiles() - /** - * Pick the Content-Type for a filename via its extension. - * - * Falls back to `application/octet-stream` for unknown extensions. - * - * @param string $filename The leaf filename. - * - * @return string The MIME type to send. - * - * @spec openspec/changes/resource-serving/tasks.md#task-1 - */ - public function contentTypeForFilename(string $filename): string - { - $position = strrpos(haystack: $filename, needle: '.'); - if ($position === false) { - return 'application/octet-stream'; - } + /** + * Pick the Content-Type for a filename via its extension. + * + * Falls back to `application/octet-stream` for unknown extensions. + * + * @param string $filename The leaf filename. + * + * @return string The MIME type to send. + * + * @spec openspec/changes/resource-serving/tasks.md#task-1 + */ + public function contentTypeForFilename(string $filename): string { + $position = strrpos(haystack: $filename, needle: '.'); + if ($position === false) { + return 'application/octet-stream'; + } - $extension = strtolower(string: substr(string: $filename, offset: ($position + 1))); - return (self::CONTENT_TYPE_MAP[$extension] ?? 'application/octet-stream'); - }//end contentTypeForFilename() + $extension = strtolower(string: substr(string: $filename, offset: ($position + 1))); + return (self::CONTENT_TYPE_MAP[$extension] ?? 'application/octet-stream'); + }//end contentTypeForFilename() - /** - * Format a Unix epoch as an ISO-8601 UTC timestamp. - * - * @param int $epoch The Unix epoch (e.g. from ISimpleFile::getMTime()). - * - * @return string The ISO-8601 timestamp. - * - * @spec openspec/changes/resource-serving/tasks.md#task-3 - */ - public function formatTimestamp(int $epoch): string - { - $dateTime = (new DateTimeImmutable(datetime: '@'.$epoch)) - ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); + /** + * Format a Unix epoch as an ISO-8601 UTC timestamp. + * + * @param int $epoch The Unix epoch (e.g. from ISimpleFile::getMTime()). + * + * @return string The ISO-8601 timestamp. + * + * @spec openspec/changes/resource-serving/tasks.md#task-3 + */ + public function formatTimestamp(int $epoch): string { + $dateTime = (new DateTimeImmutable(datetime: '@' . $epoch)) + ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); - return $dateTime->format(format: DateTimeInterface::ATOM); - }//end formatTimestamp() + return $dateTime->format(format: DateTimeInterface::ATOM); + }//end formatTimestamp() }//end class diff --git a/lib/Service/ResourceService.php b/lib/Service/ResourceService.php index 21a427322..508f0a6eb 100644 --- a/lib/Service/ResourceService.php +++ b/lib/Service/ResourceService.php @@ -24,8 +24,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); @@ -43,254 +43,323 @@ use Throwable; /** - * Admin-only base64 upload pipeline for branding assets. + * Admin-only upload pipeline for branding assets (base64 + raw multipart). + * + * @spec openspec/specs/resource-uploads/spec.md */ -class ResourceService -{ - /** - * Maximum decoded payload size (5 MB). - * - * @var int - */ - public const MAX_BYTES = (5 * 1024 * 1024); +class ResourceService { + /** + * Maximum decoded payload size (5 MB). + * + * @var int + */ + public const MAX_BYTES = (5 * 1024 * 1024); + + /** + * Name of the IAppData subfolder where resources are stored. + * + * @var string + */ + public const FOLDER = 'resources'; + + /** + * Allowed declared image types (lowercase, no dots). + * + * @var array + */ + private const ALLOWED_TYPES = [ + 'jpeg', + 'jpg', + 'png', + 'gif', + 'svg', + 'webp', + ]; + + /** + * Constructor. + * + * @param IAppData $appData Nextcloud app-data + * interface for this app. + * @param ImageMimeValidator $mimeValidator Raster MIME cross-checker. + * @param SvgSanitiser $svgSanitiser DOM whitelist SVG sanitiser. + */ + public function __construct( + private readonly IAppData $appData, + private readonly ImageMimeValidator $mimeValidator, + private readonly SvgSanitiser $svgSanitiser, + ) { + }//end __construct() + + /** + * Upload a base64 data URL and return the persisted resource info. + * + * Parses the data URL, enforces the 5 MB cap on decoded bytes + * BEFORE invoking the image library, validates the declared type, + * cross-checks the raster MIME, and persists to app data. + * + * @param string $base64DataUrl A `data:image/;base64,<...>` + * string. + * + * @return array{url: string, name: string, size: int} The created + * resource. + * + * @throws InvalidDataUrlException When the prefix is missing + * or unparseable. + * @throws InvalidImageFormatException When the declared type is + * not in the allowed list. + * @throws InvalidSvgException When an SVG payload fails + * to parse or is fully stripped. + * @throws FileTooLargeException When decoded bytes (or the + * SANITISED bytes for SVG) + * exceed 5 MB. + * @throws StorageFailureException When writing to IAppData + * fails. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + public function upload(string $base64DataUrl): array { + $parsed = $this->parseDataUrl(input: $base64DataUrl); - /** - * Name of the IAppData subfolder where resources are stored. - * - * @var string - */ - public const FOLDER = 'resources'; + return $this->storeImageBytes( + bytes: $parsed['bytes'], + declaredType: $parsed['type'] + ); + }//end upload() - /** - * Allowed declared image types (lowercase, no dots). - * - * @var array - */ - private const ALLOWED_TYPES = [ - 'jpeg', - 'jpg', - 'png', - 'gif', - 'svg', - 'webp', - ]; + /** + * Upload already-decoded raw image bytes (multipart path). + * + * The declared type comes from the uploaded file's extension / MIME + * rather than a data-URL prefix; it is normalised and vetted against + * the same allow-list before running the shared validation and + * persistence pipeline. No base64 is involved — the caller has the + * raw bytes already (REQ-RES-014). + * + * @param string $bytes The raw (decoded) image bytes. + * @param string $declaredType The declared image type (e.g. from the + * file extension or `image/` MIME). + * + * @return array{url: string, name: string, size: int} The created + * resource. + * + * @throws InvalidDataUrlException When the byte payload is empty. + * @throws InvalidImageFormatException When the declared type is not + * in the allowed list. + * @throws InvalidSvgException When an SVG payload fails to + * parse or is fully stripped. + * @throws FileTooLargeException When the bytes exceed 5 MB. + * @throws StorageFailureException When writing to IAppData fails. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + public function uploadRaw(string $bytes, string $declaredType): array { + if ($bytes === '') { + throw new InvalidDataUrlException( + message: 'Uploaded file is empty' + ); + } - /** - * Constructor. - * - * @param IAppData $appData Nextcloud app-data - * interface for this app. - * @param ImageMimeValidator $mimeValidator Raster MIME cross-checker. - * @param SvgSanitiser $svgSanitiser DOM whitelist SVG sanitiser. - */ - public function __construct( - private readonly IAppData $appData, - private readonly ImageMimeValidator $mimeValidator, - private readonly SvgSanitiser $svgSanitiser, - ) { - }//end __construct() + $declaredType = $this->normaliseDeclaredType( + raw: strtolower(string: $declaredType) + ); + if (in_array( + needle: $declaredType, + haystack: self::ALLOWED_TYPES, + strict: true + ) === false + ) { + throw new InvalidImageFormatException(); + } - /** - * Upload a base64 data URL and return the persisted resource info. - * - * Parses the data URL, enforces the 5 MB cap on decoded bytes - * BEFORE invoking the image library, validates the declared type, - * cross-checks the raster MIME, and persists to app data. - * - * @param string $base64DataUrl A `data:image/;base64,<...>` - * string. - * - * @return array{url: string, name: string, size: int} The created - * resource. - * - * @throws InvalidDataUrlException When the prefix is missing - * or unparseable. - * @throws InvalidImageFormatException When the declared type is - * not in the allowed list. - * @throws InvalidSvgException When an SVG payload fails - * to parse or is fully stripped. - * @throws FileTooLargeException When decoded bytes (or the - * SANITISED bytes for SVG) - * exceed 5 MB. - * @throws StorageFailureException When writing to IAppData - * fails. - * - * @spec openspec/specs/resource-uploads/spec.md - */ - public function upload(string $base64DataUrl): array - { - $parsed = $this->parseDataUrl(input: $base64DataUrl); - $declaredType = $parsed['type']; - $bytes = $parsed['bytes']; + return $this->storeImageBytes( + bytes: $bytes, + declaredType: $declaredType + ); + }//end uploadRaw() - // SVG branch: sanitise BEFORE the size check so the 5 MB cap - // is measured against the persisted (sanitised) byte count - // (REQ-RES-009). Sanitiser returns null on parse failure or - // an empty document — surface as HTTP 400 invalid_svg. - if ($declaredType === 'svg') { - $sanitised = $this->svgSanitiser->sanitize(bytes: $bytes); - if ($sanitised === null) { - throw new InvalidSvgException(); - } + /** + * Validate and persist decoded image bytes, returning the resource info. + * + * Shared tail for both the base64 ({@see upload}) and raw multipart + * ({@see uploadRaw}) entry points: SVG sanitise → 5 MB cap → raster + * MIME cross-check → persist → build the public URL. The declared + * type MUST already be normalised and allow-listed by the caller. + * + * @param string $bytes The decoded image bytes. + * @param string $declaredType The normalised, allow-listed declared type. + * + * @return array{url: string, name: string, size: int} The created + * resource. + * + * @throws InvalidSvgException When an SVG payload is invalid. + * @throws FileTooLargeException When the bytes exceed 5 MB. + * @throws StorageFailureException When writing to IAppData fails. + */ + private function storeImageBytes(string $bytes, string $declaredType): array { + // SVG branch: sanitise BEFORE the size check so the 5 MB cap + // is measured against the persisted (sanitised) byte count + // (REQ-RES-009). Sanitiser returns null on parse failure or + // an empty document — surface as HTTP 400 invalid_svg. + if ($declaredType === 'svg') { + $sanitised = $this->svgSanitiser->sanitize(bytes: $bytes); + if ($sanitised === null) { + throw new InvalidSvgException(); + } - $bytes = $sanitised; - } + $bytes = $sanitised; + } - // Enforce the 5 MB cap BEFORE invoking the image library. - if (strlen(string: $bytes) > self::MAX_BYTES) { - throw new FileTooLargeException(); - } + // Enforce the 5 MB cap BEFORE invoking the image library. + if (strlen(string: $bytes) > self::MAX_BYTES) { + throw new FileTooLargeException(); + } - // Cross-check raster MIME (SVG short-circuits inside validate). - $this->mimeValidator->validate( - declaredType: $declaredType, - bytes: $bytes - ); + // Cross-check raster MIME (SVG short-circuits inside validate). + $this->mimeValidator->validate( + declaredType: $declaredType, + bytes: $bytes + ); - $extension = $this->normaliseExtension(declaredType: $declaredType); - $filename = ('resource_'.uniqid(prefix: '', more_entropy: true).'.'.$extension); + $extension = $this->normaliseExtension(declaredType: $declaredType); + $filename = ('resource_' . uniqid(prefix: '', more_entropy: true) . '.' . $extension); - $this->persist(filename: $filename, bytes: $bytes); + $this->persist(filename: $filename, bytes: $bytes); - // Build the public URL directly — the serving endpoint is - // delivered by the sibling `resource-serving` change. The spec - // mandates the relative path form `/apps/launchpad/resource/`, - // so we don't pass it through linkToRoute or getAbsoluteURL. - $url = ('/apps/'.Application::APP_ID.'/resource/'.$filename); + // Build the public URL directly — the serving endpoint is + // delivered by the sibling `resource-serving` change. The spec + // mandates the relative path form `/apps/launchpad/resource/`, + // so we don't pass it through linkToRoute or getAbsoluteURL. + $url = ('/apps/' . Application::APP_ID . '/resource/' . $filename); - return [ - 'url' => $url, - 'name' => $filename, - 'size' => strlen(string: $bytes), - ]; - }//end upload() + return [ + 'url' => $url, + 'name' => $filename, + 'size' => strlen(string: $bytes), + ]; + }//end storeImageBytes() - /** - * Parse a `data:image/;base64,` string. - * - * The declared type is normalised to lowercase. Anything outside - * the allowed list is rejected. - * - * @param string $input The raw input string. - * - * @return array{type: string, bytes: string} The normalised - * declared type and the - * decoded bytes. - * - * @throws InvalidDataUrlException When the prefix is missing. - * @throws InvalidImageFormatException When the declared type is - * not allowed. - */ - private function parseDataUrl(string $input): array - { - $matches = []; - // Match "data:image/;base64," — type is alpha - // plus optional "+xml" suffix (handles `image/svg+xml`). - if (preg_match( - pattern: '#^data:image/([a-zA-Z0-9.+-]+);base64,(.+)$#s', - subject: $input, - matches: $matches - ) !== 1 - ) { - throw new InvalidDataUrlException(); - } + /** + * Parse a `data:image/;base64,` string. + * + * The declared type is normalised to lowercase. Anything outside + * the allowed list is rejected. + * + * @param string $input The raw input string. + * + * @return array{type: string, bytes: string} The normalised + * declared type and the + * decoded bytes. + * + * @throws InvalidDataUrlException When the prefix is missing. + * @throws InvalidImageFormatException When the declared type is + * not allowed. + */ + private function parseDataUrl(string $input): array { + $matches = []; + // Match "data:image/;base64," — type is alpha + // plus optional "+xml" suffix (handles `image/svg+xml`). + if (preg_match( + pattern: '#^data:image/([a-zA-Z0-9.+-]+);base64,(.+)$#s', + subject: $input, + matches: $matches + ) !== 1 + ) { + throw new InvalidDataUrlException(); + } - $declaredRaw = strtolower(string: $matches[1]); - $payload = $matches[2]; + $declaredRaw = strtolower(string: $matches[1]); + $payload = $matches[2]; - $declaredType = $this->normaliseDeclaredType(raw: $declaredRaw); - if (in_array( - needle: $declaredType, - haystack: self::ALLOWED_TYPES, - strict: true - ) === false - ) { - throw new InvalidImageFormatException(); - } + $declaredType = $this->normaliseDeclaredType(raw: $declaredRaw); + if (in_array( + needle: $declaredType, + haystack: self::ALLOWED_TYPES, + strict: true + ) === false + ) { + throw new InvalidImageFormatException(); + } - $bytes = base64_decode(string: $payload, strict: true); - if ($bytes === false || $bytes === '') { - throw new InvalidDataUrlException( - message: 'Body must contain valid base64 data' - ); - } + $bytes = base64_decode(string: $payload, strict: true); + if ($bytes === false || $bytes === '') { + throw new InvalidDataUrlException( + message: 'Body must contain valid base64 data' + ); + } - return [ - 'type' => $declaredType, - 'bytes' => $bytes, - ]; - }//end parseDataUrl() + return [ + 'type' => $declaredType, + 'bytes' => $bytes, + ]; + }//end parseDataUrl() - /** - * Normalise a raw declared type string from the data URL prefix. - * - * `image/svg+xml` → `svg`; otherwise the lowercased subtype is - * returned untouched (callers vet against ALLOWED_TYPES). - * - * @param string $raw The raw lowercased subtype. - * - * @return string The normalised declared type. - */ - private function normaliseDeclaredType(string $raw): string - { - if ($raw === 'svg+xml') { - return 'svg'; - } + /** + * Normalise a raw declared type string from the data URL prefix. + * + * `image/svg+xml` → `svg`; otherwise the lowercased subtype is + * returned untouched (callers vet against ALLOWED_TYPES). + * + * @param string $raw The raw lowercased subtype. + * + * @return string The normalised declared type. + */ + private function normaliseDeclaredType(string $raw): string { + if ($raw === 'svg+xml') { + return 'svg'; + } - return $raw; - }//end normaliseDeclaredType() + return $raw; + }//end normaliseDeclaredType() - /** - * Map a normalised declared type to its file extension. - * - * Currently the extension equals the declared type for every - * allowed value. - * - * @param string $declaredType The normalised lowercase type. - * - * @return string The file extension (without the dot). - */ - private function normaliseExtension(string $declaredType): string - { - return $declaredType; - }//end normaliseExtension() + /** + * Map a normalised declared type to its file extension. + * + * Currently the extension equals the declared type for every + * allowed value. + * + * @param string $declaredType The normalised lowercase type. + * + * @return string The file extension (without the dot). + */ + private function normaliseExtension(string $declaredType): string { + return $declaredType; + }//end normaliseExtension() - /** - * Persist validated bytes via IAppData. - * - * Auto-creates the `resources/` folder on first use. Wraps any - * IAppData failure into a typed StorageFailureException so that - * raw underlying messages never leak to clients. - * - * @param string $filename The target filename inside the folder. - * @param string $bytes The validated payload bytes. - * - * @return void - * - * @throws StorageFailureException When the underlying storage - * layer rejects the write. - */ - private function persist(string $filename, string $bytes): void - { - try { - $folder = $this->getOrCreateFolder(); - $folder->newFile(name: $filename, content: $bytes); - } catch (Throwable $e) { - throw new StorageFailureException(); - } - }//end persist() + /** + * Persist validated bytes via IAppData. + * + * Auto-creates the `resources/` folder on first use. Wraps any + * IAppData failure into a typed StorageFailureException so that + * raw underlying messages never leak to clients. + * + * @param string $filename The target filename inside the folder. + * @param string $bytes The validated payload bytes. + * + * @return void + * + * @throws StorageFailureException When the underlying storage + * layer rejects the write. + */ + private function persist(string $filename, string $bytes): void { + try { + $folder = $this->getOrCreateFolder(); + $folder->newFile(name: $filename, content: $bytes); + } catch (Throwable $e) { + throw new StorageFailureException(); + } + }//end persist() - /** - * Get the resources folder, creating it if it doesn't exist. - * - * @return \OCP\Files\SimpleFS\ISimpleFolder The resources folder. - */ - private function getOrCreateFolder(): \OCP\Files\SimpleFS\ISimpleFolder - { - try { - return $this->appData->getFolder(name: self::FOLDER); - } catch (NotFoundException $e) { - return $this->appData->newFolder(name: self::FOLDER); - } - }//end getOrCreateFolder() + /** + * Get the resources folder, creating it if it doesn't exist. + * + * @return \OCP\Files\SimpleFS\ISimpleFolder The resources folder. + */ + private function getOrCreateFolder(): \OCP\Files\SimpleFS\ISimpleFolder { + try { + return $this->appData->getFolder(name: self::FOLDER); + } catch (NotFoundException $e) { + return $this->appData->newFolder(name: self::FOLDER); + } + }//end getOrCreateFolder() }//end class diff --git a/lib/Service/RoleFeaturePermissionService.php b/lib/Service/RoleFeaturePermissionService.php index 640a4ec84..b43fb2831 100644 --- a/lib/Service/RoleFeaturePermissionService.php +++ b/lib/Service/RoleFeaturePermissionService.php @@ -18,8 +18,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); @@ -81,545 +81,668 @@ * All public methods are stateless — no per-request memoisation. Caller * concerns (controllers, other services) inject this directly. */ -class RoleFeaturePermissionService -{ - /** - * Constructor. - * - * @param RoleFeaturePermissionMapper $permissionMapper Permission mapper. - * @param RoleLayoutDefaultMapper $defaultMapper Layout default mapper. - * @param WidgetPlacementMapper $placementMapper Widget placement mapper. - * @param AdminSettingsService $adminSettings Admin settings reader. - * @param AdminTemplateService $adminTemplateService Routing resolver — single - * source of truth for - * `IGroupManager::getUserGroupIds` - * (REQ-TMPL-013). - * @param IUserManager $userManager Nextcloud user manager. - * @param IGroupManager $groupManager Group manager for the admin - * break-glass bypass (mirrors - * ActionAuthService / - * PermissionService). - */ - public function __construct( - private readonly RoleFeaturePermissionMapper $permissionMapper, - private readonly RoleLayoutDefaultMapper $defaultMapper, - private readonly WidgetPlacementMapper $placementMapper, - private readonly AdminSettingsService $adminSettings, - private readonly AdminTemplateService $adminTemplateService, - private readonly IUserManager $userManager, - private readonly IGroupManager $groupManager, - ) { - }//end __construct() - - /** - * List all RoleFeaturePermission rows for the admin UI. - * - * @return RoleFeaturePermission[] All rows. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function listPermissions(): array - { - return $this->permissionMapper->findAll(); - }//end listPermissions() - - /** - * List all RoleLayoutDefault rows for the admin UI. - * - * @return RoleLayoutDefault[] All rows. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function listLayoutDefaults(): array - { - return $this->defaultMapper->findAll(); - }//end listLayoutDefaults() - - /** - * Upsert a RoleFeaturePermission row keyed by `groupId`. - * - * @param array $data The submitted permission data. - * - * @return RoleFeaturePermission The persisted row. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function savePermission(array $data): RoleFeaturePermission - { - $groupId = (string) ($data['groupId'] ?? ''); - if ($groupId === '') { - throw new InvalidArgumentException(message: 'groupId is required'); - } - - try { - $entity = $this->permissionMapper->findByGroupId(groupId: $groupId); - } catch (DoesNotExistException $e) { - $entity = new RoleFeaturePermission(); - $now = (new DateTime())->format(format: 'c'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setCreatedAt($now); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGroupId($groupId); - } - - if (array_key_exists(key: 'name', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setName((string) $data['name']); - } - - if (array_key_exists(key: 'description', array: $data) === true) { - $description = null; - if ($data['description'] !== null) { - $description = (string) $data['description']; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setDescription($description); - } - - if (array_key_exists(key: 'allowedWidgets', array: $data) === true) { - $allowed = []; - if (is_array(value: $data['allowedWidgets']) === true) { - $allowed = $data['allowedWidgets']; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setAllowedWidgets(json_encode(value: array_values(array: $allowed))); - } - - if (array_key_exists(key: 'deniedWidgets', array: $data) === true) { - $denied = []; - if (is_array(value: $data['deniedWidgets']) === true) { - $denied = $data['deniedWidgets']; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setDeniedWidgets(json_encode(value: array_values(array: $denied))); - } - - if (array_key_exists(key: 'priorityWeights', array: $data) === true) { - $weights = []; - if (is_array(value: $data['priorityWeights']) === true) { - $weights = $data['priorityWeights']; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setPriorityWeights(json_encode(value: $weights)); - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setUpdatedAt((new DateTime())->format(format: 'c')); - - // Entity::getId() can return null when the row hasn't been - // persisted yet (REQ-RFP-007 — upsert semantics). PHPStan's - // PHPDoc says `int` but the runtime allows null until insert. - // @phpstan-ignore-next-line identical.alwaysFalse — null on insert, ok. - if ($entity->getId() === null) { - return $this->permissionMapper->insert(entity: $entity); - } - - return $this->permissionMapper->update(entity: $entity); - }//end savePermission() - - /** - * Delete a RoleFeaturePermission row by id. - * - * @param int $id The row id. - * - * @return void - * - * @throws DoesNotExistException When the row does not exist. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function deletePermission(int $id): void - { - $entity = $this->permissionMapper->find(id: $id); - $this->permissionMapper->delete(entity: $entity); - }//end deletePermission() - - /** - * Upsert a RoleLayoutDefault row keyed by `(groupId, widgetId)`. - * - * @param array $data The submitted layout default data. - * - * @return RoleLayoutDefault The persisted row. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function saveLayoutDefault(array $data): RoleLayoutDefault - { - $groupId = (string) ($data['groupId'] ?? ''); - $widgetId = (string) ($data['widgetId'] ?? ''); - if ($groupId === '' || $widgetId === '') { - throw new InvalidArgumentException( - message: 'groupId and widgetId are required' - ); - } - - try { - $entity = $this->defaultMapper->findByGroupAndWidget( - groupId: $groupId, - widgetId: $widgetId - ); - } catch (DoesNotExistException $e) { - $entity = new RoleLayoutDefault(); - $now = (new DateTime())->format(format: 'c'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setCreatedAt($now); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGroupId($groupId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setWidgetId($widgetId); - } - - if (array_key_exists(key: 'name', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setName((string) $data['name']); - } - - if (array_key_exists(key: 'description', array: $data) === true) { - $description = null; - if ($data['description'] !== null) { - $description = (string) $data['description']; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setDescription($description); - } - - if (array_key_exists(key: 'gridX', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGridX((int) $data['gridX']); - } - - if (array_key_exists(key: 'gridY', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGridY((int) $data['gridY']); - } - - if (array_key_exists(key: 'gridWidth', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGridWidth(max(1, (int) $data['gridWidth'])); - } - - if (array_key_exists(key: 'gridHeight', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGridHeight(max(1, (int) $data['gridHeight'])); - } - - if (array_key_exists(key: 'sortOrder', array: $data) === true) { - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setSortOrder((int) $data['sortOrder']); - } - - if (array_key_exists(key: 'isCompulsory', array: $data) === true) { - $isCompulsory = 0; - if ((bool) $data['isCompulsory'] === true) { - $isCompulsory = 1; - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setIsCompulsory($isCompulsory); - } - - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setUpdatedAt((new DateTime())->format(format: 'c')); - - // Same upsert semantics as above — null-on-insert tolerated. - // @phpstan-ignore-next-line identical.alwaysFalse — null on insert, ok. - if ($entity->getId() === null) { - return $this->defaultMapper->insert(entity: $entity); - } - - return $this->defaultMapper->update(entity: $entity); - }//end saveLayoutDefault() - - /** - * Delete a RoleLayoutDefault row by id. - * - * @param int $id The row id. - * - * @return void - * - * @throws DoesNotExistException When the row does not exist. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function deleteLayoutDefault(int $id): void - { - $entity = $this->defaultMapper->find(id: $id); - $this->defaultMapper->delete(entity: $entity); - }//end deleteLayoutDefault() - - /** - * Resolve the effective allowed-widget ID list for a user. - * - * Returns `null` (= no restriction, REQ-RFP-009) when none of the user's - * groups are mapped AND no `default` RoleFeaturePermission exists. - * - * Algorithm (REQ-RFP-005): - * 1. Walk the configured `group_order` array. - * 2. The FIRST group that matches BOTH the user's group memberships AND - * has a RoleFeaturePermission row provides the BASE allowed set. - * 3. ALL subsequent groups that match the user widen the allowed set - * via union. - * 4. ANY group's `deniedWidgets` removes those widget IDs from the - * final set (deny-wins). - * 5. If no `group_order` group matched, fall back to the row whose - * groupId == 'default' (REQ-RFP-009). - * - * @param string $userId The user's UID. - * - * @return array|null Sorted list of allowed widget IDs, or null. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function getAllowedWidgetIds(string $userId): ?array - { - // Admin break-glass: Nextcloud admins are never restricted by the - // role-feature-permission allow-list (mirrors the admin short-circuit - // in ActionAuthService::requireAction and PermissionService:: - // resolveAccessLevel). Returning null signals "no restriction" so an - // admin can always add any widget to their own dashboard. - if ($this->groupManager->isAdmin(userId: $userId) === true) { - return null; - } - - $userGroups = $this->groupIdsForUser(userId: $userId); - if ($userGroups === []) { - return $this->fallbackAllowedWidgets(); - } - - $resolved = $this->resolveGroupOrderWidgets(userGroups: $userGroups); - if ($resolved === null) { - // No group_order match — try the explicit 'default' row. - return $this->fallbackAllowedWidgets(); - } - - $effective = array_values( - array: array_diff($resolved['allowed'], $resolved['denied']) - ); - sort(array: $effective); - return $effective; - }//end getAllowedWidgetIds() - - /** - * Walk the configured `group_order` and fold the matching user's - * RoleFeaturePermission rows into a base + union allow-set with a - * deny-wins overlay (REQ-RFP-005). Returns `null` when none of the - * user's `group_order` groups have a permission row, so the caller can - * fall back to the explicit `default` row. - * - * @param array $userGroups The user's group IDs. - * - * @return array{allowed: array, denied: array}|null The folded allow/deny - * sets, or null on no match. - */ - private function resolveGroupOrderWidgets(array $userGroups): ?array - { - $groupOrder = $this->adminSettings->getGroupOrder(); - $base = null; - $allowed = []; - $denied = []; - - // Pre-fetch all RoleFeaturePermission rows for the user's groups (one query). - $rows = $this->permissionMapper->findByGroupIds(groupIds: $userGroups); - $byGid = []; - foreach ($rows as $row) { - $byGid[$row->getGroupId()] = $row; - } - - foreach ($groupOrder as $gid) { - $matchesUser = in_array(needle: $gid, haystack: $userGroups, strict: true); - if ($matchesUser === false || array_key_exists(key: $gid, array: $byGid) === false) { - continue; - } - - $row = $byGid[$gid]; - $rowAllow = $row->getAllowedWidgetsDecoded(); - $rowDeny = $row->getDeniedWidgetsDecoded(); - $isFirstMatch = ($base === null); - if ($isFirstMatch === true) { - $base = true; - $allowed = $rowAllow; - } - - if ($isFirstMatch === false) { - $allowed = array_values( - array: array_unique(array: array_merge($allowed, $rowAllow)) - ); - } - - $denied = array_values( - array: array_unique(array: array_merge($denied, $rowDeny)) - ); - }//end foreach - - if ($base === null) { - return null; - } - - return [ - 'allowed' => $allowed, - 'denied' => $denied, - ]; - }//end resolveGroupOrderWidgets() - - /** - * Check whether a specific widget is allowed for the given user. - * - * Returns `true` when the role configuration imposes no restriction on - * the user (i.e. `getAllowedWidgetIds()` returns `null`), or when the - * widget ID is explicitly included in the allowed set. - * - * @param string $userId The user's UID. - * @param string $widgetId The widget identifier to check. - * - * @return bool True when the widget is accessible to the user. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function isWidgetAllowed(string $userId, string $widgetId): bool - { - // Admin break-glass — admins may add any widget regardless of role - // configuration (mirrors ActionAuthService / PermissionService). - if ($this->groupManager->isAdmin(userId: $userId) === true) { - return true; - } - - $allowed = $this->getAllowedWidgetIds(userId: $userId); - if ($allowed === null) { - return true; - } - - return in_array(needle: $widgetId, haystack: $allowed, strict: true); - }//end isWidgetAllowed() - - /** - * Seed the default layout for a freshly created dashboard from the - * RoleLayoutDefault rows attached to the user's primary group. - * - * No-op when the dashboard already has placements (REQ-RFP-002 scenario 3 - * — never overwrite personal customisations). - * - * Resolves the user's primary group by walking `group_order` and taking - * the first match that has at least one RoleLayoutDefault row. - * - * @param string $userId The user's UID. - * @param Dashboard $dashboard The dashboard to seed (must already exist). - * - * @return int The number of placements created (0 when no-op). - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function seedLayoutFromRoleDefaults(string $userId, Dashboard $dashboard): int - { - $existing = $this->placementMapper->findByDashboardId( - dashboardId: $dashboard->getId() - ); - if (count(value: $existing) > 0) { - return 0; - } - - $userGroups = $this->groupIdsForUser(userId: $userId); - if ($userGroups === []) { - return 0; - } - - $groupOrder = $this->adminSettings->getGroupOrder(); - $defaults = []; - foreach ($groupOrder as $gid) { - if (in_array(needle: $gid, haystack: $userGroups, strict: true) === false) { - continue; - } - - $defaults = $this->defaultMapper->findByGroupId(groupId: $gid); - if (count(value: $defaults) > 0) { - break; - } - } - - if (count(value: $defaults) === 0) { - return 0; - } - - $created = 0; - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - foreach ($defaults as $default) { - $placement = new WidgetPlacement(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setDashboardId($dashboard->getId()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setWidgetId($default->getWidgetId()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridX($default->getGridX()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridY($default->getGridY()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridWidth($default->getGridWidth()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setGridHeight($default->getGridHeight()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setSortOrder($default->getSortOrder()); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setShowTitle(1); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setIsVisible(1); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setCreatedAt($now); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $placement->setUpdatedAt($now); - - $this->placementMapper->insert(entity: $placement); - $created++; - }//end foreach - - return $created; - }//end seedLayoutFromRoleDefaults() - - /** - * Look up `default` RoleFeaturePermission row when no user-group match - * occurred. Returns null when there is no `default` row. - * - * @return array|null The allowed widget list from the default row. - */ - private function fallbackAllowedWidgets(): ?array - { - try { - $row = $this->permissionMapper->findByGroupId( - groupId: RoleFeaturePermission::GROUP_DEFAULT - ); - } catch (DoesNotExistException $e) { - return null; - } - - $allowed = $row->getAllowedWidgetsDecoded(); - $denied = $row->getDeniedWidgetsDecoded(); - $eff = array_values(array: array_diff($allowed, $denied)); - sort(array: $eff); - return $eff; - }//end fallbackAllowedWidgets() - - /** - * Pull the list of group IDs a user belongs to. Wraps `IGroupManager`. - * - * @param string $userId The user UID. - * - * @return array The user's group IDs (may be empty). - */ - private function groupIdsForUser(string $userId): array - { - // REQ-TMPL-013: the routing resolver invariant requires every - // `getUserGroupIds(...)` call to live inside AdminTemplateService. - // Delegating here keeps the role-feature-permission resolver - // honest with the grep guard while still letting the service - // make a per-user group-membership decision. - $user = $this->userManager->get(uid: $userId); - if ($user === null) { - return []; - } - - return $this->adminTemplateService->getUserGroupIdsFor(userId: $userId); - }//end groupIdsForUser() +class RoleFeaturePermissionService { + /** + * Constructor. + * + * @param RoleFeaturePermissionMapper $permissionMapper Permission mapper. + * @param RoleLayoutDefaultMapper $defaultMapper Layout default mapper. + * @param WidgetPlacementMapper $placementMapper Widget placement mapper. + * @param AdminSettingsService $adminSettings Admin settings reader. + * @param AdminTemplateService $adminTemplateService Routing resolver — single + * source of truth for + * `IGroupManager::getUserGroupIds` + * (REQ-TMPL-013). + * @param IUserManager $userManager Nextcloud user manager. + * @param IGroupManager $groupManager Group manager for the admin + * break-glass bypass (mirrors + * ActionAuthService / + * PermissionService). + */ + public function __construct( + private readonly RoleFeaturePermissionMapper $permissionMapper, + private readonly RoleLayoutDefaultMapper $defaultMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly AdminSettingsService $adminSettings, + private readonly AdminTemplateService $adminTemplateService, + private readonly IUserManager $userManager, + private readonly IGroupManager $groupManager, + ) { + }//end __construct() + + /** + * List all RoleFeaturePermission rows for the admin UI. + * + * @return RoleFeaturePermission[] All rows. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function listPermissions(): array { + return $this->permissionMapper->findAll(); + }//end listPermissions() + + /** + * List all RoleLayoutDefault rows for the admin UI. + * + * @return RoleLayoutDefault[] All rows. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function listLayoutDefaults(): array { + return $this->defaultMapper->findAll(); + }//end listLayoutDefaults() + + /** + * Upsert a RoleFeaturePermission row keyed by `groupId`. + * + * @param array $data The submitted permission data. + * + * @return RoleFeaturePermission The persisted row. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function savePermission(array $data): RoleFeaturePermission { + $groupId = (string)($data['groupId'] ?? ''); + if ($groupId === '') { + throw new InvalidArgumentException(message: 'groupId is required'); + } + + $entity = $this->resolvePermissionEntity(groupId: $groupId); + + $this->applyPermissionCopy(entity: $entity, data: $data); + $this->applyPermissionWidgetLists(entity: $entity, data: $data); + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setUpdatedAt((new DateTime())->format(format: 'c')); + + // Entity::getId() can return null when the row hasn't been + // persisted yet (REQ-RFP-007 — upsert semantics). PHPStan's + // PHPDoc says `int` but the runtime allows null until insert. + // @phpstan-ignore-next-line identical.alwaysFalse — null on insert, ok. + if ($entity->getId() === null) { + return $this->permissionMapper->insert(entity: $entity); + } + + return $this->permissionMapper->update(entity: $entity); + }//end savePermission() + + /** + * Load the existing permission row for a group, or mint a fresh one. + * + * A miss is the insert half of the upsert (REQ-RFP-007): the new + * entity is stamped with `createdAt` and the group key so the caller + * only has to apply the submitted fields. + * + * @param string $groupId The group key. + * + * @return RoleFeaturePermission The existing or freshly minted row. + */ + private function resolvePermissionEntity(string $groupId): RoleFeaturePermission { + try { + return $this->permissionMapper->findByGroupId(groupId: $groupId); + } catch (DoesNotExistException $e) { + $entity = new RoleFeaturePermission(); + $now = (new DateTime())->format(format: 'c'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setCreatedAt($now); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGroupId($groupId); + return $entity; + } + }//end resolvePermissionEntity() + + /** + * Apply the human-facing name/description fields when submitted. + * + * Both use `array_key_exists` so an omitted key leaves the stored + * value alone while an explicit null clears the description. + * + * @param RoleFeaturePermission $entity The row being upserted. + * @param array $data The submitted permission data. + * + * @return void + */ + private function applyPermissionCopy( + RoleFeaturePermission $entity, + array $data, + ): void { + if (array_key_exists(key: 'name', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setName((string)$data['name']); + } + + if (array_key_exists(key: 'description', array: $data) === true) { + $description = null; + if ($data['description'] !== null) { + $description = (string)$data['description']; + } + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setDescription($description); + } + }//end applyPermissionCopy() + + /** + * Apply the JSON-encoded widget allow/deny lists and priority weights. + * + * The two widget lists are re-indexed with `array_values()` so they + * always encode as a JSON array; `priorityWeights` is a keyed map and + * is encoded as-is. + * + * @param RoleFeaturePermission $entity The row being upserted. + * @param array $data The submitted permission data. + * + * @return void + */ + private function applyPermissionWidgetLists( + RoleFeaturePermission $entity, + array $data, + ): void { + if (array_key_exists(key: 'allowedWidgets', array: $data) === true) { + $allowed = self::normaliseArrayPayload(data: $data, key: 'allowedWidgets'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setAllowedWidgets(json_encode(value: array_values(array: $allowed))); + } + + if (array_key_exists(key: 'deniedWidgets', array: $data) === true) { + $denied = self::normaliseArrayPayload(data: $data, key: 'deniedWidgets'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setDeniedWidgets(json_encode(value: array_values(array: $denied))); + } + + if (array_key_exists(key: 'priorityWeights', array: $data) === true) { + $weights = self::normaliseArrayPayload(data: $data, key: 'priorityWeights'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setPriorityWeights(json_encode(value: $weights)); + } + }//end applyPermissionWidgetLists() + + /** + * Read an array-typed payload key, defaulting a non-array to `[]`. + * + * Clients occasionally submit a scalar or null for a list field; the + * empty array keeps the JSON column well-formed instead of storing + * `"null"` or a coerced scalar. + * + * @param array $data The submitted data. + * @param string $key The key to read. + * + * @return array The array value, or [] when the value is not an array. + */ + private static function normaliseArrayPayload(array $data, string $key): array { + if (is_array(value: $data[$key]) === true) { + return $data[$key]; + } + + return []; + }//end normaliseArrayPayload() + + /** + * Delete a RoleFeaturePermission row by id. + * + * @param int $id The row id. + * + * @return void + * + * @throws DoesNotExistException When the row does not exist. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function deletePermission(int $id): void { + $entity = $this->permissionMapper->find(id: $id); + $this->permissionMapper->delete(entity: $entity); + }//end deletePermission() + + /** + * Upsert a RoleLayoutDefault row keyed by `(groupId, widgetId)`. + * + * @param array $data The submitted layout default data. + * + * @return RoleLayoutDefault The persisted row. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function saveLayoutDefault(array $data): RoleLayoutDefault { + $groupId = (string)($data['groupId'] ?? ''); + $widgetId = (string)($data['widgetId'] ?? ''); + if ($groupId === '' || $widgetId === '') { + throw new InvalidArgumentException( + message: 'groupId and widgetId are required' + ); + } + + $entity = $this->resolveLayoutDefaultEntity( + groupId: $groupId, + widgetId: $widgetId + ); + + $this->applyLayoutDefaultCopy(entity: $entity, data: $data); + $this->applyLayoutDefaultGeometry(entity: $entity, data: $data); + $this->applyLayoutDefaultOrdering(entity: $entity, data: $data); + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setUpdatedAt((new DateTime())->format(format: 'c')); + + // Same upsert semantics as above — null-on-insert tolerated. + // @phpstan-ignore-next-line identical.alwaysFalse — null on insert, ok. + if ($entity->getId() === null) { + return $this->defaultMapper->insert(entity: $entity); + } + + return $this->defaultMapper->update(entity: $entity); + }//end saveLayoutDefault() + + /** + * Load the existing layout default for a `(groupId, widgetId)` pair, + * or mint a fresh one. + * + * A miss is the insert half of the upsert: the new entity is stamped + * with `createdAt` and both key columns so the caller only has to + * apply the submitted fields. + * + * @param string $groupId The group key. + * @param string $widgetId The widget key. + * + * @return RoleLayoutDefault The existing or freshly minted row. + */ + private function resolveLayoutDefaultEntity( + string $groupId, + string $widgetId, + ): RoleLayoutDefault { + try { + return $this->defaultMapper->findByGroupAndWidget( + groupId: $groupId, + widgetId: $widgetId + ); + } catch (DoesNotExistException $e) { + $entity = new RoleLayoutDefault(); + $now = (new DateTime())->format(format: 'c'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setCreatedAt($now); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGroupId($groupId); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setWidgetId($widgetId); + return $entity; + } + }//end resolveLayoutDefaultEntity() + + /** + * Apply the human-facing name/description fields when submitted. + * + * Both use `array_key_exists` so an omitted key leaves the stored + * value alone while an explicit null clears the description. + * + * @param RoleLayoutDefault $entity The row being upserted. + * @param array $data The submitted layout default data. + * + * @return void + */ + private function applyLayoutDefaultCopy( + RoleLayoutDefault $entity, + array $data, + ): void { + if (array_key_exists(key: 'name', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setName((string)$data['name']); + } + + if (array_key_exists(key: 'description', array: $data) === true) { + $description = null; + if ($data['description'] !== null) { + $description = (string)$data['description']; + } + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setDescription($description); + } + }//end applyLayoutDefaultCopy() + + /** + * Apply the grid geometry fields when submitted. + * + * Width and height are floored at 1 — a zero or negative span would + * make the widget unrenderable on the grid. + * + * @param RoleLayoutDefault $entity The row being upserted. + * @param array $data The submitted layout default data. + * + * @return void + */ + private function applyLayoutDefaultGeometry( + RoleLayoutDefault $entity, + array $data, + ): void { + if (array_key_exists(key: 'gridX', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGridX((int)$data['gridX']); + } + + if (array_key_exists(key: 'gridY', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGridY((int)$data['gridY']); + } + + if (array_key_exists(key: 'gridWidth', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGridWidth(max(1, (int)$data['gridWidth'])); + } + + if (array_key_exists(key: 'gridHeight', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGridHeight(max(1, (int)$data['gridHeight'])); + } + }//end applyLayoutDefaultGeometry() + + /** + * Apply the ordering and compulsory-placement fields when submitted. + * + * `isCompulsory` is normalised to the canonical 0/1 column value so + * any truthy shape the client sends lands consistently. + * + * @param RoleLayoutDefault $entity The row being upserted. + * @param array $data The submitted layout default data. + * + * @return void + */ + private function applyLayoutDefaultOrdering( + RoleLayoutDefault $entity, + array $data, + ): void { + if (array_key_exists(key: 'sortOrder', array: $data) === true) { + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setSortOrder((int)$data['sortOrder']); + } + + if (array_key_exists(key: 'isCompulsory', array: $data) === true) { + $isCompulsory = 0; + if ((bool)$data['isCompulsory'] === true) { + $isCompulsory = 1; + } + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setIsCompulsory($isCompulsory); + } + }//end applyLayoutDefaultOrdering() + + /** + * Delete a RoleLayoutDefault row by id. + * + * @param int $id The row id. + * + * @return void + * + * @throws DoesNotExistException When the row does not exist. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function deleteLayoutDefault(int $id): void { + $entity = $this->defaultMapper->find(id: $id); + $this->defaultMapper->delete(entity: $entity); + }//end deleteLayoutDefault() + + /** + * Resolve the effective allowed-widget ID list for a user. + * + * Returns `null` (= no restriction, REQ-RFP-009) when none of the user's + * groups are mapped AND no `default` RoleFeaturePermission exists. + * + * Algorithm (REQ-RFP-005): + * 1. Walk the configured `group_order` array. + * 2. The FIRST group that matches BOTH the user's group memberships AND + * has a RoleFeaturePermission row provides the BASE allowed set. + * 3. ALL subsequent groups that match the user widen the allowed set + * via union. + * 4. ANY group's `deniedWidgets` removes those widget IDs from the + * final set (deny-wins). + * 5. If no `group_order` group matched, fall back to the row whose + * groupId == 'default' (REQ-RFP-009). + * + * @param string $userId The user's UID. + * + * @return array|null Sorted list of allowed widget IDs, or null. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function getAllowedWidgetIds(string $userId): ?array { + // Admin break-glass: Nextcloud admins are never restricted by the + // role-feature-permission allow-list (mirrors the admin short-circuit + // in ActionAuthService::requireAction and PermissionService:: + // resolveAccessLevel). Returning null signals "no restriction" so an + // admin can always add any widget to their own dashboard. + if ($this->groupManager->isAdmin(userId: $userId) === true) { + return null; + } + + $userGroups = $this->groupIdsForUser(userId: $userId); + if ($userGroups === []) { + return $this->fallbackAllowedWidgets(); + } + + $resolved = $this->resolveGroupOrderWidgets(userGroups: $userGroups); + if ($resolved === null) { + // No group_order match — try the explicit 'default' row. + return $this->fallbackAllowedWidgets(); + } + + $effective = array_values( + array: array_diff($resolved['allowed'], $resolved['denied']) + ); + sort(array: $effective); + return $effective; + }//end getAllowedWidgetIds() + + /** + * Walk the configured `group_order` and fold the matching user's + * RoleFeaturePermission rows into a base + union allow-set with a + * deny-wins overlay (REQ-RFP-005). Returns `null` when none of the + * user's `group_order` groups have a permission row, so the caller can + * fall back to the explicit `default` row. + * + * @param array $userGroups The user's group IDs. + * + * @return array{allowed: array, denied: array}|null The folded allow/deny + * sets, or null on no match. + */ + private function resolveGroupOrderWidgets(array $userGroups): ?array { + $groupOrder = $this->adminSettings->getGroupOrder(); + $base = null; + $allowed = []; + $denied = []; + + // Pre-fetch all RoleFeaturePermission rows for the user's groups (one query). + $rows = $this->permissionMapper->findByGroupIds(groupIds: $userGroups); + $byGid = []; + foreach ($rows as $row) { + $byGid[$row->getGroupId()] = $row; + } + + foreach ($groupOrder as $gid) { + $matchesUser = in_array(needle: $gid, haystack: $userGroups, strict: true); + if ($matchesUser === false || array_key_exists(key: $gid, array: $byGid) === false) { + continue; + } + + $row = $byGid[$gid]; + $rowAllow = $row->getAllowedWidgetsDecoded(); + $rowDeny = $row->getDeniedWidgetsDecoded(); + $isFirstMatch = ($base === null); + if ($isFirstMatch === true) { + $base = true; + $allowed = $rowAllow; + } + + if ($isFirstMatch === false) { + $allowed = array_values( + array: array_unique(array: array_merge($allowed, $rowAllow)) + ); + } + + $denied = array_values( + array: array_unique(array: array_merge($denied, $rowDeny)) + ); + }//end foreach + + if ($base === null) { + return null; + } + + return [ + 'allowed' => $allowed, + 'denied' => $denied, + ]; + }//end resolveGroupOrderWidgets() + + /** + * Check whether a specific widget is allowed for the given user. + * + * Returns `true` when the role configuration imposes no restriction on + * the user (i.e. `getAllowedWidgetIds()` returns `null`), or when the + * widget ID is explicitly included in the allowed set. + * + * @param string $userId The user's UID. + * @param string $widgetId The widget identifier to check. + * + * @return bool True when the widget is accessible to the user. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function isWidgetAllowed(string $userId, string $widgetId): bool { + // Admin break-glass — admins may add any widget regardless of role + // configuration (mirrors ActionAuthService / PermissionService). + if ($this->groupManager->isAdmin(userId: $userId) === true) { + return true; + } + + $allowed = $this->getAllowedWidgetIds(userId: $userId); + if ($allowed === null) { + return true; + } + + return in_array(needle: $widgetId, haystack: $allowed, strict: true); + }//end isWidgetAllowed() + + /** + * Seed the default layout for a freshly created dashboard from the + * RoleLayoutDefault rows attached to the user's primary group. + * + * No-op when the dashboard already has placements (REQ-RFP-002 scenario 3 + * — never overwrite personal customisations). + * + * Resolves the user's primary group by walking `group_order` and taking + * the first match that has at least one RoleLayoutDefault row. + * + * @param string $userId The user's UID. + * @param Dashboard $dashboard The dashboard to seed (must already exist). + * + * @return int The number of placements created (0 when no-op). + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function seedLayoutFromRoleDefaults(string $userId, Dashboard $dashboard): int { + $existing = $this->placementMapper->findByDashboardId( + dashboardId: $dashboard->getId() + ); + if (count(value: $existing) > 0) { + return 0; + } + + $userGroups = $this->groupIdsForUser(userId: $userId); + if ($userGroups === []) { + return 0; + } + + $groupOrder = $this->adminSettings->getGroupOrder(); + $defaults = []; + foreach ($groupOrder as $gid) { + if (in_array(needle: $gid, haystack: $userGroups, strict: true) === false) { + continue; + } + + $defaults = $this->defaultMapper->findByGroupId(groupId: $gid); + if (count(value: $defaults) > 0) { + break; + } + } + + if (count(value: $defaults) === 0) { + return 0; + } + + $created = 0; + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + foreach ($defaults as $default) { + $placement = new WidgetPlacement(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setDashboardId($dashboard->getId()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setWidgetId($default->getWidgetId()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setGridX($default->getGridX()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setGridY($default->getGridY()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setGridWidth($default->getGridWidth()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setGridHeight($default->getGridHeight()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setSortOrder($default->getSortOrder()); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setShowTitle(1); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setIsVisible(1); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setCreatedAt($now); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $placement->setUpdatedAt($now); + + $this->placementMapper->insert(entity: $placement); + $created++; + }//end foreach + + return $created; + }//end seedLayoutFromRoleDefaults() + + /** + * Look up `default` RoleFeaturePermission row when no user-group match + * occurred. Returns null when there is no `default` row. + * + * @return array|null The allowed widget list from the default row. + */ + private function fallbackAllowedWidgets(): ?array { + try { + $row = $this->permissionMapper->findByGroupId( + groupId: RoleFeaturePermission::GROUP_DEFAULT + ); + } catch (DoesNotExistException $e) { + return null; + } + + $allowed = $row->getAllowedWidgetsDecoded(); + $denied = $row->getDeniedWidgetsDecoded(); + $eff = array_values(array: array_diff($allowed, $denied)); + sort(array: $eff); + return $eff; + }//end fallbackAllowedWidgets() + + /** + * Pull the list of group IDs a user belongs to. Wraps `IGroupManager`. + * + * @param string $userId The user UID. + * + * @return array The user's group IDs (may be empty). + */ + private function groupIdsForUser(string $userId): array { + // REQ-TMPL-013: the routing resolver invariant requires every + // `getUserGroupIds(...)` call to live inside AdminTemplateService. + // Delegating here keeps the role-feature-permission resolver + // honest with the grep guard while still letting the service + // make a per-user group-membership decision. + $user = $this->userManager->get(uid: $userId); + if ($user === null) { + return []; + } + + return $this->adminTemplateService->getUserGroupIdsFor(userId: $userId); + }//end groupIdsForUser() }//end class diff --git a/lib/Service/RoleService.php b/lib/Service/RoleService.php index 8fbe2346a..15b9d53c5 100644 --- a/lib/Service/RoleService.php +++ b/lib/Service/RoleService.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); @@ -46,377 +46,409 @@ /** * Role assignment service (REQ-ROLE-001..011). - * - * @SuppressWarnings(PHPMD.TooManyPublicMethods) Validation, resolution, - * CRUD and cascade methods - * belong on a single - * cohesive role service. */ -class RoleService -{ - /** - * Constructor - * - * @param RoleAssignmentMapper $mapper Persistence mapper. - * @param IUserManager $userManager Nextcloud user manager. - * @param IGroupManager $groupManager Nextcloud group manager - * (used only for `isAdmin` - * and `groupExists` — - * group-membership lookups - * go through the routing - * resolver per - * REQ-TMPL-013). - * @param AdminTemplateService $adminTemplateService Routing resolver — the - * single source of truth - * for `getUserGroupIds`. - */ - public function __construct( - private readonly RoleAssignmentMapper $mapper, - private readonly IUserManager $userManager, - private readonly IGroupManager $groupManager, - private readonly AdminTemplateService $adminTemplateService, - ) { - }//end __construct() - - /** - * Resolve the effective LaunchPad role for a user (REQ-ROLE-005). - * - * @param string $userId The Nextcloud user ID. - * - * @return string|null The role string, or null when no role applies. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function getEffectiveRole(string $userId): ?string - { - if ($this->groupManager->isAdmin(userId: $userId) === true) { - return RoleAssignment::ROLE_ADMIN; - } - - $direct = $this->mapper->findByUser(userId: $userId); - if (count($direct) > 0) { - return $this->highestRole(assignments: $direct); - } - - $groupIds = $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ); - - $groupAssignments = $this->mapper->findByGroupIds(groupIds: $groupIds); - if (count($groupAssignments) === 0) { - return null; - } - - return $this->highestRole(assignments: $groupAssignments); - }//end getEffectiveRole() - - /** - * Resolve the source of a user's effective role (REQ-ROLE-006). - * - * Returns "nc-admin", "user-assigned", "group-assigned:{groupId}", - * or null when no role applies. - * - * @param string $userId The Nextcloud user ID. - * - * @return string|null The source identifier, or null when no role. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function getRoleSource(string $userId): ?string - { - if ($this->groupManager->isAdmin(userId: $userId) === true) { - return RoleAssignment::SOURCE_NC_ADMIN; - } - - $direct = $this->mapper->findByUser(userId: $userId); - if (count($direct) > 0) { - return RoleAssignment::SOURCE_USER_ASSIGNED; - } - - $groupIds = $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ); - - $groupAssignments = $this->mapper->findByGroupIds(groupIds: $groupIds); - if (count($groupAssignments) === 0) { - return null; - } - - $winning = $this->highestAssignment(assignments: $groupAssignments); - - return RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX.(string) $winning->getGroupId(); - }//end getRoleSource() - - /** - * Validate a role string against the canonical enum (REQ-ROLE-001..003). - * - * @param string $role The candidate role. - * - * @return void - * - * @throws InvalidRoleAssignmentException When the role is unknown. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function validateRole(string $role): void - { - if (in_array( - needle: $role, - haystack: RoleAssignment::VALID_ROLES, - strict: true - ) === false - ) { - throw new InvalidRoleAssignmentException( - message: 'Unknown role; must be one of admin, editor, viewer' - ); - } - }//end validateRole() - - /** - * Validate the user/group XOR target plus existence in Nextcloud - * (REQ-ROLE-004). - * - * @param string|null $userId Candidate user ID. - * @param string|null $groupId Candidate group ID. - * - * @return void - * - * @throws InvalidRoleAssignmentException On any structural failure. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function validateTarget(?string $userId, ?string $groupId): void - { - $hasUser = ($userId !== null && $userId !== ''); - $hasGroup = ($groupId !== null && $groupId !== ''); - - if ($hasUser === false && $hasGroup === false) { - throw new InvalidRoleAssignmentException( - message: 'Either userId or groupId must be provided' - ); - } - - if ($hasUser === true && $hasGroup === true) { - throw new InvalidRoleAssignmentException( - message: 'Only one of userId or groupId may be provided' - ); - } - - if ($hasUser === true && $this->userManager->userExists(uid: $userId) === false) { - throw new InvalidRoleAssignmentException( - message: 'Unknown user' - ); - } - - if ($hasGroup === true && $this->groupManager->groupExists(gid: $groupId) === false) { - throw new InvalidRoleAssignmentException( - message: 'Unknown group' - ); - } - }//end validateTarget() - - /** - * Create a new role assignment (REQ-ROLE-004). - * - * @param string|null $userId The user ID, or null for a group assignment. - * @param string|null $groupId The group ID, or null for a user assignment. - * @param string $role The role name. - * @param string $assignedBy The acting admin's user ID. - * - * @return RoleAssignment The persisted assignment with its generated ID. - * - * @throws InvalidRoleAssignmentException On structural failures. - * @throws DuplicateRoleAssignmentException When the (target, role) pair exists. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function assignRole( - ?string $userId, - ?string $groupId, - string $role, - string $assignedBy - ): RoleAssignment { - $this->validateRole(role: $role); - $this->validateTarget(userId: $userId, groupId: $groupId); - - if ($userId !== null && $userId !== '' - && $this->mapper->findUserRole(userId: $userId, role: $role) !== null - ) { - throw new DuplicateRoleAssignmentException(); - } - - if ($groupId !== null && $groupId !== '' - && $this->mapper->findGroupRole(groupId: $groupId, role: $role) !== null - ) { - throw new DuplicateRoleAssignmentException(); - } - - $assignment = new RoleAssignment(); - // Entity setters MUST receive positional args — Entity::__call - // forwards $args[0] which means named args would be misinterpreted. - // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $assignment->setUserId($userId); - $assignment->setGroupId($groupId); - $assignment->setRole($role); - $assignment->setAssignedBy($assignedBy); - $assignment->setAssignedAt((new DateTime())->format('c')); - // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - - return $this->mapper->insert(entity: $assignment); - }//end assignRole() - - /** - * Remove a role assignment by ID (REQ-ROLE-004). - * - * @param int $id The assignment ID. - * - * @return void - * - * @throws DoesNotExistException When no row matches the given ID. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function removeRole(int $id): void - { - $affected = $this->mapper->deleteById(id: $id); - if ($affected === 0) { - throw new DoesNotExistException(msg: 'Role assignment not found'); - } - }//end removeRole() - - /** - * List every role assignment in the system (REQ-ROLE-006 admin listing). - * - * @return RoleAssignment[] Every persisted assignment. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function listAssignments(): array - { - return $this->mapper->findAll(); - }//end listAssignments() - - /** - * Cascade entry point invoked by the user-deletion listener - * (REQ-ROLE-010). - * - * @param string $userId The deleted user's UID. - * - * @return int The number of rows removed. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function deleteByUserId(string $userId): int - { - return $this->mapper->deleteByUserId(userId: $userId); - }//end deleteByUserId() - - /** - * Cascade entry point invoked by the group-deletion listener - * (REQ-ROLE-011). - * - * @param string $groupId The deleted group's GID. - * - * @return int The number of rows removed. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function deleteByGroupId(string $groupId): int - { - return $this->mapper->deleteByGroupId(groupId: $groupId); - }//end deleteByGroupId() - - /** - * Whether the user's effective role is "admin" (REQ-ROLE-001). - * - * @param string $userId The user ID. - * - * @return bool True for admin role. - */ - public function isAdmin(string $userId): bool - { - return $this->getEffectiveRole(userId: $userId) === RoleAssignment::ROLE_ADMIN; - }//end isAdmin() - - /** - * Whether the user's effective role is editor or higher (REQ-ROLE-002). - * - * @param string $userId The user ID. - * - * @return bool True for editor or admin. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function isEditorOrHigher(string $userId): bool - { - $role = $this->getEffectiveRole(userId: $userId); - - return $role === RoleAssignment::ROLE_EDITOR - || $role === RoleAssignment::ROLE_ADMIN; - }//end isEditorOrHigher() - - /** - * Whether the user is explicitly Viewer (REQ-ROLE-008 mutation guard). - * - * @param string $userId The user ID. - * - * @return bool True when the effective role is "viewer". - */ - public function isViewer(string $userId): bool - { - return $this->getEffectiveRole(userId: $userId) === RoleAssignment::ROLE_VIEWER; - }//end isViewer() - - /** - * Whether the user can mutate dashboard structure (REQ-ROLE-008). - * - * Returns false only for users whose effective role is explicitly - * "viewer". Users with no assignment fall back to true so the existing - * permissions capability stays the source of truth. - * - * @param string $userId The user ID. - * - * @return bool False when the user has the Viewer role. - * - * @spec openspec/specs/admin-roles/spec.md - */ - public function canMutate(string $userId): bool - { - return $this->isViewer(userId: $userId) === false; - }//end canMutate() - - /** - * Pick the highest-ranked assignment from a non-empty list. - * - * @param RoleAssignment[] $assignments The candidate rows. - * - * @return RoleAssignment The winning assignment. - */ - private function highestAssignment(array $assignments): RoleAssignment - { - $winner = $assignments[0]; - $bestRank = RoleAssignment::ROLE_RANKS[(string) $winner->getRole()] ?? -1; - - foreach ($assignments as $candidate) { - $rank = RoleAssignment::ROLE_RANKS[(string) $candidate->getRole()] ?? -1; - if ($rank > $bestRank) { - $winner = $candidate; - $bestRank = $rank; - } - } - - return $winner; - }//end highestAssignment() - - /** - * Return the highest-ranked role string from a non-empty list. - * - * @param RoleAssignment[] $assignments The candidate rows. - * - * @return string The winning role name. - */ - private function highestRole(array $assignments): string - { - return (string) $this->highestAssignment(assignments: $assignments)->getRole(); - }//end highestRole() +class RoleService { + /** + * Constructor + * + * @param RoleAssignmentMapper $mapper Persistence mapper. + * @param IUserManager $userManager Nextcloud user manager. + * @param IGroupManager $groupManager Nextcloud group manager + * (used only for `isAdmin` + * and `groupExists` — + * group-membership lookups + * go through the routing + * resolver per + * REQ-TMPL-013). + * @param AdminTemplateService $adminTemplateService Routing resolver — the + * single source of truth + * for `getUserGroupIds`. + */ + public function __construct( + private readonly RoleAssignmentMapper $mapper, + private readonly IUserManager $userManager, + private readonly IGroupManager $groupManager, + private readonly AdminTemplateService $adminTemplateService, + ) { + }//end __construct() + + /** + * Resolve the effective LaunchPad role for a user (REQ-ROLE-005). + * + * @param string $userId The Nextcloud user ID. + * + * @return string|null The role string, or null when no role applies. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function getEffectiveRole(string $userId): ?string { + if ($this->groupManager->isAdmin(userId: $userId) === true) { + return RoleAssignment::ROLE_ADMIN; + } + + $direct = $this->mapper->findByUser(userId: $userId); + if (count($direct) > 0) { + return $this->highestRole(assignments: $direct); + } + + $groupIds = $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ); + + $groupAssignments = $this->mapper->findByGroupIds(groupIds: $groupIds); + if (count($groupAssignments) === 0) { + return null; + } + + return $this->highestRole(assignments: $groupAssignments); + }//end getEffectiveRole() + + /** + * Resolve the source of a user's effective role (REQ-ROLE-006). + * + * Returns "nc-admin", "user-assigned", "group-assigned:{groupId}", + * or null when no role applies. + * + * @param string $userId The Nextcloud user ID. + * + * @return string|null The source identifier, or null when no role. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function getRoleSource(string $userId): ?string { + if ($this->groupManager->isAdmin(userId: $userId) === true) { + return RoleAssignment::SOURCE_NC_ADMIN; + } + + $direct = $this->mapper->findByUser(userId: $userId); + if (count($direct) > 0) { + return RoleAssignment::SOURCE_USER_ASSIGNED; + } + + $groupIds = $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ); + + $groupAssignments = $this->mapper->findByGroupIds(groupIds: $groupIds); + if (count($groupAssignments) === 0) { + return null; + } + + $winning = $this->highestAssignment(assignments: $groupAssignments); + + return RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX . (string)$winning->getGroupId(); + }//end getRoleSource() + + /** + * Validate a role string against the canonical enum (REQ-ROLE-001..003). + * + * @param string $role The candidate role. + * + * @return void + * + * @throws InvalidRoleAssignmentException When the role is unknown. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function validateRole(string $role): void { + if (in_array( + needle: $role, + haystack: RoleAssignment::VALID_ROLES, + strict: true + ) === false + ) { + throw new InvalidRoleAssignmentException( + message: 'Unknown role; must be one of admin, editor, viewer' + ); + } + }//end validateRole() + + /** + * Validate the user/group XOR target plus existence in Nextcloud + * (REQ-ROLE-004). + * + * @param string|null $userId Candidate user ID. + * @param string|null $groupId Candidate group ID. + * + * @return void + * + * @throws InvalidRoleAssignmentException On any structural failure. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function validateTarget(?string $userId, ?string $groupId): void { + $hasUser = ($userId !== null && $userId !== ''); + $hasGroup = ($groupId !== null && $groupId !== ''); + + $this->assertExactlyOneTarget(hasUser: $hasUser, hasGroup: $hasGroup); + $this->assertUserExists(userId: $userId); + $this->assertGroupExists(groupId: $groupId); + }//end validateTarget() + + /** + * Assert that exactly one of the user/group targets was supplied. + * + * @param bool $hasUser Whether a non-empty user ID was supplied. + * @param bool $hasGroup Whether a non-empty group ID was supplied. + * + * @return void + * + * @throws InvalidRoleAssignmentException When neither or both are set. + */ + private function assertExactlyOneTarget(bool $hasUser, bool $hasGroup): void { + if ($hasUser === false && $hasGroup === false) { + throw new InvalidRoleAssignmentException( + message: 'Either userId or groupId must be provided' + ); + } + + if ($hasUser === true && $hasGroup === true) { + throw new InvalidRoleAssignmentException( + message: 'Only one of userId or groupId may be provided' + ); + } + }//end assertExactlyOneTarget() + + /** + * Assert that a supplied user ID resolves to a real Nextcloud user. + * + * A null/empty user ID is a group assignment and is left alone — the + * XOR check has already run by the time this is called. + * + * @param string|null $userId Candidate user ID. + * + * @return void + * + * @throws InvalidRoleAssignmentException When the user does not exist. + */ + private function assertUserExists(?string $userId): void { + if ($userId === null || $userId === '') { + return; + } + + if ($this->userManager->userExists(uid: $userId) === false) { + throw new InvalidRoleAssignmentException( + message: 'Unknown user' + ); + } + }//end assertUserExists() + + /** + * Assert that a supplied group ID resolves to a real Nextcloud group. + * + * A null/empty group ID is a user assignment and is left alone — the + * XOR check has already run by the time this is called. + * + * @param string|null $groupId Candidate group ID. + * + * @return void + * + * @throws InvalidRoleAssignmentException When the group does not exist. + */ + private function assertGroupExists(?string $groupId): void { + if ($groupId === null || $groupId === '') { + return; + } + + if ($this->groupManager->groupExists(gid: $groupId) === false) { + throw new InvalidRoleAssignmentException( + message: 'Unknown group' + ); + } + }//end assertGroupExists() + + /** + * Create a new role assignment (REQ-ROLE-004). + * + * @param string|null $userId The user ID, or null for a group assignment. + * @param string|null $groupId The group ID, or null for a user assignment. + * @param string $role The role name. + * @param string $assignedBy The acting admin's user ID. + * + * @return RoleAssignment The persisted assignment with its generated ID. + * + * @throws InvalidRoleAssignmentException On structural failures. + * @throws DuplicateRoleAssignmentException When the (target, role) pair exists. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function assignRole( + ?string $userId, + ?string $groupId, + string $role, + string $assignedBy, + ): RoleAssignment { + $this->validateRole(role: $role); + $this->validateTarget(userId: $userId, groupId: $groupId); + + if ($userId !== null && $userId !== '' + && $this->mapper->findUserRole(userId: $userId, role: $role) !== null + ) { + throw new DuplicateRoleAssignmentException(); + } + + if ($groupId !== null && $groupId !== '' + && $this->mapper->findGroupRole(groupId: $groupId, role: $role) !== null + ) { + throw new DuplicateRoleAssignmentException(); + } + + $assignment = new RoleAssignment(); + // Entity setters MUST receive positional args — Entity::__call + // forwards $args[0] which means named args would be misinterpreted. + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $assignment->setUserId($userId); + $assignment->setGroupId($groupId); + $assignment->setRole($role); + $assignment->setAssignedBy($assignedBy); + $assignment->setAssignedAt((new DateTime())->format('c')); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + + return $this->mapper->insert(entity: $assignment); + }//end assignRole() + + /** + * Remove a role assignment by ID (REQ-ROLE-004). + * + * @param int $id The assignment ID. + * + * @return void + * + * @throws DoesNotExistException When no row matches the given ID. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function removeRole(int $id): void { + $affected = $this->mapper->deleteById(id: $id); + if ($affected === 0) { + throw new DoesNotExistException(msg: 'Role assignment not found'); + } + }//end removeRole() + + /** + * List every role assignment in the system (REQ-ROLE-006 admin listing). + * + * @return RoleAssignment[] Every persisted assignment. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function listAssignments(): array { + return $this->mapper->findAll(); + }//end listAssignments() + + /** + * Cascade entry point invoked by the user-deletion listener + * (REQ-ROLE-010). + * + * @param string $userId The deleted user's UID. + * + * @return int The number of rows removed. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function deleteByUserId(string $userId): int { + return $this->mapper->deleteByUserId(userId: $userId); + }//end deleteByUserId() + + /** + * Cascade entry point invoked by the group-deletion listener + * (REQ-ROLE-011). + * + * @param string $groupId The deleted group's GID. + * + * @return int The number of rows removed. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function deleteByGroupId(string $groupId): int { + return $this->mapper->deleteByGroupId(groupId: $groupId); + }//end deleteByGroupId() + + /** + * Whether the user's effective role is "admin" (REQ-ROLE-001). + * + * @param string $userId The user ID. + * + * @return bool True for admin role. + */ + public function isAdmin(string $userId): bool { + return $this->getEffectiveRole(userId: $userId) === RoleAssignment::ROLE_ADMIN; + }//end isAdmin() + + /** + * Whether the user's effective role is editor or higher (REQ-ROLE-002). + * + * @param string $userId The user ID. + * + * @return bool True for editor or admin. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function isEditorOrHigher(string $userId): bool { + $role = $this->getEffectiveRole(userId: $userId); + + return $role === RoleAssignment::ROLE_EDITOR + || $role === RoleAssignment::ROLE_ADMIN; + }//end isEditorOrHigher() + + /** + * Whether the user is explicitly Viewer (REQ-ROLE-008 mutation guard). + * + * @param string $userId The user ID. + * + * @return bool True when the effective role is "viewer". + */ + public function isViewer(string $userId): bool { + return $this->getEffectiveRole(userId: $userId) === RoleAssignment::ROLE_VIEWER; + }//end isViewer() + + /** + * Whether the user can mutate dashboard structure (REQ-ROLE-008). + * + * Returns false only for users whose effective role is explicitly + * "viewer". Users with no assignment fall back to true so the existing + * permissions capability stays the source of truth. + * + * @param string $userId The user ID. + * + * @return bool False when the user has the Viewer role. + * + * @spec openspec/specs/admin-roles/spec.md + */ + public function canMutate(string $userId): bool { + return $this->isViewer(userId: $userId) === false; + }//end canMutate() + + /** + * Pick the highest-ranked assignment from a non-empty list. + * + * @param RoleAssignment[] $assignments The candidate rows. + * + * @return RoleAssignment The winning assignment. + */ + private function highestAssignment(array $assignments): RoleAssignment { + $winner = $assignments[0]; + $bestRank = RoleAssignment::ROLE_RANKS[(string)$winner->getRole()] ?? -1; + + foreach ($assignments as $candidate) { + $rank = RoleAssignment::ROLE_RANKS[(string)$candidate->getRole()] ?? -1; + if ($rank > $bestRank) { + $winner = $candidate; + $bestRank = $rank; + } + } + + return $winner; + }//end highestAssignment() + + /** + * Return the highest-ranked role string from a non-empty list. + * + * @param RoleAssignment[] $assignments The candidate rows. + * + * @return string The winning role name. + */ + private function highestRole(array $assignments): string { + return (string)$this->highestAssignment(assignments: $assignments)->getRole(); + }//end highestRole() }//end class diff --git a/lib/Service/RuleEvaluatorService.php b/lib/Service/RuleEvaluatorService.php index 44b64d4f3..4dc4f355e 100644 --- a/lib/Service/RuleEvaluatorService.php +++ b/lib/Service/RuleEvaluatorService.php @@ -19,199 +19,243 @@ namespace OCA\LaunchPad\Service; use DateTime; +use DateTimeInterface; use OCA\LaunchPad\Db\ConditionalRule; /** * Service for evaluating conditional rules against user context. */ -class RuleEvaluatorService -{ - /** - * Constructor - * - * @param AdminTemplateService $adminTemplateService Routing resolver — single - * source of truth for - * `IGroupManager::getUserGroupIds` - * (REQ-TMPL-013). - * @param UserAttributeResolver $attrResolver The attribute resolver. - */ - public function __construct( - private readonly AdminTemplateService $adminTemplateService, - private readonly UserAttributeResolver $attrResolver, - ) { - }//end __construct() - - /** - * Evaluate a single rule. - * - * Dispatcher for all rule types — group (REQ-VIS-005), time - * (REQ-VIS-006), date (REQ-VIS-007) and attribute (REQ-VIS-008) rules - * are all evaluated through private helpers below. Public surface is - * tagged against the dispatch Requirement (REQ-VIS-010). - * - * @param ConditionalRule $rule The rule to evaluate. - * @param string $userId The user ID. - * - * @return bool Whether the rule matches. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-14 - */ - public function evaluateRule( - ConditionalRule $rule, - string $userId - ): bool { - return match ($rule->getRuleType()) { - ConditionalRule::TYPE_GROUP => $this->evaluateGroupRule( - rule: $rule, - userId: $userId - ), - ConditionalRule::TYPE_TIME => $this->evaluateTimeRule( - rule: $rule - ), - ConditionalRule::TYPE_DATE => $this->evaluateDateRule( - rule: $rule - ), - ConditionalRule::TYPE_ATTRIBUTE => $this->evaluateAttributeRule( - rule: $rule, - userId: $userId - ), - default => false, - }; - }//end evaluateRule() - - /** - * Evaluate a group-based rule. - * Config: { "groups": ["admin", "editors"] }. - * - * @param ConditionalRule $rule The rule to evaluate. - * @param string $userId The user ID. - * - * @return bool Whether the rule matches. - */ - private function evaluateGroupRule( - ConditionalRule $rule, - string $userId - ): bool { - $config = $rule->getRuleConfigArray(); - $targetGroups = $config['groups'] ?? []; - - if (empty($targetGroups) === true) { - return false; - } - - // Group memberships are read through the routing resolver so the - // single-source-of-truth invariant (REQ-TMPL-013) holds. - $userGroups = $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ); - if ($userGroups === []) { - return false; - } - - return empty(array_intersect($userGroups, $targetGroups)) === false; - }//end evaluateGroupRule() - - /** - * Evaluate a time-based rule. - * Config: { "startTime": "09:00", "endTime": "17:00", "days": ["mon"] }. - * - * @param ConditionalRule $rule The rule to evaluate. - * - * @return bool Whether the rule matches. - */ - private function evaluateTimeRule(ConditionalRule $rule): bool - { - $config = $rule->getRuleConfigArray(); - - $now = new DateTime(); - $currentTime = $now->format(format: 'H:i'); - $currentDay = strtolower(string: $now->format(format: 'D')); - - // Check day of week. - if (isset($config['days']) === true - && is_array($config['days']) === true - ) { - if (in_array( - needle: $currentDay, - haystack: $config['days'] - ) === false - ) { - return false; - } - } - - // Check time range. - $startTime = $config['startTime'] ?? '00:00'; - $endTime = $config['endTime'] ?? '23:59'; - - return $currentTime >= $startTime && $currentTime <= $endTime; - }//end evaluateTimeRule() - - /** - * Evaluate a date-based rule. - * Config: { "startDate": "2024-01-01", "endDate": "2024-12-31" }. - * - * @param ConditionalRule $rule The rule to evaluate. - * - * @return bool Whether the rule matches. - */ - private function evaluateDateRule(ConditionalRule $rule): bool - { - $config = $rule->getRuleConfigArray(); - - $now = new DateTime(); - $currentDate = $now->format(format: 'Y-m-d'); - - $startDate = $config['startDate'] ?? null; - $endDate = $config['endDate'] ?? null; - - if ($startDate !== null && $currentDate < $startDate) { - return false; - } - - if ($endDate !== null && $currentDate > $endDate) { - return false; - } - - return true; - }//end evaluateDateRule() - - /** - * Evaluate an attribute-based rule. - * Config: { "attribute": "locale", "operator": "equals", "value": "nl" }. - * - * @param ConditionalRule $rule The rule to evaluate. - * @param string $userId The user ID. - * - * @return bool Whether the rule matches. - */ - private function evaluateAttributeRule( - ConditionalRule $rule, - string $userId - ): bool { - $config = $rule->getRuleConfigArray(); - - $attribute = $config['attribute'] ?? null; - $operator = $config['operator'] ?? 'equals'; - $value = $config['value'] ?? null; - - if ($attribute === null) { - return false; - } - - $userValue = $this->attrResolver->getUserAttributeValue( - userId: $userId, - attribute: $attribute - ); - - if ($userValue === null) { - return false; - } - - return $this->attrResolver->evaluateOperator( - userValue: $userValue, - operator: $operator, - value: $value - ); - }//end evaluateAttributeRule() +class RuleEvaluatorService { + /** + * Constructor + * + * @param AdminTemplateService $adminTemplateService Routing resolver — single + * source of truth for + * `IGroupManager::getUserGroupIds` + * (REQ-TMPL-013). + * @param UserAttributeResolver $attrResolver The attribute resolver. + */ + public function __construct( + private readonly AdminTemplateService $adminTemplateService, + private readonly UserAttributeResolver $attrResolver, + ) { + }//end __construct() + + /** + * Evaluate a single rule. + * + * Dispatcher for all rule types — group (REQ-VIS-005), time + * (REQ-VIS-006), date (REQ-VIS-007) and attribute (REQ-VIS-008) rules + * are all evaluated through private helpers below. Public surface is + * tagged against the dispatch Requirement (REQ-VIS-010). + * + * `$groupsOverride` / `$nowOverride` are optional context injections + * consumed ONLY by the read-only preview path + * (conditional-visibility-editor spec, REQ-CVUI-005 — + * `VisibilityPreviewController` via `ConditionalService::previewRules()` + * / `VisibilityChecker::evaluateRuleSet()`). Render-time callers + * (`ConditionalService::checkRulesForPlacement()`) never pass them, so + * behaviour for the existing call sites is byte-for-byte unchanged: + * group rules keep resolving the live user's group memberships and + * time/date rules keep using the server clock. + * + * @param ConditionalRule $rule The rule to evaluate. + * @param string $userId The user ID. + * @param string[]|null $groupsOverride When non-null, used + * instead of the live + * user's group + * memberships for + * `group` rules. + * @param DateTimeInterface|null $nowOverride When non-null, used + * instead of the server + * clock for `time` / + * `date` rules. + * + * @return bool Whether the rule matches. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-14 + * @spec openspec/specs/conditional-visibility-editor/spec.md#requirement-req-cvui-005-preview-endpoint-reuses-the-render-time-evaluation-path-and-never-persists + */ + public function evaluateRule( + ConditionalRule $rule, + string $userId, + ?array $groupsOverride = null, + ?DateTimeInterface $nowOverride = null, + ): bool { + return match ($rule->getRuleType()) { + ConditionalRule::TYPE_GROUP => $this->evaluateGroupRule( + rule: $rule, + userId: $userId, + groupsOverride: $groupsOverride + ), + ConditionalRule::TYPE_TIME => $this->evaluateTimeRule( + rule: $rule, + nowOverride: $nowOverride + ), + ConditionalRule::TYPE_DATE => $this->evaluateDateRule( + rule: $rule, + nowOverride: $nowOverride + ), + ConditionalRule::TYPE_ATTRIBUTE => $this->evaluateAttributeRule( + rule: $rule, + userId: $userId + ), + default => false, + }; + }//end evaluateRule() + + /** + * Evaluate a group-based rule. + * Config: { "groups": ["admin", "editors"] }. + * + * @param ConditionalRule $rule The rule to evaluate. + * @param string $userId The user ID. + * @param string[]|null $groupsOverride When non-null, the group set to + * test instead of the live user's + * memberships (preview only). + * + * @return bool Whether the rule matches. + */ + private function evaluateGroupRule( + ConditionalRule $rule, + string $userId, + ?array $groupsOverride = null, + ): bool { + $config = $rule->getRuleConfigArray(); + $targetGroups = $config['groups'] ?? []; + + if (empty($targetGroups) === true) { + return false; + } + + // Group memberships are read through the routing resolver so the + // single-source-of-truth invariant (REQ-TMPL-013) holds, UNLESS a + // preview context supplied an explicit group set to test. + $userGroups = $groupsOverride; + if ($userGroups === null) { + $userGroups = $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ); + } + + if ($userGroups === []) { + return false; + } + + return empty(array_intersect($userGroups, $targetGroups)) === false; + }//end evaluateGroupRule() + + /** + * Evaluate a time-based rule. + * Config: { "startTime": "09:00", "endTime": "17:00", "days": ["mon"] }. + * + * @param ConditionalRule $rule The rule to evaluate. + * @param DateTimeInterface|null $nowOverride When non-null, the moment to + * test instead of the server + * clock (preview only). + * + * @return bool Whether the rule matches. + */ + private function evaluateTimeRule( + ConditionalRule $rule, + ?DateTimeInterface $nowOverride = null, + ): bool { + $config = $rule->getRuleConfigArray(); + + $now = $nowOverride ?? new DateTime(); + $currentTime = $now->format(format: 'H:i'); + $currentDay = strtolower(string: $now->format(format: 'D')); + + // Check day of week. + if (isset($config['days']) === true + && is_array($config['days']) === true + ) { + if (in_array( + needle: $currentDay, + haystack: $config['days'] + ) === false + ) { + return false; + } + } + + // Check time range. + $startTime = $config['startTime'] ?? '00:00'; + $endTime = $config['endTime'] ?? '23:59'; + + return $currentTime >= $startTime && $currentTime <= $endTime; + }//end evaluateTimeRule() + + /** + * Evaluate a date-based rule. + * Config: { "startDate": "2024-01-01", "endDate": "2024-12-31" }. + * + * @param ConditionalRule $rule The rule to evaluate. + * @param DateTimeInterface|null $nowOverride When non-null, the moment to + * test instead of the server + * clock (preview only). + * + * @return bool Whether the rule matches. + */ + private function evaluateDateRule( + ConditionalRule $rule, + ?DateTimeInterface $nowOverride = null, + ): bool { + $config = $rule->getRuleConfigArray(); + + $now = $nowOverride ?? new DateTime(); + $currentDate = $now->format(format: 'Y-m-d'); + + $startDate = $config['startDate'] ?? null; + $endDate = $config['endDate'] ?? null; + + if ($startDate !== null && $currentDate < $startDate) { + return false; + } + + if ($endDate !== null && $currentDate > $endDate) { + return false; + } + + return true; + }//end evaluateDateRule() + + /** + * Evaluate an attribute-based rule. + * Config: { "attribute": "locale", "operator": "equals", "value": "nl" }. + * + * @param ConditionalRule $rule The rule to evaluate. + * @param string $userId The user ID. + * + * @return bool Whether the rule matches. + */ + private function evaluateAttributeRule( + ConditionalRule $rule, + string $userId, + ): bool { + $config = $rule->getRuleConfigArray(); + + $attribute = $config['attribute'] ?? null; + $operator = $config['operator'] ?? 'equals'; + $value = $config['value'] ?? null; + + if ($attribute === null) { + return false; + } + + $userValue = $this->attrResolver->getUserAttributeValue( + userId: $userId, + attribute: $attribute + ); + + if ($userValue === null) { + return false; + } + + return $this->attrResolver->evaluateOperator( + userValue: $userValue, + operator: $operator, + value: $value + ); + }//end evaluateAttributeRule() }//end class diff --git a/lib/Service/SetupWizardService.php b/lib/Service/SetupWizardService.php index 4c9f76f5e..e3ac48d26 100644 --- a/lib/Service/SetupWizardService.php +++ b/lib/Service/SetupWizardService.php @@ -16,8 +16,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); @@ -38,236 +38,227 @@ * the optional steps 5/6 — when their guarded capability isn't available, * the heuristic returns `'skipped'` so the UI can collapse the step. */ -class SetupWizardService -{ - /** - * Total number of wizard steps (REQ-WIZ-002). - * - * @var integer - */ - public const STEP_COUNT = 7; +class SetupWizardService { + /** + * Total number of wizard steps (REQ-WIZ-002). + * + * @var integer + */ + public const STEP_COUNT = 7; - /** - * Storage backend value: relational database (default). - * - * @var string - */ - public const STORAGE_DATABASE = 'database'; + /** + * Storage backend value: relational database (default). + * + * @var string + */ + public const STORAGE_DATABASE = 'database'; - /** - * Storage backend value: GroupFolder app. - * - * @var string - */ - public const STORAGE_GROUPFOLDER = 'groupfolder'; + /** + * Storage backend value: GroupFolder app. + * + * @var string + */ + public const STORAGE_GROUPFOLDER = 'groupfolder'; - /** - * GroupFolder dependency app id used by Step 2's tooltip gate. - * - * @var string - */ - public const GROUPFOLDER_APP_ID = 'groupfolders'; + /** + * GroupFolder dependency app id used by Step 2's tooltip gate. + * + * @var string + */ + public const GROUPFOLDER_APP_ID = 'groupfolders'; - /** - * Constructor. - * - * @param AdminSettingMapper $settingMapper Admin-setting persistence. - * @param IAppManager $appManager Used to detect the optional - * `groupfolders` Nextcloud - * app for Step 2's gate. - */ - public function __construct( - private readonly AdminSettingMapper $settingMapper, - private readonly IAppManager $appManager, - ) { - }//end __construct() + /** + * Constructor. + * + * @param AdminSettingMapper $settingMapper Admin-setting persistence. + * @param IAppManager $appManager Used to detect the optional + * `groupfolders` Nextcloud + * app for Step 2's gate. + */ + public function __construct( + private readonly AdminSettingMapper $settingMapper, + private readonly IAppManager $appManager, + ) { + }//end __construct() - /** - * Return the wizard state payload (REQ-WIZ-008). - * - * Shape: `{complete: bool, currentRecommendedStep: int, - * stepStatuses: array}`. Step 1 is always - * `'done'`; Step 7 stays `'pending'` until the admin clicks Finish. - * - * @return array{complete: bool, currentRecommendedStep: int, stepStatuses: array} - * The wizard state payload. - * - * @spec openspec/specs/setup-wizard/spec.md - */ - public function getWizardState(): array - { - $complete = $this->isWizardComplete(); - $stepStatuses = $this->computeStepStatuses(complete: $complete); + /** + * Return the wizard state payload (REQ-WIZ-008). + * + * Shape: `{complete: bool, currentRecommendedStep: int, + * stepStatuses: array}`. Step 1 is always + * `'done'`; Step 7 stays `'pending'` until the admin clicks Finish. + * + * @return array{complete: bool, currentRecommendedStep: int, stepStatuses: array} + * The wizard state payload. + * + * @spec openspec/specs/setup-wizard/spec.md + */ + public function getWizardState(): array { + $complete = $this->isWizardComplete(); + $stepStatuses = $this->computeStepStatuses(complete: $complete); - return [ - 'complete' => $complete, - 'currentRecommendedStep' => $this->resolveRecommendedStep( - stepStatuses: $stepStatuses - ), - 'stepStatuses' => $stepStatuses, - ]; - }//end getWizardState() + return [ + 'complete' => $complete, + 'currentRecommendedStep' => $this->resolveRecommendedStep( + stepStatuses: $stepStatuses + ), + 'stepStatuses' => $stepStatuses, + ]; + }//end getWizardState() - /** - * Mark the wizard complete (REQ-WIZ-009). Idempotent — calling on a - * completed instance is a no-op that still returns the current state. - * - * @return array{complete: bool, currentRecommendedStep: int, stepStatuses: array} - * The updated wizard state payload. - * - * @spec openspec/specs/setup-wizard/spec.md - */ - public function markWizardComplete(): array - { - $this->settingMapper->setSetting( - key: AdminSetting::KEY_SETUP_WIZARD_COMPLETE, - value: true - ); + /** + * Mark the wizard complete (REQ-WIZ-009). Idempotent — calling on a + * completed instance is a no-op that still returns the current state. + * + * @return array{complete: bool, currentRecommendedStep: int, stepStatuses: array} + * The updated wizard state payload. + * + * @spec openspec/specs/setup-wizard/spec.md + */ + public function markWizardComplete(): array { + $this->settingMapper->setSetting( + key: AdminSetting::KEY_SETUP_WIZARD_COMPLETE, + value: true + ); - return $this->getWizardState(); - }//end markWizardComplete() + return $this->getWizardState(); + }//end markWizardComplete() - /** - * Whether the GroupFolder dependency is installed (REQ-WIZ-003). - * - * @return boolean True when the GroupFolder option may be selected. - */ - public function hasGroupfolderApp(): bool - { - return $this->appManager->isInstalled(self::GROUPFOLDER_APP_ID); - }//end hasGroupfolderApp() + /** + * Whether the GroupFolder dependency is installed (REQ-WIZ-003). + * + * @return boolean True when the GroupFolder option may be selected. + */ + public function hasGroupfolderApp(): bool { + return $this->appManager->isInstalled(self::GROUPFOLDER_APP_ID); + }//end hasGroupfolderApp() - /** - * Persist the storage backend choice from Step 2. - * - * @param string $value `'database'` or `'groupfolder'`. - * - * @return void - * - * @spec openspec/specs/setup-wizard/spec.md - */ - public function setContentStorage(string $value): void - { - $allowed = [self::STORAGE_DATABASE, self::STORAGE_GROUPFOLDER]; - if (in_array(needle: $value, haystack: $allowed, strict: true) === false) { - throw new InvalidArgumentException( - message: 'Unsupported storage backend: '.$value - ); - } + /** + * Persist the storage backend choice from Step 2. + * + * @param string $value `'database'` or `'groupfolder'`. + * + * @return void + * + * @spec openspec/specs/setup-wizard/spec.md + */ + public function setContentStorage(string $value): void { + $allowed = [self::STORAGE_DATABASE, self::STORAGE_GROUPFOLDER]; + if (in_array(needle: $value, haystack: $allowed, strict: true) === false) { + throw new InvalidArgumentException( + message: 'Unsupported storage backend: ' . $value + ); + } - $this->settingMapper->setSetting( - key: AdminSetting::KEY_CONTENT_STORAGE, - value: $value - ); - }//end setContentStorage() + $this->settingMapper->setSetting( + key: AdminSetting::KEY_CONTENT_STORAGE, + value: $value + ); + }//end setContentStorage() - /** - * Read the persisted storage backend choice with the safe default. - * - * @return string The persisted backend or `'database'` when unset. - * - * @spec openspec/specs/setup-wizard/spec.md - */ - public function getContentStorage(): string - { - $value = $this->settingMapper->getValue( - key: AdminSetting::KEY_CONTENT_STORAGE, - default: null - ); + /** + * Read the persisted storage backend choice with the safe default. + * + * @return string The persisted backend or `'database'` when unset. + * + * @spec openspec/specs/setup-wizard/spec.md + */ + public function getContentStorage(): string { + $value = $this->settingMapper->getValue( + key: AdminSetting::KEY_CONTENT_STORAGE, + default: null + ); - if (is_string($value) === true && $value !== '') { - return $value; - } + if (is_string($value) === true && $value !== '') { + return $value; + } - return self::STORAGE_DATABASE; - }//end getContentStorage() + return self::STORAGE_DATABASE; + }//end getContentStorage() - /** - * Whether the wizard has been completed at least once. - * - * @return boolean True when the flag is JSON `true`. - */ - private function isWizardComplete(): bool - { - $value = $this->settingMapper->getValue( - key: AdminSetting::KEY_SETUP_WIZARD_COMPLETE, - default: false - ); - return ($value === true); - }//end isWizardComplete() + /** + * Whether the wizard has been completed at least once. + * + * @return boolean True when the flag is JSON `true`. + */ + private function isWizardComplete(): bool { + $value = $this->settingMapper->getValue( + key: AdminSetting::KEY_SETUP_WIZARD_COMPLETE, + default: false + ); + return ($value === true); + }//end isWizardComplete() - /** - * Heuristic step-status table per REQ-WIZ-008's Data Model. - * - * The returned keys are numeric strings 1..STEP_COUNT — PHP coerces - * numeric string keys to integers internally, but JSON serialisation - * emits them as numeric properties matching the spec example. - * - * @param boolean $complete Whether the wizard has been completed. - * - * @return array Step number → status. - */ - private function computeStepStatuses(bool $complete): array - { - $settings = $this->settingMapper->getAllAsArray(); + /** + * Heuristic step-status table per REQ-WIZ-008's Data Model. + * + * The returned keys are numeric strings 1..STEP_COUNT — PHP coerces + * numeric string keys to integers internally, but JSON serialisation + * emits them as numeric properties matching the spec example. + * + * @param boolean $complete Whether the wizard has been completed. + * + * @return array Step number → status. + */ + private function computeStepStatuses(bool $complete): array { + $settings = $this->settingMapper->getAllAsArray(); - $hasStorage = isset($settings[AdminSetting::KEY_CONTENT_STORAGE]); - $groupOrder = ($settings[AdminSetting::KEY_GROUP_ORDER] ?? null); - $hasGroup = (is_array($groupOrder) === true && count($groupOrder) > 0); - $hasFooter = isset($settings[AdminSetting::KEY_FOOTER_CONFIG]); + $hasStorage = isset($settings[AdminSetting::KEY_CONTENT_STORAGE]); + $groupOrder = ($settings[AdminSetting::KEY_GROUP_ORDER] ?? null); + $hasGroup = (is_array($groupOrder) === true && count($groupOrder) > 0); + $hasFooter = isset($settings[AdminSetting::KEY_FOOTER_CONFIG]); - // Steps 4 (demos) and 5 (admin-roles) live in sibling capabilities - // not yet implemented in this branch; surface them as `'skipped'` - // so the wizard advances cleanly when the embed component is a - // local stub. They flip to `'done'` once the sibling capability - // ships and writes its own settings. - $statusStorage = 'pending'; - if ($hasStorage === true) { - $statusStorage = 'done'; - } + // Steps 4 (demos) and 5 (admin-roles) live in sibling capabilities + // not yet implemented in this branch; surface them as `'skipped'` + // so the wizard advances cleanly when the embed component is a + // local stub. They flip to `'done'` once the sibling capability + // ships and writes its own settings. + $statusStorage = 'pending'; + if ($hasStorage === true) { + $statusStorage = 'done'; + } - $statusGroup = 'pending'; - if ($hasGroup === true) { - $statusGroup = 'done'; - } + $statusGroup = 'pending'; + if ($hasGroup === true) { + $statusGroup = 'done'; + } - $statusFooter = 'skipped'; - if ($hasFooter === true) { - $statusFooter = 'done'; - } + $statusFooter = 'skipped'; + if ($hasFooter === true) { + $statusFooter = 'done'; + } - $statusDone = 'pending'; - if ($complete === true) { - $statusDone = 'done'; - } + $statusDone = 'pending'; + if ($complete === true) { + $statusDone = 'done'; + } - return [ - '1' => 'done', - '2' => $statusStorage, - '3' => $statusGroup, - '4' => 'skipped', - '5' => 'skipped', - '6' => $statusFooter, - '7' => $statusDone, - ]; - }//end computeStepStatuses() + return [ + '1' => 'done', + '2' => $statusStorage, + '3' => $statusGroup, + '4' => 'skipped', + '5' => 'skipped', + '6' => $statusFooter, + '7' => $statusDone, + ]; + }//end computeStepStatuses() - /** - * The first step whose status is not `'done'`, defaulting to Step 1. - * - * @param array $stepStatuses Heuristic per-step statuses. - * - * @return integer The recommended next step index (1..STEP_COUNT). - */ - private function resolveRecommendedStep(array $stepStatuses): int - { - for ($step = 1; $step <= self::STEP_COUNT; $step++) { - $status = ($stepStatuses[$step] ?? 'pending'); - if ($status !== 'done') { - return $step; - } - } + /** + * The first step whose status is not `'done'`, defaulting to Step 1. + * + * @param array $stepStatuses Heuristic per-step statuses. + * + * @return integer The recommended next step index (1..STEP_COUNT). + */ + private function resolveRecommendedStep(array $stepStatuses): int { + for ($step = 1; $step <= self::STEP_COUNT; $step++) { + $status = ($stepStatuses[$step] ?? 'pending'); + if ($status !== 'done') { + return $step; + } + } - return 1; - }//end resolveRecommendedStep() + return 1; + }//end resolveRecommendedStep() }//end class diff --git a/lib/Service/SlugGenerator.php b/lib/Service/SlugGenerator.php index b0e304fdd..ad7fada55 100644 --- a/lib/Service/SlugGenerator.php +++ b/lib/Service/SlugGenerator.php @@ -17,8 +17,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 * * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-1 */ @@ -30,88 +30,85 @@ /** * Slug helper used by `DashboardFactory` and the tree update path. */ -class SlugGenerator -{ - /** - * Maximum permitted slug length (REQ-DASH-024). - * - * Mirrors the `slug VARCHAR(128)` column added by - * `Version001010Date20260502120000`. - * - * @var integer - */ - public const MAX_LENGTH = 128; +class SlugGenerator { + /** + * Maximum permitted slug length (REQ-DASH-024). + * + * Mirrors the `slug VARCHAR(128)` column added by + * `Version001010Date20260502120000`. + * + * @var integer + */ + public const MAX_LENGTH = 128; - /** - * Regex matching the legal slug grammar — lowercase alphanumerics, - * dashes, and underscores. Empty / NULL / whitespace-only is invalid. - * - * @var string - */ - public const SLUG_PATTERN = '/^[a-z0-9_-]+$/'; + /** + * Regex matching the legal slug grammar — lowercase alphanumerics, + * dashes, and underscores. Empty / NULL / whitespace-only is invalid. + * + * @var string + */ + public const SLUG_PATTERN = '/^[a-z0-9_-]+$/'; - /** - * Convert an arbitrary user-supplied name into a slug. - * - * Steps: - * 1. Lowercase - * 2. Replace any run of whitespace with a single dash - * 3. Strip every character that is not `[a-z0-9_-]` - * 4. Collapse repeated dashes to a single dash - * 5. Trim leading/trailing dashes - * 6. Truncate to {@see self::MAX_LENGTH} - * - * Returns an empty string when the name is empty or yields no legal - * characters; the caller decides whether to substitute a UUID - * fallback or reject the request. - * - * @param string $name The dashboard name. - * - * @return string The slugified value (may be empty). - * - * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-1 - */ - public static function slugify(string $name): string - { - $lower = strtolower($name); + /** + * Convert an arbitrary user-supplied name into a slug. + * + * Steps: + * 1. Lowercase + * 2. Replace any run of whitespace with a single dash + * 3. Strip every character that is not `[a-z0-9_-]` + * 4. Collapse repeated dashes to a single dash + * 5. Trim leading/trailing dashes + * 6. Truncate to {@see self::MAX_LENGTH} + * + * Returns an empty string when the name is empty or yields no legal + * characters; the caller decides whether to substitute a UUID + * fallback or reject the request. + * + * @param string $name The dashboard name. + * + * @return string The slugified value (may be empty). + * + * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-1 + */ + public static function slugify(string $name): string { + $lower = strtolower($name); - // Replace whitespace with a single dash before stripping so - // multi-word names produce `q1-campaigns` not `q1campaigns`. - $dashed = (string) preg_replace('/\s+/', '-', $lower); + // Replace whitespace with a single dash before stripping so + // multi-word names produce `q1-campaigns` not `q1campaigns`. + $dashed = (string)preg_replace('/\s+/', '-', $lower); - // Strip every character outside the slug grammar. - $stripped = (string) preg_replace('/[^a-z0-9_-]+/', '', $dashed); + // Strip every character outside the slug grammar. + $stripped = (string)preg_replace('/[^a-z0-9_-]+/', '', $dashed); - // Collapse consecutive dashes (`--` → `-`). - $collapsed = (string) preg_replace('/-+/', '-', $stripped); + // Collapse consecutive dashes (`--` → `-`). + $collapsed = (string)preg_replace('/-+/', '-', $stripped); - $trimmed = trim($collapsed, '-'); + $trimmed = trim($collapsed, '-'); - if (strlen($trimmed) > self::MAX_LENGTH) { - $trimmed = substr($trimmed, 0, self::MAX_LENGTH); - // Re-trim in case the cut left a trailing dash. - $trimmed = rtrim($trimmed, '-'); - } + if (strlen($trimmed) > self::MAX_LENGTH) { + $trimmed = substr($trimmed, 0, self::MAX_LENGTH); + // Re-trim in case the cut left a trailing dash. + $trimmed = rtrim($trimmed, '-'); + } - return $trimmed; - }//end slugify() + return $trimmed; + }//end slugify() - /** - * Validate that a caller-supplied slug matches the grammar pinned - * by REQ-DASH-024. - * - * @param string $slug The candidate slug. - * - * @return bool True when the slug is acceptable. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-1 - */ - public static function isValid(string $slug): bool - { - if ($slug === '' || strlen($slug) > self::MAX_LENGTH) { - return false; - } + /** + * Validate that a caller-supplied slug matches the grammar pinned + * by REQ-DASH-024. + * + * @param string $slug The candidate slug. + * + * @return bool True when the slug is acceptable. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-1 + */ + public static function isValid(string $slug): bool { + if ($slug === '' || strlen($slug) > self::MAX_LENGTH) { + return false; + } - return preg_match(self::SLUG_PATTERN, $slug) === 1; - }//end isValid() + return preg_match(self::SLUG_PATTERN, $slug) === 1; + }//end isValid() }//end class diff --git a/lib/Service/SvgSanitiser.php b/lib/Service/SvgSanitiser.php index 053c98a25..857de2d3a 100644 --- a/lib/Service/SvgSanitiser.php +++ b/lib/Service/SvgSanitiser.php @@ -33,8 +33,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); @@ -49,307 +49,328 @@ /** * Whitelist-based SVG sanitiser. No Nextcloud dependencies — pure PHP. */ -class SvgSanitiser -{ - - /** - * Allowed element local-names (lowercase). Anything not in this - * list is removed from the tree along with its children. See - * REQ-RES-010 in the resource-uploads capability. - * - * @var array - */ - private const ALLOWED_ELEMENTS = [ - 'svg', - 'g', - 'path', - 'rect', - 'circle', - 'ellipse', - 'line', - 'polyline', - 'polygon', - 'text', - 'tspan', - 'defs', - 'clippath', - 'use', - 'image', - 'style', - 'lineargradient', - 'radialgradient', - 'stop', - 'mask', - 'pattern', - 'symbol', - 'title', - 'desc', - ]; - - /** - * Allowed attribute names (lowercase). Anything not in this list - * is removed from each element regardless of element name. See - * REQ-RES-011 in the resource-uploads capability. - * - * @var array - */ - private const ALLOWED_ATTRIBUTES = [ - 'id', - 'class', - 'style', - 'd', - 'x', - 'y', - 'x1', - 'y1', - 'x2', - 'y2', - 'cx', - 'cy', - 'r', - 'rx', - 'ry', - 'width', - 'height', - 'viewbox', - 'fill', - 'stroke', - 'stroke-width', - 'stroke-linecap', - 'stroke-linejoin', - 'stroke-dasharray', - 'stroke-dashoffset', - 'stroke-opacity', - 'fill-opacity', - 'opacity', - 'transform', - 'points', - 'font-size', - 'font-family', - 'font-weight', - 'text-anchor', - 'dominant-baseline', - 'dx', - 'dy', - 'clip-path', - 'mask', - 'filter', - 'gradientunits', - 'gradienttransform', - 'offset', - 'stop-color', - 'stop-opacity', - 'patternunits', - 'preserveaspectratio', - 'xmlns', - 'xmlns:xlink', - 'version', - 'href', - 'xlink:href', - ]; - - /** - * Sanitise SVG bytes; return the sanitised serialisation or `null` - * when the input is unparseable / fully stripped to an empty tree. - * - * Parse flags `LIBXML_NONET | LIBXML_NOENT` ensure the libxml - * parser cannot fetch external DTDs / entities (XXE) and that - * entity expansion is bounded by libxml's internal limits - * (defends against billion-laughs). - * - * @param string $bytes The raw uploaded SVG bytes. - * - * @return string|null The sanitised SVG, or null when unparseable - * or sanitised to an empty result. - * - * @spec openspec/specs/resource-uploads/spec.md - */ - public function sanitize(string $bytes): ?string - { - if ($bytes === '') { - return null; - } - - $previousErrors = libxml_use_internal_errors(use_errors: true); - - $document = new DOMDocument(); - $document->preserveWhiteSpace = false; - $document->formatOutput = false; - - // C2: LIBXML_NOENT removed — it resolves (not disables) entities, - // enabling XXE. LIBXML_NONET blocks external DTD/entity fetches. - $loaded = $document->loadXML( - source: $bytes, - options: LIBXML_NONET - ); - - libxml_clear_errors(); - libxml_use_internal_errors(use_errors: $previousErrors); - - if ($loaded === false) { - return null; - } - - $root = $document->documentElement; - if ($root === null) { - return null; - } - - // Validate the root element itself — reject if it is not in - // the whitelist (the recursive walker only inspects children). - if (in_array( - needle: strtolower(string: $root->localName), - haystack: self::ALLOWED_ELEMENTS, - strict: true - ) === false - ) { - return null; - } - - $this->cleanElement(element: $root); - $this->walkChildren(node: $root); - - $serialised = $document->saveXML($root); - if ($serialised === false || $serialised === '') { - return null; - } - - return $serialised; - }//end sanitize() - - /** - * Recursively walk children of $node, removing disallowed elements - * and cleaning the attributes of allowed ones. Snapshots the child - * list before mutation so removals during iteration are safe. - * - * @param DOMNode $node The parent node whose children to walk. - * - * @return void - */ - private function walkChildren(DOMNode $node): void - { - $children = []; - foreach ($node->childNodes as $child) { - $children[] = $child; - } - - foreach ($children as $child) { - if ($child instanceof DOMElement === false) { - continue; - } - - $localName = strtolower(string: $child->localName); - if (in_array( - needle: $localName, - haystack: self::ALLOWED_ELEMENTS, - strict: true - ) === false - ) { - $node->removeChild(child: $child); - continue; - } - - $this->cleanElement(element: $child); - $this->walkChildren(node: $child); - } - }//end walkChildren() - - /** - * Strip every disallowed attribute from $element, plus any `on*` - * attribute (defence in depth) and any `href` / `xlink:href` / - * `style` whose value is on the URL or CSS denylist. - * - * @param DOMElement $element The element whose attributes to clean. - * - * @return void - */ - private function cleanElement(DOMElement $element): void - { - if ($element->hasAttributes() === false) { - return; - } - - $attributes = []; - foreach ($element->attributes as $attribute) { - if ($attribute instanceof DOMAttr) { - $attributes[] = $attribute; - } - } - - foreach ($attributes as $attribute) { - $name = $attribute->nodeName; - $lowerName = strtolower(string: $name); - - // Defence in depth — strip every `on*` attribute regardless - // of whitelist (REQ-RES-011). - if (str_starts_with(haystack: $lowerName, needle: 'on') === true) { - $element->removeAttributeNode(attr: $attribute); - continue; - } - - if (in_array( - needle: $lowerName, - haystack: self::ALLOWED_ATTRIBUTES, - strict: true - ) === false - ) { - $element->removeAttributeNode(attr: $attribute); - continue; - } - - if ($lowerName === 'href' || $lowerName === 'xlink:href') { - if ($this->isDangerousUrl(value: $attribute->value) === true) { - $element->removeAttributeNode(attr: $attribute); - } - - continue; - } - - if ($lowerName === 'style') { - if ($this->isDangerousStyle(value: $attribute->value) === true) { - $element->removeAttributeNode(attr: $attribute); - } - - continue; - } - }//end foreach - }//end cleanElement() - - /** - * Whether the supplied URL value is on the denylist. Trims + - * lowercases before comparing; matches `javascript:` and `data:` - * prefixes (REQ-RES-012). - * - * @param string $value The raw attribute value. - * - * @return boolean True when the value should be rejected. - */ - private function isDangerousUrl(string $value): bool - { - $normalised = strtolower(string: trim(string: $value)); - if (str_starts_with(haystack: $normalised, needle: 'javascript:') === true) { - return true; - } - - if (str_starts_with(haystack: $normalised, needle: 'data:') === true) { - return true; - } - - return false; - }//end isDangerousUrl() - - /** - * Whether the supplied style value contains a forbidden CSS - * construct: `expression(`, `javascript:`, or `url(data:`. Match - * is case-insensitive with optional whitespace per REQ-RES-012. - * - * @param string $value The raw style attribute value. - * - * @return boolean True when the style attribute should be removed. - */ - private function isDangerousStyle(string $value): bool - { - $pattern = '/expression\s*\(|javascript\s*:|url\s*\(\s*["\']?\s*data\s*:/i'; - return (preg_match(pattern: $pattern, subject: $value) === 1); - }//end isDangerousStyle() +class SvgSanitiser { + + /** + * Allowed element local-names (lowercase). Anything not in this + * list is removed from the tree along with its children. See + * REQ-RES-010 in the resource-uploads capability. + * + * @var array + */ + private const ALLOWED_ELEMENTS = [ + 'svg', + 'g', + 'path', + 'rect', + 'circle', + 'ellipse', + 'line', + 'polyline', + 'polygon', + 'text', + 'tspan', + 'defs', + 'clippath', + 'use', + 'image', + 'style', + 'lineargradient', + 'radialgradient', + 'stop', + 'mask', + 'pattern', + 'symbol', + 'title', + 'desc', + ]; + + /** + * Allowed attribute names (lowercase). Anything not in this list + * is removed from each element regardless of element name. See + * REQ-RES-011 in the resource-uploads capability. + * + * @var array + */ + private const ALLOWED_ATTRIBUTES = [ + 'id', + 'class', + 'style', + 'd', + 'x', + 'y', + 'x1', + 'y1', + 'x2', + 'y2', + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'width', + 'height', + 'viewbox', + 'fill', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-opacity', + 'fill-opacity', + 'opacity', + 'transform', + 'points', + 'font-size', + 'font-family', + 'font-weight', + 'text-anchor', + 'dominant-baseline', + 'dx', + 'dy', + 'clip-path', + 'mask', + 'filter', + 'gradientunits', + 'gradienttransform', + 'offset', + 'stop-color', + 'stop-opacity', + 'patternunits', + 'preserveaspectratio', + 'xmlns', + 'xmlns:xlink', + 'version', + 'href', + 'xlink:href', + ]; + + /** + * Sanitise SVG bytes; return the sanitised serialisation or `null` + * when the input is unparseable / fully stripped to an empty tree. + * + * Parse flags `LIBXML_NONET | LIBXML_NOENT` ensure the libxml + * parser cannot fetch external DTDs / entities (XXE) and that + * entity expansion is bounded by libxml's internal limits + * (defends against billion-laughs). + * + * @param string $bytes The raw uploaded SVG bytes. + * + * @return string|null The sanitised SVG, or null when unparseable + * or sanitised to an empty result. + * + * @spec openspec/specs/resource-uploads/spec.md + */ + public function sanitize(string $bytes): ?string { + if ($bytes === '') { + return null; + } + + $previousErrors = libxml_use_internal_errors(use_errors: true); + + $document = new DOMDocument(); + $document->preserveWhiteSpace = false; + $document->formatOutput = false; + + // C2: LIBXML_NOENT removed — it resolves (not disables) entities, + // enabling XXE. LIBXML_NONET blocks external DTD/entity fetches. + $loaded = $document->loadXML( + source: $bytes, + options: LIBXML_NONET + ); + + libxml_clear_errors(); + libxml_use_internal_errors(use_errors: $previousErrors); + + if ($loaded === false) { + return null; + } + + $root = $document->documentElement; + if ($root === null) { + return null; + } + + // Validate the root element itself — reject if it is not in + // the whitelist (the recursive walker only inspects children). + if (in_array( + needle: strtolower(string: $root->localName), + haystack: self::ALLOWED_ELEMENTS, + strict: true + ) === false + ) { + return null; + } + + $this->cleanElement(element: $root); + $this->walkChildren(node: $root); + + $serialised = $document->saveXML($root); + if ($serialised === false || $serialised === '') { + return null; + } + + return $serialised; + }//end sanitize() + + /** + * Recursively walk children of $node, removing disallowed elements + * and cleaning the attributes of allowed ones. Snapshots the child + * list before mutation so removals during iteration are safe. + * + * @param DOMNode $node The parent node whose children to walk. + * + * @return void + */ + private function walkChildren(DOMNode $node): void { + $children = []; + foreach ($node->childNodes as $child) { + $children[] = $child; + } + + foreach ($children as $child) { + if ($child instanceof DOMElement === false) { + continue; + } + + $localName = strtolower(string: $child->localName); + if (in_array( + needle: $localName, + haystack: self::ALLOWED_ELEMENTS, + strict: true + ) === false + ) { + $node->removeChild(child: $child); + continue; + } + + $this->cleanElement(element: $child); + $this->walkChildren(node: $child); + } + }//end walkChildren() + + /** + * Strip every disallowed attribute from $element, plus any `on*` + * attribute (defence in depth) and any `href` / `xlink:href` / + * `style` whose value is on the URL or CSS denylist. + * + * @param DOMElement $element The element whose attributes to clean. + * + * @return void + */ + private function cleanElement(DOMElement $element): void { + if ($element->hasAttributes() === false) { + return; + } + + // No instanceof DOMAttr filter: DOMElement::$attributes is a + // DOMNamedNodeMap of DOMAttr, so the check is always true (PHPStan 2: + // instanceof.alwaysTrue). Kept as a copy into a plain array because the + // caller mutates attributes while iterating, and mutating a live + // DOMNamedNodeMap mid-loop skips nodes. + $attributes = []; + foreach ($element->attributes as $attribute) { + $attributes[] = $attribute; + } + + foreach ($attributes as $attribute) { + $lowerName = strtolower(string: $attribute->nodeName); + + if ($this->isDisallowedAttribute( + lowerName: $lowerName, + value: $attribute->value + ) === true + ) { + $element->removeAttributeNode(attr: $attribute); + } + }//end foreach + }//end cleanElement() + + /** + * Decide whether a single attribute must be stripped. + * + * The checks are evaluated in strict order and the FIRST match wins — + * this ordering is load-bearing for REQ-RES-011 / REQ-RES-012 and must + * not be rearranged: + * + * 1. Any `on*` attribute is removed regardless of the whitelist + * (defence in depth). + * 2. Anything absent from {@see self::ALLOWED_ATTRIBUTES} is removed. + * 3. A whitelisted `href` / `xlink:href` is removed when its value is + * on the URL denylist. + * 4. A whitelisted `style` is removed when its value is on the CSS + * denylist. + * + * Anything reaching the end is whitelisted and carries no dangerous + * payload, so it is kept. + * + * @param string $lowerName The lower-cased attribute name. + * @param string $value The raw attribute value. + * + * @return boolean True when the attribute must be removed. + */ + private function isDisallowedAttribute(string $lowerName, string $value): bool { + // Defence in depth — strip every `on*` attribute regardless + // of whitelist (REQ-RES-011). + if (str_starts_with(haystack: $lowerName, needle: 'on') === true) { + return true; + } + + if (in_array( + needle: $lowerName, + haystack: self::ALLOWED_ATTRIBUTES, + strict: true + ) === false + ) { + return true; + } + + if ($lowerName === 'href' || $lowerName === 'xlink:href') { + return $this->isDangerousUrl(value: $value); + } + + if ($lowerName === 'style') { + return $this->isDangerousStyle(value: $value); + } + + return false; + }//end isDisallowedAttribute() + + /** + * Whether the supplied URL value is on the denylist. Trims + + * lowercases before comparing; matches `javascript:` and `data:` + * prefixes (REQ-RES-012). + * + * @param string $value The raw attribute value. + * + * @return boolean True when the value should be rejected. + */ + private function isDangerousUrl(string $value): bool { + $normalised = strtolower(string: trim(string: $value)); + if (str_starts_with(haystack: $normalised, needle: 'javascript:') === true) { + return true; + } + + if (str_starts_with(haystack: $normalised, needle: 'data:') === true) { + return true; + } + + return false; + }//end isDangerousUrl() + + /** + * Whether the supplied style value contains a forbidden CSS + * construct: `expression(`, `javascript:`, or `url(data:`. Match + * is case-insensitive with optional whitespace per REQ-RES-012. + * + * @param string $value The raw style attribute value. + * + * @return boolean True when the style attribute should be removed. + */ + private function isDangerousStyle(string $value): bool { + $pattern = '/expression\s*\(|javascript\s*:|url\s*\(\s*["\']?\s*data\s*:/i'; + return (preg_match(pattern: $pattern, subject: $value) === 1); + }//end isDangerousStyle() }//end class diff --git a/lib/Service/TemplateResyncService.php b/lib/Service/TemplateResyncService.php new file mode 100644 index 000000000..d7f6d291c --- /dev/null +++ b/lib/Service/TemplateResyncService.php @@ -0,0 +1,940 @@ + + * @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 InvalidArgumentException; +use OCA\LaunchPad\Activity\ActivityPublisher; +use OCA\LaunchPad\Activity\Extension; +use OCA\LaunchPad\BackgroundJob\TemplateResyncJob; +use OCA\LaunchPad\Db\Dashboard; +use OCA\LaunchPad\Db\DashboardMapper; +use OCA\LaunchPad\Db\WidgetPlacement; +use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\BackgroundJob\IJobList; +use OCP\IDBConnection; +use OCP\Notification\IManager as INotificationManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Diffs an admin template against its provisioned copies and applies the + * chosen re-sync strategy. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Orchestrates DB mappers, + * the transaction boundary, the audit publisher, the notification + * manager, and the async job dispatcher for one cohesive re-sync + * operation — splitting further would fragment a single atomic concern. + */ +class TemplateResyncService { + /** + * Replace each copy's layout with the current template layout. + */ + public const STRATEGY_OVERWRITE = 'overwrite'; + + /** + * Reconcile template-origin placements while preserving user-added + * placements. + */ + public const STRATEGY_MERGE = 'merge'; + + /** + * The only accepted `strategy` values (REQ-RESYNC-001 "Invalid + * strategy is rejected"). + * + * @var string[] + */ + public const VALID_STRATEGIES = [ + self::STRATEGY_OVERWRITE, + self::STRATEGY_MERGE, + ]; + + /** + * Above this many provisioned copies, a real (non-dry-run) re-sync is + * applied asynchronously via {@see TemplateResyncJob} instead of + * inline within the request, so the admin's HTTP request returns + * promptly (REQ-RESYNC-005 "Large groups apply asynchronously"). + * + * @var int + */ + public const ASYNC_THRESHOLD = 50; + + /** + * Constructor. + * + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param WidgetPlacementMapper $placementMapper Widget placement mapper. + * @param IDBConnection $db DB connection — + * used for the + * per-copy + * transaction + * boundary. + * @param ActivityPublisher $activityPublisher Audit-trail + * publisher + * (REQ-RESYNC-005). + * @param INotificationManager $notificationManager Nextcloud + * notification + * manager — used + * for the + * per-user + * "your dashboard + * was updated" + * notification. + * Canonical + * fallback per + * REQ-RESYNC-005; + * this app has no + * OpenRegister + * dependency + * (see the + * admin-templates + * storage + * policy), so the + * `x-openregister- + * notifications` + * branch is not + * wired in. + * @param IJobList $jobList Background job + * list — used to + * enqueue + * {@see TemplateResyncJob} + * for large + * target groups. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly IDBConnection $db, + private readonly ActivityPublisher $activityPublisher, + private readonly INotificationManager $notificationManager, + private readonly IJobList $jobList, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Controller-facing entry point (REQ-RESYNC-001). + * + * Validates the template + strategy up front (before any dry-run or + * mutation work), then dispatches to the dry-run report, the + * synchronous apply, or the async job enqueue depending on `$dryRun` + * and the provisioned-copy count. + * + * @param int $templateId The admin template's dashboard ID. + * @param string $strategy `'overwrite'` or `'merge'`. + * @param bool $dryRun When true, compute and return the plan + * without mutating anything. + * @param string $actingAdminId The acting admin's NC user ID (for the + * audit record). + * + * @return array The dry-run report, the applied result, + * or the async-accepted envelope. + * + * @throws InvalidArgumentException When the strategy is invalid or the + * dashboard is not an admin template. + * + * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies + */ + public function resync( + int $templateId, + string $strategy, + bool $dryRun, + string $actingAdminId, + ): array { + $this->assertValidStrategy(strategy: $strategy); + $this->getValidatedTemplate(templateId: $templateId); + + if ($dryRun === true) { + $plan = $this->planResync( + templateId: $templateId, + strategy: $strategy + ); + $plan['dryRun'] = true; + $plan['async'] = false; + + return $plan; + } + + $totalCopies = count( + $this->dashboardMapper->findByBasedOnTemplate( + templateId: $templateId + ) + ); + + if ($totalCopies > self::ASYNC_THRESHOLD) { + $plan = $this->planResync( + templateId: $templateId, + strategy: $strategy + ); + + $this->jobList->add( + TemplateResyncJob::class, + [ + 'templateId' => $templateId, + 'strategy' => $strategy, + 'actingAdminId' => $actingAdminId, + ] + ); + + return [ + 'templateId' => $templateId, + 'strategy' => $strategy, + 'dryRun' => false, + 'async' => true, + 'accepted' => true, + 'totalCopies' => $totalCopies, + 'affectedCount' => $plan['affectedCount'], + ]; + }//end if + + return $this->applyResync( + templateId: $templateId, + strategy: $strategy, + actingAdminId: $actingAdminId + ); + }//end resync() + + /** + * Compute the re-sync plan WITHOUT mutating anything (REQ-RESYNC-002). + * + * Safe to call directly (bypassing {@see self::resync()}) when the + * caller has already validated the template/strategy — used by + * {@see self::resync()} itself both for the dry-run response and to + * report `affectedCount` in the async-accepted envelope. + * + * @param int $templateId The admin template's dashboard ID. + * @param string $strategy `'overwrite'` or `'merge'`. + * + * @return array `{templateId, strategy, totalCopies, + * affectedCount, copies: [...]}`. + * + * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-002-dry-run-reports-the-plan-without-mutating + */ + public function planResync(int $templateId, string $strategy): array { + $template = $this->getValidatedTemplate(templateId: $templateId); + $templatePlacements = $this->placementMapper->findByDashboardId( + dashboardId: $template->getId() + ); + $copies = $this->dashboardMapper->findByBasedOnTemplate( + templateId: $templateId + ); + + $copyReports = []; + $affectedCount = 0; + + foreach ($copies as $copy) { + $copyPlacements = $this->placementMapper->findByDashboardId( + dashboardId: $copy->getId() + ); + $diff = $this->diffCopy( + templatePlacements: $templatePlacements, + copyPlacements: $copyPlacements, + strategy: $strategy + ); + + $hasChanges = $this->diffHasChanges(diff: $diff); + if ($hasChanges === true) { + $affectedCount++; + } + + $copyReports[] = $this->summarizeCopy( + copy: $copy, + diff: $diff, + applied: false, + error: null + ); + }//end foreach + + return [ + 'templateId' => $templateId, + 'strategy' => $strategy, + 'totalCopies' => count($copies), + 'affectedCount' => $affectedCount, + 'copies' => $copyReports, + ]; + }//end planResync() + + /** + * Recompute the plan and apply it, per copy, inside its own + * transaction (REQ-RESYNC-005). Writes exactly one audit record for + * the whole run and notifies every user whose copy actually changed. + * + * Idempotent: when a copy's diff has no add/update/remove entries the + * copy is left untouched (no transaction opened, no notification + * queued) — re-running against an unchanged template is a no-op for + * every copy (REQ-RESYNC-005 "Re-sync is idempotent"). + * + * Called both inline (small target groups, from {@see self::resync()}) + * and from {@see TemplateResyncJob::run()} (large target groups). + * + * @param int $templateId The admin template's dashboard ID. + * @param string $strategy `'overwrite'` or `'merge'`. + * @param string $actingAdminId The acting admin's NC user ID. + * + * @return array `{templateId, strategy, dryRun: false, + * async: false, totalCopies, + * affectedCount, copies: [...]}`. + * + * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-005-re-sync-is-idempotent-audited-async-capable-and-notifies-users + */ + public function applyResync( + int $templateId, + string $strategy, + string $actingAdminId, + ): array { + $template = $this->getValidatedTemplate(templateId: $templateId); + $templatePlacements = $this->placementMapper->findByDashboardId( + dashboardId: $template->getId() + ); + $copies = $this->dashboardMapper->findByBasedOnTemplate( + templateId: $templateId + ); + + $copyReports = []; + $affectedCount = 0; + $notifyDashboards = []; + + foreach ($copies as $copy) { + $copyPlacements = $this->placementMapper->findByDashboardId( + dashboardId: $copy->getId() + ); + $diff = $this->diffCopy( + templatePlacements: $templatePlacements, + copyPlacements: $copyPlacements, + strategy: $strategy + ); + + if ($this->diffHasChanges(diff: $diff) === false) { + $copyReports[] = $this->summarizeCopy( + copy: $copy, + diff: $diff, + applied: true, + error: null + ); + continue; + } + + $error = $this->applyDiffToCopy(copy: $copy, diff: $diff); + + $copyReports[] = $this->summarizeCopy( + copy: $copy, + diff: $diff, + applied: ($error === null), + error: $error + ); + + if ($error === null) { + $affectedCount++; + $notifyDashboards[] = $copy; + } + }//end foreach + + $this->emitAuditEvent( + templateId: $templateId, + templateName: (string)$template->getName(), + strategy: $strategy, + affectedCount: $affectedCount, + actingAdminId: $actingAdminId + ); + + foreach ($notifyDashboards as $dashboard) { + $this->notifyUserResync(dashboard: $dashboard); + } + + return [ + 'templateId' => $templateId, + 'strategy' => $strategy, + 'dryRun' => false, + 'async' => false, + 'totalCopies' => count($copies), + 'affectedCount' => $affectedCount, + 'copies' => $copyReports, + ]; + }//end applyResync() + + /** + * Apply one copy's diff inside its own DB transaction. Partial + * placement failure rolls that single copy back — the run continues + * to the next copy rather than aborting the whole batch + * (REQ-RESYNC-005 "Each per-copy apply MUST be transactional"). + * + * @param Dashboard $copy The provisioned copy. + * @param array $diff The diff computed by + * {@see self::diffCopy()}, whose + * `@return` documents the exact + * shape: `toAdd` and `toRemove` hold + * {@see WidgetPlacement} lists and + * `toUpdate` holds copy/template + * pairs. + * + * @return string|null The failure message, or null on success. + */ + private function applyDiffToCopy(Dashboard $copy, array $diff): ?string { + $this->db->beginTransaction(); + + try { + foreach ($diff['toAdd'] as $templatePlacement) { + $this->placementMapper->insert( + entity: $this->cloneTemplatePlacementInto( + template: $templatePlacement, + dashboardId: $copy->getId() + ) + ); + } + + foreach ($diff['toUpdate'] as $pair) { + $this->applyTemplateFields( + copy: $pair['copy'], + template: $pair['template'] + ); + $this->placementMapper->update(entity: $pair['copy']); + } + + foreach ($diff['toRemove'] as $copyPlacement) { + $this->placementMapper->delete(entity: $copyPlacement); + } + + $this->db->commit(); + } catch (Throwable $t) { + $this->db->rollBack(); + $this->logger->error( + message: 'launchpad.template_resync.copy_apply_failed', + context: [ + 'dashboardId' => $copy->getId(), + 'exception' => $t, + ] + ); + + return $t->getMessage(); + }//end try + + return null; + }//end applyDiffToCopy() + + // --------------------------------------------------------------- + // Diff engine + // --------------------------------------------------------------- + + /** + * Partition one copy's placements against the template's current + * placements (REQ-RESYNC-003, REQ-RESYNC-004). + * + * Matching is by {@see WidgetPlacement::getTemplatePlacementId()}: + * + * - Matched + fields differ → `toUpdate` (reconcile onto the copy). + * - Matched + fields equal → `toPreserve` (no-op, both strategies). + * - Template-origin but the + * matching template placement + * no longer exists → `toRemove` (admin deleted it from the + * template — both strategies). + * - No known origin + * (user-added) → `toPreserve` under `merge`, + * `toRemove` under `overwrite`. + * - A template placement with + * no matching copy placement → `toAdd` (new template placement, or + * a compulsory widget the user removed + * — restored either way). + * + * Compulsory widgets receive no special-case handling: they are + * ordinary template placements, so the `toAdd` (restore) / `toUpdate` + * (align position+flags) branches above already reconcile them under + * BOTH strategies, satisfying REQ-RESYNC-004 without a separate code + * path. + * + * @param WidgetPlacement[] $templatePlacements The template's current + * placements. + * @param WidgetPlacement[] $copyPlacements The copy's current + * placements. + * @param string $strategy `'overwrite'` or + * `'merge'`. + * + * @return array{ + * toAdd: WidgetPlacement[], + * toUpdate: array, + * toRemove: WidgetPlacement[], + * toPreserve: WidgetPlacement[] + * } + */ + private function diffCopy( + array $templatePlacements, + array $copyPlacements, + string $strategy, + ): array { + $templateById = []; + foreach ($templatePlacements as $tp) { + $templateById[$tp->getId()] = $tp; + } + + $matchedTemplateIds = []; + $toUpdate = []; + $toRemove = []; + $toPreserve = []; + + foreach ($copyPlacements as $cp) { + $originId = $cp->getTemplatePlacementId(); + + if ($originId !== null && isset($templateById[$originId]) === true) { + $matchedTemplateIds[$originId] = true; + $tp = $templateById[$originId]; + + if ($this->placementDiffers(copy: $cp, template: $tp) === true) { + $toUpdate[] = [ + 'copy' => $cp, + 'template' => $tp, + ]; + continue; + } + + $toPreserve[] = $cp; + continue; + } + + if ($originId !== null && isset($templateById[$originId]) === false) { + // Template-origin, but the admin has since deleted this + // placement from the template — remove it under both + // strategies (REQ-RESYNC-003 "Template widget removed by + // admin is removed under merge"). + $toRemove[] = $cp; + continue; + } + + // No known origin — a genuinely user-added placement. + if ($strategy === self::STRATEGY_OVERWRITE) { + $toRemove[] = $cp; + continue; + } + + $toPreserve[] = $cp; + }//end foreach + + return [ + 'toAdd' => $this->unmatchedTemplatePlacements( + templatePlacements: $templatePlacements, + matchedTemplateIds: $matchedTemplateIds + ), + 'toUpdate' => $toUpdate, + 'toRemove' => $toRemove, + 'toPreserve' => $toPreserve, + ]; + }//end diffCopy() + + /** + * Template placements the copy has no counterpart for — i.e. the + * additions a re-sync must make. + * + * @param WidgetPlacement[] $templatePlacements The template's current placements. + * @param array $matchedTemplateIds Set of template placement IDs + * already matched to a copy + * placement, keyed by ID. + * + * @return WidgetPlacement[] The unmatched template placements, in input order. + */ + private function unmatchedTemplatePlacements(array $templatePlacements, array $matchedTemplateIds): array { + $toAdd = []; + foreach ($templatePlacements as $tp) { + if (isset($matchedTemplateIds[$tp->getId()]) === false) { + $toAdd[] = $tp; + } + } + + return $toAdd; + }//end unmatchedTemplatePlacements() + + /** + * Whether a diff carries any mutation (add/update/remove). Used to + * decide `affectedCount` and to skip untouched copies entirely + * (REQ-RESYNC-005 idempotency). + * + * @param array $diff The diff computed by + * {@see self::diffCopy()}. + * + * @return bool True when applying the diff would change the copy. + */ + private function diffHasChanges(array $diff): bool { + return count($diff['toAdd']) > 0 + || count($diff['toUpdate']) > 0 + || count($diff['toRemove']) > 0; + }//end diffHasChanges() + + /** + * The reconcilable field snapshot compared for equality and applied + * on update. Kept as one array so the equality check + * ({@see self::placementDiffers()}) and the field-copy + * ({@see self::applyTemplateFields()} / {@see self::cloneTemplatePlacementInto()}) + * can never silently drift apart. + * + * @param WidgetPlacement $placement The placement to snapshot. + * + * @return array The comparable field values. + */ + private function templateFieldSnapshot(WidgetPlacement $placement): array { + return [ + 'widgetId' => $placement->getWidgetId(), + 'gridX' => $placement->getGridX(), + 'gridY' => $placement->getGridY(), + 'gridWidth' => $placement->getGridWidth(), + 'gridHeight' => $placement->getGridHeight(), + 'isCompulsory' => $placement->getIsCompulsory(), + 'isVisible' => $placement->getIsVisible(), + 'styleConfig' => $placement->getStyleConfig(), + 'customTitle' => $placement->getCustomTitle(), + 'customIcon' => $placement->getCustomIcon(), + 'showTitle' => $placement->getShowTitle(), + 'sortOrder' => $placement->getSortOrder(), + 'content' => $placement->getContent(), + 'tileType' => $placement->getTileType(), + 'tileTitle' => $placement->getTileTitle(), + 'tileIcon' => $placement->getTileIcon(), + 'tileIconType' => $placement->getTileIconType(), + 'tileBackgroundColor' => $placement->getTileBackgroundColor(), + 'tileTextColor' => $placement->getTileTextColor(), + 'tileLinkType' => $placement->getTileLinkType(), + 'tileLinkValue' => $placement->getTileLinkValue(), + 'requiresAcknowledgement' => $placement->getRequiresAcknowledgement(), + 'acknowledgementPrompt' => $placement->getAcknowledgementPrompt(), + 'acknowledgementDeadline' => $placement->getAcknowledgementDeadline(), + 'reacknowledgeOnChange' => $placement->getReacknowledgeOnChange(), + 'acknowledgementContentVersion' => $placement->getAcknowledgementContentVersion(), + 'announcementKey' => $placement->getAnnouncementKey(), + ]; + }//end templateFieldSnapshot() + + /** + * Whether a copy placement's reconcilable fields differ from its + * matching template placement's current fields. + * + * @param WidgetPlacement $copy The copy's placement. + * @param WidgetPlacement $template The matching template placement. + * + * @return bool True when the copy needs to be updated to match. + */ + private function placementDiffers( + WidgetPlacement $copy, + WidgetPlacement $template, + ): bool { + return $this->templateFieldSnapshot(placement: $copy) !== $this->templateFieldSnapshot(placement: $template); + }//end placementDiffers() + + /** + * Mutate a copy placement in place so its reconcilable fields match + * the template placement's current fields (REQ-RESYNC-004 "position + * and flags aligned"). + * + * @param WidgetPlacement $copy The copy's placement (mutated). + * @param WidgetPlacement $template The template placement to copy + * from. + * + * @return void + */ + private function applyTemplateFields( + WidgetPlacement $copy, + WidgetPlacement $template, + ): void { + $copy->setWidgetId($template->getWidgetId()); + $copy->setGridX($template->getGridX()); + $copy->setGridY($template->getGridY()); + $copy->setGridWidth($template->getGridWidth()); + $copy->setGridHeight($template->getGridHeight()); + $copy->setIsCompulsory($template->getIsCompulsory()); + $copy->setIsVisible($template->getIsVisible()); + $copy->setStyleConfig($template->getStyleConfig()); + $copy->setCustomTitle($template->getCustomTitle()); + $copy->setCustomIcon($template->getCustomIcon()); + $copy->setShowTitle($template->getShowTitle()); + $copy->setSortOrder($template->getSortOrder()); + $copy->setContent($template->getContent()); + $copy->setTileType($template->getTileType()); + $copy->setTileTitle($template->getTileTitle()); + $copy->setTileIcon($template->getTileIcon()); + $copy->setTileIconType($template->getTileIconType()); + $copy->setTileBackgroundColor($template->getTileBackgroundColor()); + $copy->setTileTextColor($template->getTileTextColor()); + $copy->setTileLinkType($template->getTileLinkType()); + $copy->setTileLinkValue($template->getTileLinkValue()); + $copy->setRequiresAcknowledgement($template->getRequiresAcknowledgement()); + $copy->setAcknowledgementPrompt($template->getAcknowledgementPrompt()); + $copy->setAcknowledgementDeadline($template->getAcknowledgementDeadline()); + $copy->setReacknowledgeOnChange($template->getReacknowledgeOnChange()); + $copy->setAcknowledgementContentVersion($template->getAcknowledgementContentVersion()); + $copy->setAnnouncementKey($template->getAnnouncementKey()); + $copy->setUpdatedAt( + (new DateTime())->format(format: 'Y-m-d H:i:s') + ); + }//end applyTemplateFields() + + /** + * Build a fresh placement for `$dashboardId` cloned from a template + * placement, stamping the origin key so future re-syncs recognise it + * as template-origin. + * + * @param WidgetPlacement $template The template placement to clone. + * @param int $dashboardId The target copy's dashboard ID. + * + * @return WidgetPlacement The new (uninserted) placement entity. + */ + private function cloneTemplatePlacementInto( + WidgetPlacement $template, + int $dashboardId, + ): WidgetPlacement { + $clone = new WidgetPlacement(); + $clone->setDashboardId($dashboardId); + $clone->setWidgetId($template->getWidgetId()); + $clone->setGridX($template->getGridX()); + $clone->setGridY($template->getGridY()); + $clone->setGridWidth($template->getGridWidth()); + $clone->setGridHeight($template->getGridHeight()); + $clone->setIsCompulsory($template->getIsCompulsory()); + $clone->setIsVisible($template->getIsVisible()); + $clone->setStyleConfig($template->getStyleConfig()); + $clone->setCustomTitle($template->getCustomTitle()); + $clone->setCustomIcon($template->getCustomIcon()); + $clone->setShowTitle($template->getShowTitle()); + $clone->setSortOrder($template->getSortOrder()); + $clone->setContent($template->getContent()); + $clone->setTileType($template->getTileType()); + $clone->setTileTitle($template->getTileTitle()); + $clone->setTileIcon($template->getTileIcon()); + $clone->setTileIconType($template->getTileIconType()); + $clone->setTileBackgroundColor($template->getTileBackgroundColor()); + $clone->setTileTextColor($template->getTileTextColor()); + $clone->setTileLinkType($template->getTileLinkType()); + $clone->setTileLinkValue($template->getTileLinkValue()); + $clone->setRequiresAcknowledgement($template->getRequiresAcknowledgement()); + $clone->setAcknowledgementPrompt($template->getAcknowledgementPrompt()); + $clone->setAcknowledgementDeadline($template->getAcknowledgementDeadline()); + $clone->setReacknowledgeOnChange($template->getReacknowledgeOnChange()); + $clone->setAcknowledgementContentVersion($template->getAcknowledgementContentVersion()); + $clone->setAnnouncementKey($template->getAnnouncementKey()); + $clone->setTemplatePlacementId($template->getId()); + + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $clone->setCreatedAt($now); + $clone->setUpdatedAt($now); + + return $clone; + }//end cloneTemplatePlacementInto() + + // --------------------------------------------------------------- + // Validation + // --------------------------------------------------------------- + + /** + * Validate the `strategy` param (REQ-RESYNC-001 "Invalid strategy is + * rejected"). + * + * @param string $strategy The candidate strategy. + * + * @return void + * + * @throws InvalidArgumentException When not `overwrite` or `merge`. + */ + private function assertValidStrategy(string $strategy): void { + if (in_array(needle: $strategy, haystack: self::VALID_STRATEGIES, strict: true) === false) { + throw new InvalidArgumentException( + message: 'Invalid strategy: only "overwrite" and "merge" are accepted' + ); + } + }//end assertValidStrategy() + + /** + * Load a dashboard by ID and assert it is an admin template + * (REQ-RESYNC-001 "Re-sync rejects a non-template dashboard"). + * + * @param int $templateId The candidate template's dashboard ID. + * + * @return Dashboard The validated template. + * + * @throws InvalidArgumentException When the ID is unknown or the + * dashboard is not an admin + * template. + */ + private function getValidatedTemplate(int $templateId): Dashboard { + try { + $template = $this->dashboardMapper->find(id: $templateId); + } catch (DoesNotExistException $e) { + throw new InvalidArgumentException( + message: 'Not an admin template', + previous: $e + ); + } + + if ($template->getType() !== Dashboard::TYPE_ADMIN_TEMPLATE) { + throw new InvalidArgumentException( + message: 'Not an admin template' + ); + } + + return $template; + }//end getValidatedTemplate() + + // --------------------------------------------------------------- + // Reporting / audit / notification + // --------------------------------------------------------------- + + /** + * Summarise one copy's diff for the report returned to the admin. + * + * @param Dashboard $copy The provisioned copy. + * @param array $diff The diff computed by + * {@see + * self::diffCopy()}. + * @param bool $applied Whether the diff was applied + * (false for dry-run reports). + * @param string|null $error The failure message, or null. + * + * @return array The per-copy report row. + */ + private function summarizeCopy( + Dashboard $copy, + array $diff, + bool $applied, + ?string $error, + ): array { + return [ + 'dashboardId' => $copy->getId(), + 'dashboardUuid' => $copy->getUuid(), + 'userId' => $copy->getUserId(), + 'toAdd' => count($diff['toAdd']), + 'toUpdate' => count($diff['toUpdate']), + 'toRemove' => count($diff['toRemove']), + 'toPreserve' => count($diff['toPreserve']), + 'hasChanges' => $this->diffHasChanges(diff: $diff), + 'applied' => $applied, + 'error' => $error, + ]; + }//end summarizeCopy() + + /** + * Emit the single audit Activity event summarising the whole re-sync + * run (REQ-RESYNC-005 "Audit record is written on a real run"). + * + * Mirrors {@see BulkOperationService}'s audit pattern: one row via + * {@see ActivityPublisher::publish()} with a synthetic object identity + * and the operation's structured payload in `extraParams`. Any + * Activity failure is swallowed by the publisher so an audit-log + * problem never rolls back the re-sync itself. + * + * @param int $templateId The template's dashboard ID. + * @param string $templateName The template's human-readable name. + * @param string $strategy `'overwrite'` or `'merge'`. + * @param int $affectedCount The number of copies actually changed. + * @param string $actingAdminId The acting admin's NC user ID. + * + * @return void + */ + private function emitAuditEvent( + int $templateId, + string $templateName, + string $strategy, + int $affectedCount, + string $actingAdminId, + ): void { + try { + $this->activityPublisher->publish( + type: Extension::EVENT_UPDATED, + actorUserId: $actingAdminId, + recipientUserId: $actingAdminId, + dashboardUuid: 'template-resync-' . $templateId, + dashboardName: 'Template resync: ' . $templateName . ' (' . $strategy . ', ' . $affectedCount . ' copies)', + dashboardLink: '', + extraParams: [ + 'templateResync' => true, + 'templateId' => $templateId, + 'strategy' => $strategy, + 'affectedCount' => $affectedCount, + ] + ); + } catch (Throwable $t) { + $this->logger->warning( + message: 'launchpad.template_resync.audit_event_failed', + context: [ + 'templateId' => $templateId, + 'exception' => $t, + ] + ); + }//end try + }//end emitAuditEvent() + + /** + * Notify the copy's owner that an administrator updated their + * dashboard (REQ-RESYNC-005 "Affected users are notified"). + * + * Dispatched via the canonical Nextcloud `INotification` manager — + * the same pattern {@see DashboardShareService} uses for + * `dashboard_shared` / `dashboard_ownership_transferred`. LaunchPad + * has no OpenRegister install-time dependency (see the + * `admin-templates` storage policy), so the `x-openregister- + * notifications` dialect branch described in REQ-RESYNC-005 is not + * applicable here — this IS the "otherwise" branch. + * + * @param Dashboard $dashboard The user's provisioned copy. + * + * @return void + */ + private function notifyUserResync(Dashboard $dashboard): void { + $userId = (string)$dashboard->getUserId(); + if ($userId === '') { + return; + } + + try { + $notification = $this->notificationManager->createNotification(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $notification->setApp('launchpad') + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + ->setUser($userId) + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + ->setDateTime(new DateTime()) + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + ->setObject('dashboard', (string)$dashboard->getId()) + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + ->setSubject('dashboard_template_resynced', [(string)$dashboard->getName()]); + + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $this->notificationManager->notify($notification); + } catch (Throwable $t) { + $this->logger->warning( + message: 'launchpad.template_resync.notify_failed', + context: [ + 'dashboardId' => $dashboard->getId(), + 'userId' => $userId, + 'exception' => $t, + ] + ); + }//end try + }//end notifyUserResync() +}//end class diff --git a/lib/Service/TemplateService.php b/lib/Service/TemplateService.php index 6f85d08f6..b664d023b 100644 --- a/lib/Service/TemplateService.php +++ b/lib/Service/TemplateService.php @@ -28,222 +28,264 @@ /** * Service for managing admin dashboard templates. */ -class TemplateService -{ - /** - * Constructor - * - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param WidgetPlacementMapper $placementMapper Widget placement mapper. - * @param AdminTemplateService $adminTemplateService Routing resolver — - * single source of truth - * for - * `IGroupManager::getUserGroupIds` - * (REQ-TMPL-013). - */ - public function __construct( - private readonly DashboardMapper $dashboardMapper, - private readonly WidgetPlacementMapper $placementMapper, - private readonly AdminTemplateService $adminTemplateService, - ) { - }//end __construct() - - /** - * Get the applicable admin template for a user. - * - * @param string $userId The user ID. - * - * @return Dashboard|null The applicable template or null. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-7 - */ - public function getApplicableTemplate(string $userId): ?Dashboard - { - $templates = $this->dashboardMapper->findAdminTemplates(); - - // Group memberships are read through the routing resolver so the - // single-source-of-truth invariant (REQ-TMPL-013) holds. An empty - // result means either an unknown user OR a known user with no - // group memberships — in both cases we skip the per-template - // intersection scan and fall through to the default template - // lookup at the end of the method (preserving legacy behaviour). - $userGroups = $this->adminTemplateService->getUserGroupIdsFor( - userId: $userId - ); - - // Find template that matches user's groups. - foreach ($templates as $template) { - $targetGroups = $template->getTargetGroupsArray(); - - // Empty target groups means applies to all users. - if (empty($targetGroups) === true) { - continue; - // Check for more specific templates first. - } - - // Check if user is in any target group. - if (empty(array_intersect($userGroups, $targetGroups)) === false) { - return $template; - } - } - - // Return default template if exists. - try { - return $this->dashboardMapper->findDefaultTemplate(); - } catch (DoesNotExistException) { - return null; - } - }//end getApplicableTemplate() - - /** - * Create a user dashboard based on an admin template. - * - * @param string $userId The user ID. - * @param Dashboard $template The admin template. - * - * @return Dashboard The created dashboard. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-7 - */ - public function createDashboardFromTemplate( - string $userId, - Dashboard $template - ): Dashboard { - // Create user dashboard. - $dashboard = $this->buildDashboardFromTemplate( - userId: $userId, - template: $template - ); - - // Deactivate other dashboards. - $this->dashboardMapper->deactivateAllForUser(userId: $userId); - - $dashboard = $this->dashboardMapper->insert(entity: $dashboard); - - // Copy widget placements from template. - $this->copyTemplatePlacements( - templateId: $template->getId(), - dashboardId: $dashboard->getId() - ); - - return $dashboard; - }//end createDashboardFromTemplate() - - /** - * Build a dashboard entity from a template. - * - * @param string $userId The user ID. - * @param Dashboard $template The admin template. - * - * @return Dashboard The built dashboard entity. - */ - private function buildDashboardFromTemplate( - string $userId, - Dashboard $template - ): Dashboard { - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - $dashboard = new Dashboard(); - $dashboard->setUuid($this->generateUuid()); - $dashboard->setName($template->getName()); - $dashboard->setDescription( - $template->getDescription() - ); - $dashboard->setType(Dashboard::TYPE_USER); - $dashboard->setUserId($userId); - $dashboard->setBasedOnTemplate( - $template->getId() - ); - $dashboard->setGridColumns( - $template->getGridColumns() - ); - $dashboard->setPermissionLevel( - $template->getPermissionLevel() - ); - $dashboard->setIsActive(1); - $dashboard->setCreatedAt($now); - $dashboard->setUpdatedAt($now); - - return $dashboard; - }//end buildDashboardFromTemplate() - - /** - * Generate a v4 UUID using random_bytes (no external dependency). - * - * @return string A v4 UUID. - */ - private function generateUuid(): string - { - $data = random_bytes(length: 16); - $data[6] = chr((ord($data[6]) & 0x0F) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3F) | 0x80); - return vsprintf( - format: '%s%s-%s-%s-%s-%s%s%s', - values: str_split(string: bin2hex(string: $data), length: 4) - ); - }//end generateUuid() - - /** - * Copy widget placements from a template to a new dashboard. - * - * @param int $templateId The template dashboard ID. - * @param int $dashboardId The target dashboard ID. - * - * @return void - */ - private function copyTemplatePlacements( - int $templateId, - int $dashboardId - ): void { - $templatePlacements = $this->placementMapper->findByDashboardId( - dashboardId: $templateId - ); - - foreach ($templatePlacements as $templatePlacement) { - $placement = $this->clonePlacement( - source: $templatePlacement, - dashboardId: $dashboardId - ); - $this->placementMapper->insert(entity: $placement); - } - }//end copyTemplatePlacements() - - /** - * Clone a widget placement for a new dashboard. - * - * @param WidgetPlacement $source The source placement. - * @param int $dashboardId The target dashboard ID. - * - * @return WidgetPlacement The cloned placement entity. - */ - private function clonePlacement( - WidgetPlacement $source, - int $dashboardId - ): WidgetPlacement { - $placement = new WidgetPlacement(); - $placement->setDashboardId($dashboardId); - $placement->setWidgetId($source->getWidgetId()); - $placement->setGridX($source->getGridX()); - $placement->setGridY($source->getGridY()); - $placement->setGridWidth($source->getGridWidth()); - $placement->setGridHeight( - $source->getGridHeight() - ); - $placement->setIsCompulsory( - $source->getIsCompulsory() - ); - $placement->setIsVisible($source->getIsVisible()); - $placement->setStyleConfig( - $source->getStyleConfig() - ); - $placement->setCustomTitle( - $source->getCustomTitle() - ); - $placement->setShowTitle($source->getShowTitle()); - $placement->setSortOrder($source->getSortOrder()); - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - $placement->setCreatedAt($now); - $placement->setUpdatedAt($now); - - return $placement; - }//end clonePlacement() +class TemplateService { + /** + * Constructor + * + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param WidgetPlacementMapper $placementMapper Widget placement mapper. + * @param AdminTemplateService $adminTemplateService Routing resolver — + * single source of truth + * for + * `IGroupManager::getUserGroupIds` + * (REQ-TMPL-013). + */ + public function __construct( + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly AdminTemplateService $adminTemplateService, + ) { + }//end __construct() + + /** + * Get the applicable admin template for a user. + * + * @param string $userId The user ID. + * + * @return Dashboard|null The applicable template or null. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-7 + */ + public function getApplicableTemplate(string $userId): ?Dashboard { + $templates = $this->dashboardMapper->findAdminTemplates(); + + // Group memberships are read through the routing resolver so the + // single-source-of-truth invariant (REQ-TMPL-013) holds. An empty + // result means either an unknown user OR a known user with no + // group memberships — in both cases we skip the per-template + // intersection scan and fall through to the default template + // lookup at the end of the method (preserving legacy behaviour). + $userGroups = $this->adminTemplateService->getUserGroupIdsFor( + userId: $userId + ); + + // Find template that matches user's groups. + foreach ($templates as $template) { + $targetGroups = $template->getTargetGroupsArray(); + + // Empty target groups means applies to all users. + if (empty($targetGroups) === true) { + continue; + // Check for more specific templates first. + } + + // Check if user is in any target group. + if (empty(array_intersect($userGroups, $targetGroups)) === false) { + return $template; + } + } + + // Return default template if exists. + try { + return $this->dashboardMapper->findDefaultTemplate(); + } catch (DoesNotExistException) { + return null; + } + }//end getApplicableTemplate() + + /** + * Create a user dashboard based on an admin template. + * + * @param string $userId The user ID. + * @param Dashboard $template The admin template. + * + * @return Dashboard The created dashboard. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-7 + */ + public function createDashboardFromTemplate( + string $userId, + Dashboard $template, + ): Dashboard { + // Create user dashboard. + $dashboard = $this->buildDashboardFromTemplate( + userId: $userId, + template: $template + ); + + // Deactivate other dashboards. + $this->dashboardMapper->deactivateAllForUser(userId: $userId); + + $dashboard = $this->dashboardMapper->insert(entity: $dashboard); + + // Copy widget placements from template. + $this->copyTemplatePlacements( + templateId: $template->getId(), + dashboardId: $dashboard->getId() + ); + + return $dashboard; + }//end createDashboardFromTemplate() + + /** + * Build a dashboard entity from a template. + * + * @param string $userId The user ID. + * @param Dashboard $template The admin template. + * + * @return Dashboard The built dashboard entity. + */ + private function buildDashboardFromTemplate( + string $userId, + Dashboard $template, + ): Dashboard { + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $dashboard = new Dashboard(); + $dashboard->setUuid($this->generateUuid()); + $dashboard->setName($template->getName()); + $dashboard->setDescription( + $template->getDescription() + ); + $dashboard->setType(Dashboard::TYPE_USER); + $dashboard->setUserId($userId); + $dashboard->setBasedOnTemplate( + $template->getId() + ); + $dashboard->setGridColumns( + $template->getGridColumns() + ); + $dashboard->setPermissionLevel( + $template->getPermissionLevel() + ); + $dashboard->setIsActive(1); + $dashboard->setCreatedAt($now); + $dashboard->setUpdatedAt($now); + + return $dashboard; + }//end buildDashboardFromTemplate() + + /** + * Generate a v4 UUID using random_bytes (no external dependency). + * + * @return string A v4 UUID. + */ + private function generateUuid(): string { + $data = random_bytes(length: 16); + $data[6] = chr((ord($data[6]) & 0x0F) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3F) | 0x80); + return vsprintf( + format: '%s%s-%s-%s-%s-%s%s%s', + values: str_split(string: bin2hex(string: $data), length: 4) + ); + }//end generateUuid() + + /** + * Copy widget placements from a template to a new dashboard. + * + * @param int $templateId The template dashboard ID. + * @param int $dashboardId The target dashboard ID. + * + * @return void + */ + private function copyTemplatePlacements( + int $templateId, + int $dashboardId, + ): void { + $templatePlacements = $this->placementMapper->findByDashboardId( + dashboardId: $templateId + ); + + foreach ($templatePlacements as $templatePlacement) { + $placement = $this->clonePlacement( + source: $templatePlacement, + dashboardId: $dashboardId + ); + $this->placementMapper->insert(entity: $placement); + } + }//end copyTemplatePlacements() + + /** + * Clone a widget placement for a new dashboard. + * + * Copies every widget-, tile-, style- and grid-field byte-for-byte + * (mirrors {@see \OCA\LaunchPad\Db\WidgetPlacementMapper::cloneToDashboard()} + * so a first-access template copy and an owner-fork clone the exact + * same field set — a pre-existing gap where `content`, `customIcon`, + * and the `tile*` fields were silently dropped on template + * distribution, fixed while touching this method for + * `admin-template-resync`). + * + * Also stamps {@see WidgetPlacement::setTemplatePlacementId()} with the + * source template placement's `id` — the origin key + * {@see \OCA\LaunchPad\Service\TemplateResyncService} uses to tell + * template-origin placements apart from placements the user adds to + * their copy afterward (REQ-RESYNC-003 / REQ-RESYNC-004). + * + * @param WidgetPlacement $source The source placement. + * @param int $dashboardId The target dashboard ID. + * + * @return WidgetPlacement The cloned placement entity. + */ + private function clonePlacement( + WidgetPlacement $source, + int $dashboardId, + ): WidgetPlacement { + $placement = new WidgetPlacement(); + $placement->setDashboardId($dashboardId); + $placement->setWidgetId($source->getWidgetId()); + $placement->setGridX($source->getGridX()); + $placement->setGridY($source->getGridY()); + $placement->setGridWidth($source->getGridWidth()); + $placement->setGridHeight( + $source->getGridHeight() + ); + $placement->setIsCompulsory( + $source->getIsCompulsory() + ); + $placement->setIsVisible($source->getIsVisible()); + $placement->setStyleConfig( + $source->getStyleConfig() + ); + $placement->setCustomTitle( + $source->getCustomTitle() + ); + $placement->setCustomIcon($source->getCustomIcon()); + $placement->setShowTitle($source->getShowTitle()); + $placement->setSortOrder($source->getSortOrder()); + // REQ-DASH-020: `content` carries the widget configuration — for + // `nc-widget` rows the `{"widgetId": ...}` JSON that tells the + // renderer what to load. Dropping it distributes widgets into a + // sourceless "No items available" state. + $placement->setContent($source->getContent()); + // Tile fields — a template placement may be a tile (REQ-TMPL-007 + // "Template placements include tile data"); tileType is the + // discriminator gating jsonSerialize()'s tile block, so all tile + // columns must travel together. + $placement->setTileType($source->getTileType()); + $placement->setTileTitle($source->getTileTitle()); + $placement->setTileIcon($source->getTileIcon()); + $placement->setTileIconType($source->getTileIconType()); + $placement->setTileBackgroundColor($source->getTileBackgroundColor()); + $placement->setTileTextColor($source->getTileTextColor()); + $placement->setTileLinkType($source->getTileLinkType()); + $placement->setTileLinkValue($source->getTileLinkValue()); + // REQ-ACK-001: copy the acknowledgement requirement and the stable + // `announcementKey` so every recipient cloned from this template + // placement shares one announcement identity (design D2). + $placement->setRequiresAcknowledgement($source->getRequiresAcknowledgement()); + $placement->setAcknowledgementPrompt($source->getAcknowledgementPrompt()); + $placement->setAcknowledgementDeadline($source->getAcknowledgementDeadline()); + $placement->setReacknowledgeOnChange($source->getReacknowledgeOnChange()); + $placement->setAcknowledgementContentVersion($source->getAcknowledgementContentVersion()); + $placement->setAnnouncementKey($source->getAnnouncementKey()); + // REQ-RESYNC-003/004: stamp the template-origin key so a later + // admin re-sync can reconcile this placement against the template + // while leaving genuinely user-added placements alone. + $placement->setTemplatePlacementId($source->getId()); + $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); + $placement->setCreatedAt($now); + $placement->setUpdatedAt($now); + + return $placement; + }//end clonePlacement() }//end class diff --git a/lib/Service/TileAnalyticsService.php b/lib/Service/TileAnalyticsService.php new file mode 100644 index 000000000..3b6ed7326 --- /dev/null +++ b/lib/Service/TileAnalyticsService.php @@ -0,0 +1,340 @@ + + * @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 DateTimeImmutable; +use DateTimeZone; +use OCA\LaunchPad\Db\DashboardMapper; +use OCA\LaunchPad\Db\TileClickMapper; +use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Reporting service for the tile usage-analytics capability. + * + * @SuppressWarnings(PHPMD.StaticAccess) Calls only pure, stateless helpers — + * {@see AnalyticsService::periodToDateRange()} and + * {@see UniqueViewerDedup::utcDateFor()}. Both are dependency-free functions + * declared `public static`; injecting their owning classes purely to reach + * them would add two collaborators that are never otherwise used here. + */ +class TileAnalyticsService { + /** + * Constructor. + * + * @param TileClickMapper $tileClickMapper Aggregate-row mapper. + * @param WidgetPlacementMapper $placementMapper Resolves the placement + * (tile) that was + * clicked to its owning + * dashboard. + * @param DashboardMapper $dashboardMapper Resolves the owning + * dashboard's UUID. + * @param UniqueViewerDedup $dedup Reused unique-actor + * dedup service + * (REQ-TANLT-002). + * @param AnalyticsService $analyticsService Reused for + * `analytics_enabled` + * / + * `analytics_optout` + * / + * retention + * (REQ-TANLT-003, + * REQ-TANLT-005) + * — this + * service + * never + * reads + * `IAppConfig`/`IConfig` + * directly + * so there + * is exactly + * one place + * those + * gates + * live. + */ + public function __construct( + private readonly TileClickMapper $tileClickMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly DashboardMapper $dashboardMapper, + private readonly UniqueViewerDedup $dedup, + private readonly AnalyticsService $analyticsService, + ) { + }//end __construct() + + /** + * Report whether tile-click tracking is currently active for + * `$userId` — i.e. analytics is globally enabled AND the user has + * not opted out. Exposed so the frontend config endpoint can + * suppress the client-side hook without duplicating the gate + * logic (REQ-TANLT-003). + * + * @param string $userId The user identifier. + * + * @return bool `true` when clicks by this user would be recorded. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function isTrackingActiveFor(string $userId): bool { + if ($this->analyticsService->isGloballyEnabled() === false) { + return false; + } + + return ($this->analyticsService->isUserOptedOut(userId: $userId) === false); + }//end isTrackingActiveFor() + + /** + * Record a click on the tile at widget-placement `$placementId` + * by `$userId` (REQ-TANLT-001, REQ-TANLT-002, REQ-TANLT-003). + * + * Short-circuits to `false` (no-op — no counter change, no cache + * write, no per-event row) when: + * - global analytics is disabled (reused REQ-ANLT-005 gate); + * - the user has opted out (reused REQ-ANLT-004 gate). + * + * Always increments `clickCount` by 1 when the call proceeds; + * `uniqueActorCount` is incremented only when the dedup layer + * reports the actor as new for today on this placement. + * + * @param int $placementId The widget-placement (tile) ID. + * @param string $userId The clicking user identifier. + * + * @return bool `true` when a click was recorded, `false` when the + * call was short-circuited. + * + * @throws DoesNotExistException When the placement does not exist. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function recordClick(int $placementId, string $userId): bool { + // Existence guard surfaces a DoesNotExistException to the + // caller so the controller can return a clean 404, mirroring + // AnalyticsService::recordViewEvent()'s dashboard guard. + $placement = $this->placementMapper->find(id: $placementId); + $placementUuid = (string)$placementId; + + if ($this->analyticsService->isGloballyEnabled() === false) { + return false; + } + + if ($this->analyticsService->isUserOptedOut(userId: $userId) === true) { + return false; + } + + $dashboardUuid = ''; + try { + $dashboard = $this->dashboardMapper->find(id: $placement->getDashboardId()); + $dashboardUuid = (string)$dashboard->getUuid(); + } catch (DoesNotExistException) { + // Orphan placement — dashboard already deleted. Still record the + // click against the placement so no data is silently dropped; + // the row simply carries an empty dashboardUuid. + $dashboardUuid = ''; + } + + $today = UniqueViewerDedup::utcDateFor(); + $isNewActor = $this->dedup->isNewUniqueViewer( + userId: $userId, + viewBucketDate: $today, + dashboardUuid: $placementUuid + ); + $uniqueDelta = 0; + if ($isNewActor === true) { + $uniqueDelta = 1; + } + + $this->tileClickMapper->upsertClick( + placementUuid: $placementUuid, + dashboardUuid: $dashboardUuid, + clickBucket: $today, + clickCountDelta: 1, + uniqueCountDelta: $uniqueDelta + ); + + return true; + }//end recordClick() + + /** + * Resolve the top-N tiles by total click count for the supplied + * period (REQ-TANLT-004). + * + * @param string $period The period string (`7d`, `30d`, `90d`). + * @param int $limit Maximum rows. + * + * @return array + * Top-N tiles sorted by `clickCount` descending. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function getTopTiles(string $period, int $limit): array { + [$startDate, $endDate] = AnalyticsService::periodToDateRange(period: $period); + + return $this->tileClickMapper->findTopTilesInRange( + startDate: $startDate, + endDate: $endDate, + limit: $limit + ); + }//end getTopTiles() + + /** + * Return the per-tile breakdown for one dashboard + * (REQ-TANLT-004 — "per-dashboard tile breakdown"). + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $period The period string. + * + * @return array + * Per-tile totals sorted by `clickCount` descending. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function getDashboardBreakdown( + string $dashboardUuid, + string $period, + ): array { + [$startDate, $endDate] = AnalyticsService::periodToDateRange(period: $period); + + return $this->tileClickMapper->findByDashboardInRange( + dashboardUuid: $dashboardUuid, + startDate: $startDate, + endDate: $endDate + ); + }//end getDashboardBreakdown() + + /** + * Generate a CSV export of every aggregate row in the supplied + * period (REQ-TANLT-005). Header row first, sorted by + * `(placementUuid, clickBucket)` ascending. + * + * @param string $period The period string. + * + * @return string The CSV body (CRLF line endings). + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function generateCsvExport(string $period): string { + [$startDate, $endDate] = AnalyticsService::periodToDateRange(period: $period); + + $rows = $this->tileClickMapper->findAllInRange( + startDate: $startDate, + endDate: $endDate + ); + + $output = []; + $output[] = self::csvLine( + cells: [ + 'placementUuid', + 'dashboardUuid', + 'clickBucket', + 'clickCount', + 'uniqueActorCount', + ] + ); + + foreach ($rows as $row) { + $output[] = self::csvLine( + cells: [ + (string)$row->getPlacementUuid(), + (string)$row->getDashboardUuid(), + (string)$row->getClickBucket(), + (string)$row->getClickCount(), + (string)$row->getUniqueActorCount(), + ] + ); + } + + return implode(separator: "\r\n", array: $output) . "\r\n"; + }//end generateCsvExport() + + /** + * Compute a filename suitable for the CSV export attachment + * (REQ-TANLT-005 scenario "CSV export contains tile statistics"). + * + * @return string The filename in the form + * `tile-analytics-YYYY-MM-DD.csv`. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function csvExportFilename(): string { + $today = (new DateTimeImmutable('now')) + ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')) + ->format(format: 'Y-m-d'); + + return 'tile-analytics-' . $today . '.csv'; + }//end csvExportFilename() + + /** + * Render one CSV line from the supplied cells. Quotes every + * cell, escaping embedded quotes per RFC 4180 and prefixing + * spreadsheet-formula trigger characters (mirrors + * `AnalyticsService::csvLine()` — M1 CSV injection guard). + * + * @param string[] $cells The raw cell values. + * + * @return string The CSV-encoded line (no trailing newline). + */ + private static function csvLine(array $cells): string { + $escaped = array_map( + callback: static function (string $cell): string { + $formula = ['=', '+', '-', '@', "\t", "\r"]; + if ($cell !== '' && in_array(needle: $cell[0], haystack: $formula, strict: true) === true) { + $cell = "'" . $cell; + } + + return '"' . str_replace( + search: '"', + replace: '""', + subject: $cell + ) . '"'; + }, + array: $cells + ); + + return implode(separator: ',', array: $escaped); + }//end csvLine() +}//end class diff --git a/lib/Service/TileService.php b/lib/Service/TileService.php index f9c756b58..ace59f2ce 100644 --- a/lib/Service/TileService.php +++ b/lib/Service/TileService.php @@ -3,7 +3,24 @@ /** * TileService * - * Service for managing tiles. + * Read-only access to the legacy `oc_launchpad_tiles` reusable-entity table. + * + * This service used to carry createTile/updateTile/deleteTile as well. All + * three were removed: the endpoints above them — TileApiController's create, + * update and destroy — return HTTP 410 Gone unconditionally and never called + * them, and nothing else in the app, the CLI commands or the migrations did + * either. Their docblocks claimed they were "preserved for legacy callers and + * migration tooling", and there were none of either. + * + * That is not merely dead code. A write path with no caller, sitting under a + * permanently-410 endpoint, is a way to put rows back into a table the app has + * deliberately stopped writing to (REQ-WDG-022 / REQ-TILE-PLACEMENT moved tile + * creation onto widget placements) — the deprecation would be bypassed by + * whoever wired it up next, with nothing to warn them. + * + * The table is still READ: getUserTiles backs `GET /api/tiles`, so existing + * rows stay visible. Read-only is the intended end state, and it is now the + * only state this class can express. * * @category Service * @package OCA\LaunchPad\Service @@ -12,183 +29,38 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @version GIT:auto * @link https://conduction.nl + * + * @spec openspec/specs/tiles/spec.md */ declare(strict_types=1); namespace OCA\LaunchPad\Service; -use DateTime; use OCA\LaunchPad\Db\Tile; use OCA\LaunchPad\Db\TileMapper; -class TileService -{ - /** - * Constructor - * - * @param TileMapper $tileMapper The tile mapper. - */ - public function __construct( - private readonly TileMapper $tileMapper, - ) { - }//end __construct() - - /** - * Get all tiles for a user. - * - * @param string $userId The user ID. - * - * @return Tile[] Array of tiles. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-29 - */ - public function getUserTiles(string $userId): array - { - return $this->tileMapper->findByUserId(userId: $userId); - }//end getUserTiles() - - /** - * Create a new tile. - * - * @param string $userId The user ID. - * @param string $title The tile title. - * @param string $icon The icon (class, URL, or emoji). - * @param string $iconType The icon type (class, url, or emoji). - * @param string $backgroundColor The background color (hex). - * @param string $textColor The text color (hex). - * @param string $linkType The link type (app or url). - * @param string $linkValue The link value (app ID or URL). - * - * @return Tile The created tile. - * - * @deprecated 1.0 Use the unified add-widget flow with `type: tile` - * (REQ-WDG-022 / REQ-TILE-PLACEMENT) instead. New tile - * placements MUST be created via - * `POST /api/dashboards/{uuid}/widgets` with the - * widget content stored inline on the placement; the - * `oc_launchpad_tiles` reusable-entity table is being - * phased out. This method is preserved for legacy - * callers and migration tooling only and MUST NOT be - * invoked by new code paths. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-28 - */ - public function createTile( - string $userId, - string $title, - string $icon, - string $iconType='class', - string $backgroundColor='#0082c9', - string $textColor='#ffffff', - string $linkType='url', - string $linkValue='#' - ): Tile { - $now = (new DateTime())->format(format: 'Y-m-d H:i:s'); - - $tile = new Tile(); - $tile->setUserId($userId); - $tile->setTitle($title); - $tile->setIcon($icon); - $tile->setIconType($iconType); - $tile->setBackgroundColor($backgroundColor); - $tile->setTextColor($textColor); - $tile->setLinkType($linkType); - $tile->setLinkValue($linkValue); - $tile->setCreatedAt($now); - $tile->setUpdatedAt($now); - - return $this->tileMapper->insert(entity: $tile); - }//end createTile() - - /** - * Update a tile. - * - * @param int $id The tile ID. - * @param string $userId The user ID. - * @param array $data The data to update. - * - * @return Tile The updated tile. - * @throws \OCP\AppFramework\Db\DoesNotExistException If tile not found. - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException If multiple found. - * - * @deprecated 1.0 Use the unified add-widget flow with `type: tile` - * (REQ-WDG-022 / REQ-TILE-PLACEMENT) instead. Tile - * placement edits MUST go through the standard - * widget-placement update endpoint; the reusable - * tile-entity table is read-only going forward. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-30 - */ - public function updateTile(int $id, string $userId, array $data): Tile - { - $tile = $this->tileMapper->findByIdAndUser( - id: $id, - userId: $userId - ); - - if (isset($data['title']) === true) { - $tile->setTitle($data['title']); - } - - if (isset($data['icon']) === true) { - $tile->setIcon($data['icon']); - } - - if (isset($data['iconType']) === true) { - $tile->setIconType($data['iconType']); - } - - if (isset($data['backgroundColor']) === true) { - $tile->setBackgroundColor( - $data['backgroundColor'] - ); - } - - if (isset($data['textColor']) === true) { - $tile->setTextColor($data['textColor']); - } - - if (isset($data['linkType']) === true) { - $tile->setLinkType($data['linkType']); - } - - if (isset($data['linkValue']) === true) { - $tile->setLinkValue($data['linkValue']); - } - - $tile->setUpdatedAt( - (new DateTime())->format(format: 'Y-m-d H:i:s') - ); - - return $this->tileMapper->update(entity: $tile); - }//end updateTile() - - /** - * Delete a tile. - * - * @param int $id The tile ID. - * @param string $userId The user ID. - * - * @return void - * @throws \OCP\AppFramework\Db\DoesNotExistException If tile not found. - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException If multiple found. - * - * @deprecated 1.0 Use the unified add-widget flow with `type: tile` - * (REQ-WDG-022 / REQ-TILE-PLACEMENT) instead. The - * controller's destroy endpoint now returns HTTP 410 - * Gone and this service method is preserved for - * migration tooling that needs to clear out legacy - * rows server-side. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-31 - */ - public function deleteTile(int $id, string $userId): void - { - $tile = $this->tileMapper->findByIdAndUser( - id: $id, - userId: $userId - ); - $this->tileMapper->delete(entity: $tile); - }//end deleteTile() +class TileService { + /** + * Constructor + * + * @param TileMapper $tileMapper The tile mapper. + */ + public function __construct( + private readonly TileMapper $tileMapper, + ) { + }//end __construct() + + /** + * Get all tiles for a user. + * + * @param string $userId The user ID. + * + * @return Tile[] Array of tiles. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-29 + */ + public function getUserTiles(string $userId): array { + return $this->tileMapper->findByUserId(userId: $userId); + }//end getUserTiles() }//end class diff --git a/lib/Service/TileUpdater.php b/lib/Service/TileUpdater.php index f4110ab9b..133e6acd5 100644 --- a/lib/Service/TileUpdater.php +++ b/lib/Service/TileUpdater.php @@ -23,96 +23,95 @@ /** * Service for applying tile-specific updates to widget placements. */ -class TileUpdater -{ - /** - * Apply tile configuration to a new placement entity. - * - * @param WidgetPlacement $placement The placement entity. - * @param array $tileData The tile configuration data. - * - * @return void - * - * @spec openspec/specs/tiles/spec.md - */ - public function applyTileConfig( - WidgetPlacement $placement, - array $tileData - ): void { - $placement->setTileType('custom'); - $placement->setTileTitle( - $tileData['title'] ?? 'New Tile' - ); - $placement->setTileIcon( - $tileData['icon'] ?? 'icon-link' - ); - $placement->setTileIconType( - $tileData['iconType'] ?? 'class' - ); - $placement->setTileBackgroundColor( - $tileData['bgColor'] ?? '#0082c9' - ); - $placement->setTileTextColor( - $tileData['txtColor'] ?? '#ffffff' - ); - $placement->setTileLinkType( - $tileData['linkType'] ?? 'app' - ); - $placement->setTileLinkValue( - $tileData['linkVal'] ?? '' - ); - }//end applyTileConfig() +class TileUpdater { + /** + * Apply tile configuration to a new placement entity. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $tileData The tile configuration data. + * + * @return void + * + * @spec openspec/specs/tiles/spec.md + */ + public function applyTileConfig( + WidgetPlacement $placement, + array $tileData, + ): void { + $placement->setTileType('custom'); + $placement->setTileTitle( + $tileData['title'] ?? 'New Tile' + ); + $placement->setTileIcon( + $tileData['icon'] ?? 'icon-link' + ); + $placement->setTileIconType( + $tileData['iconType'] ?? 'class' + ); + $placement->setTileBackgroundColor( + $tileData['bgColor'] ?? '#0082c9' + ); + $placement->setTileTextColor( + $tileData['txtColor'] ?? '#ffffff' + ); + $placement->setTileLinkType( + $tileData['linkType'] ?? 'app' + ); + $placement->setTileLinkValue( + $tileData['linkVal'] ?? '' + ); + }//end applyTileConfig() - /** - * Apply tile-specific field updates to a placement. - * - * @param WidgetPlacement $placement The placement entity. - * @param array $data The update data. - * - * @return void - * - * @spec openspec/specs/tiles/spec.md - */ - public function applyTileUpdates( - WidgetPlacement $placement, - array $data - ): void { - if (isset($data['tileTitle']) === true) { - $placement->setTileTitle($data['tileTitle']); - } + /** + * Apply tile-specific field updates to a placement. + * + * @param WidgetPlacement $placement The placement entity. + * @param array $data The update data. + * + * @return void + * + * @spec openspec/specs/tiles/spec.md + */ + public function applyTileUpdates( + WidgetPlacement $placement, + array $data, + ): void { + if (isset($data['tileTitle']) === true) { + $placement->setTileTitle($data['tileTitle']); + } - if (isset($data['tileIcon']) === true) { - $placement->setTileIcon($data['tileIcon']); - } + if (isset($data['tileIcon']) === true) { + $placement->setTileIcon($data['tileIcon']); + } - if (isset($data['tileIconType']) === true) { - $placement->setTileIconType( - $data['tileIconType'] - ); - } + if (isset($data['tileIconType']) === true) { + $placement->setTileIconType( + $data['tileIconType'] + ); + } - if (isset($data['tileBackgroundColor']) === true) { - $placement->setTileBackgroundColor( - $data['tileBackgroundColor'] - ); - } + if (isset($data['tileBackgroundColor']) === true) { + $placement->setTileBackgroundColor( + $data['tileBackgroundColor'] + ); + } - if (isset($data['tileTextColor']) === true) { - $placement->setTileTextColor( - $data['tileTextColor'] - ); - } + if (isset($data['tileTextColor']) === true) { + $placement->setTileTextColor( + $data['tileTextColor'] + ); + } - if (isset($data['tileLinkType']) === true) { - $placement->setTileLinkType( - $data['tileLinkType'] - ); - } + if (isset($data['tileLinkType']) === true) { + $placement->setTileLinkType( + $data['tileLinkType'] + ); + } - if (isset($data['tileLinkValue']) === true) { - $placement->setTileLinkValue( - $data['tileLinkValue'] - ); - } - }//end applyTileUpdates() + if (isset($data['tileLinkValue']) === true) { + $placement->setTileLinkValue( + $data['tileLinkValue'] + ); + } + }//end applyTileUpdates() }//end class diff --git a/lib/Service/UniqueViewerDedup.php b/lib/Service/UniqueViewerDedup.php index 81494ddd8..8ad8ce20e 100644 --- a/lib/Service/UniqueViewerDedup.php +++ b/lib/Service/UniqueViewerDedup.php @@ -27,8 +27,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); @@ -45,265 +45,260 @@ * Daily-rotating salted-hash dedup for the dashboard view-analytics * capability (REQ-ANLT-003). */ -class UniqueViewerDedup -{ - /** - * App-config key under which the daily salt is persisted - * (REQ-ANLT-003 design D2). - * - * @var string - */ - public const CONFIG_KEY_SALT = 'analytics_dailysalt'; +class UniqueViewerDedup { + /** + * App-config key under which the daily salt is persisted + * (REQ-ANLT-003 design D2). + * + * @var string + */ + public const CONFIG_KEY_SALT = 'analytics_dailysalt'; - /** - * App-config key under which the salt's UTC date marker is - * persisted. Used to detect that the cached salt belongs to a - * previous day so the lazy-rotation path can refresh it without - * waiting for the cron. - * - * @var string - */ - public const CONFIG_KEY_SALT_DATE = 'analytics_dailysalt_date'; + /** + * App-config key under which the salt's UTC date marker is + * persisted. Used to detect that the cached salt belongs to a + * previous day so the lazy-rotation path can refresh it without + * waiting for the cron. + * + * @var string + */ + public const CONFIG_KEY_SALT_DATE = 'analytics_dailysalt_date'; - /** - * Cache namespace for dedup hashes. - * - * @var string - */ - public const CACHE_NAMESPACE = 'launchpad_anlt'; + /** + * Cache namespace for dedup hashes. + * + * @var string + */ + public const CACHE_NAMESPACE = 'launchpad_anlt'; - /** - * Concrete cache instance, lazily resolved from the cache - * factory the first time it is needed. - * - * @var ICache|null - */ - private ?ICache $cache = null; + /** + * Concrete cache instance, lazily resolved from the cache + * factory the first time it is needed. + * + * @var ICache|null + */ + private ?ICache $cache = null; - /** - * Constructor. - * - * @param ICacheFactory $cacheFactory The Nextcloud cache factory. - * @param IAppConfig $appConfig The app config service. - */ - public function __construct( - private readonly ICacheFactory $cacheFactory, - private readonly IAppConfig $appConfig, - ) { - }//end __construct() + /** + * Constructor. + * + * @param ICacheFactory $cacheFactory The Nextcloud cache factory. + * @param IAppConfig $appConfig The app config service. + */ + public function __construct( + private readonly ICacheFactory $cacheFactory, + private readonly IAppConfig $appConfig, + ) { + }//end __construct() - /** - * Compute the UTC date string `YYYY-MM-DD` for the supplied - * timestamp (defaults to "now"). - * - * @param DateTimeImmutable|null $when Optional reference timestamp. - * - * @return string The UTC date in `YYYY-MM-DD` format. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public static function utcDateFor(?DateTimeImmutable $when=null): string - { - $reference = ($when ?? new DateTimeImmutable('now')) - ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); + /** + * Compute the UTC date string `YYYY-MM-DD` for the supplied + * timestamp (defaults to "now"). + * + * @param DateTimeImmutable|null $when Optional reference timestamp. + * + * @return string The UTC date in `YYYY-MM-DD` format. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public static function utcDateFor(?DateTimeImmutable $when = null): string { + $reference = ($when ?? new DateTimeImmutable('now')) + ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); - return $reference->format(format: 'Y-m-d'); - }//end utcDateFor() + return $reference->format(format: 'Y-m-d'); + }//end utcDateFor() - /** - * Compute the number of seconds remaining until the next UTC - * midnight from `$when`. The result is always strictly positive - * (a minimum of 1 is enforced so cache entries do not expire - * instantly). - * - * @param DateTimeImmutable|null $when Optional reference timestamp. - * - * @return int The TTL in seconds. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public static function secondsUntilNextUtcMidnight( - ?DateTimeImmutable $when=null - ): int { - $reference = ($when ?? new DateTimeImmutable('now')) - ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); + /** + * Compute the number of seconds remaining until the next UTC + * midnight from `$when`. The result is always strictly positive + * (a minimum of 1 is enforced so cache entries do not expire + * instantly). + * + * @param DateTimeImmutable|null $when Optional reference timestamp. + * + * @return int The TTL in seconds. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public static function secondsUntilNextUtcMidnight( + ?DateTimeImmutable $when = null, + ): int { + $reference = ($when ?? new DateTimeImmutable('now')) + ->setTimezone(timezone: new DateTimeZone(timezone: 'UTC')); - $nextMidnight = $reference - ->modify(modifier: 'tomorrow') - ->setTime(hour: 0, minute: 0, second: 0); + $nextMidnight = $reference + ->modify(modifier: 'tomorrow') + ->setTime(hour: 0, minute: 0, second: 0); - $delta = ($nextMidnight->getTimestamp() - $reference->getTimestamp()); + $delta = ($nextMidnight->getTimestamp() - $reference->getTimestamp()); - if ($delta < 1) { - return 1; - } + if ($delta < 1) { + return 1; + } - return $delta; - }//end secondsUntilNextUtcMidnight() + return $delta; + }//end secondsUntilNextUtcMidnight() - /** - * Retrieve (or lazily generate) the daily salt for the supplied - * UTC date. When the persisted salt's date marker does not match - * `$viewBucketDate`, a fresh 32-byte random value is generated, - * the previous salt is overwritten without history, and the new - * value is returned (REQ-ANLT-003 design D2). - * - * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. - * - * @return string The salt as a hex string. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public function getSaltForDate(string $viewBucketDate): string - { - $existingSalt = $this->appConfig->getValueString( - 'launchpad', - self::CONFIG_KEY_SALT, - '' - ); - $existingDate = $this->appConfig->getValueString( - 'launchpad', - self::CONFIG_KEY_SALT_DATE, - '' - ); + /** + * Retrieve (or lazily generate) the daily salt for the supplied + * UTC date. When the persisted salt's date marker does not match + * `$viewBucketDate`, a fresh 32-byte random value is generated, + * the previous salt is overwritten without history, and the new + * value is returned (REQ-ANLT-003 design D2). + * + * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. + * + * @return string The salt as a hex string. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function getSaltForDate(string $viewBucketDate): string { + $existingSalt = $this->appConfig->getValueString( + 'launchpad', + self::CONFIG_KEY_SALT, + '' + ); + $existingDate = $this->appConfig->getValueString( + 'launchpad', + self::CONFIG_KEY_SALT_DATE, + '' + ); - if ($existingSalt !== '' && $existingDate === $viewBucketDate) { - return $existingSalt; - } + if ($existingSalt !== '' && $existingDate === $viewBucketDate) { + return $existingSalt; + } - return $this->rotateSalt(viewBucketDate: $viewBucketDate); - }//end getSaltForDate() + return $this->rotateSalt(viewBucketDate: $viewBucketDate); + }//end getSaltForDate() - /** - * Force a salt rotation for the supplied UTC date. Generates a - * fresh 32-byte random value, overwrites the previous salt with - * no history kept, and returns the new value. Called eagerly by - * the {@see \OCA\LaunchPad\BackgroundJob\SaltRotationJob} and - * lazily by {@see self::getSaltForDate()} when the persisted - * date marker is stale. - * - * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. - * - * @return string The new salt as a hex string. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public function rotateSalt(string $viewBucketDate): string - { - $newSalt = bin2hex(string: random_bytes(length: 32)); - $this->appConfig->setValueString( - 'launchpad', - self::CONFIG_KEY_SALT, - $newSalt - ); - $this->appConfig->setValueString( - 'launchpad', - self::CONFIG_KEY_SALT_DATE, - $viewBucketDate - ); + /** + * Force a salt rotation for the supplied UTC date. Generates a + * fresh 32-byte random value, overwrites the previous salt with + * no history kept, and returns the new value. Called eagerly by + * the {@see \OCA\LaunchPad\BackgroundJob\SaltRotationJob} and + * lazily by {@see self::getSaltForDate()} when the persisted + * date marker is stale. + * + * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. + * + * @return string The new salt as a hex string. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function rotateSalt(string $viewBucketDate): string { + $newSalt = bin2hex(string: random_bytes(length: 32)); + $this->appConfig->setValueString( + 'launchpad', + self::CONFIG_KEY_SALT, + $newSalt + ); + $this->appConfig->setValueString( + 'launchpad', + self::CONFIG_KEY_SALT_DATE, + $viewBucketDate + ); - return $newSalt; - }//end rotateSalt() + return $newSalt; + }//end rotateSalt() - /** - * Compute the deterministic SHA-256 hex hash of `userId` salted - * with the supplied date's daily salt. - * - * @param string $userId The user identifier (hashed - * before storage; raw string fine). - * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. - * - * @return string The 64-char hex SHA-256 digest. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public function hashUserForDate( - string $userId, - string $viewBucketDate - ): string { - $salt = $this->getSaltForDate(viewBucketDate: $viewBucketDate); + /** + * Compute the deterministic SHA-256 hex hash of `userId` salted + * with the supplied date's daily salt. + * + * @param string $userId The user identifier (hashed + * before storage; raw string fine). + * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. + * + * @return string The 64-char hex SHA-256 digest. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function hashUserForDate( + string $userId, + string $viewBucketDate, + ): string { + $salt = $this->getSaltForDate(viewBucketDate: $viewBucketDate); - return hash(algo: 'sha256', data: $userId.'|'.$salt); - }//end hashUserForDate() + return hash(algo: 'sha256', data: $userId . '|' . $salt); + }//end hashUserForDate() - /** - * Determine whether the supplied `(userId, viewBucketDate, - * dashboardUuid)` tuple represents a NEW unique viewer for that - * day. If so, the marker is stored in cache (TTL = seconds until - * the next UTC midnight) and `true` is returned; otherwise - * `false` is returned and no DB increment should follow - * (REQ-ANLT-003). - * - * @param string $userId The user identifier. - * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. - * @param string $dashboardUuid The dashboard UUID being viewed. - * - * @return bool `true` when the user is a new unique viewer for - * this dashboard today, `false` when they have - * already been counted. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public function isNewUniqueViewer( - string $userId, - string $viewBucketDate, - string $dashboardUuid - ): bool { - $hash = $this->hashUserForDate( - userId: $userId, - viewBucketDate: $viewBucketDate - ); - $cacheKey = $this->buildCacheKey( - dashboardUuid: $dashboardUuid, - viewerHash: $hash - ); + /** + * Determine whether the supplied `(userId, viewBucketDate, + * dashboardUuid)` tuple represents a NEW unique viewer for that + * day. If so, the marker is stored in cache (TTL = seconds until + * the next UTC midnight) and `true` is returned; otherwise + * `false` is returned and no DB increment should follow + * (REQ-ANLT-003). + * + * @param string $userId The user identifier. + * @param string $viewBucketDate The UTC date `YYYY-MM-DD`. + * @param string $dashboardUuid The dashboard UUID being viewed. + * + * @return bool `true` when the user is a new unique viewer for + * this dashboard today, `false` when they have + * already been counted. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function isNewUniqueViewer( + string $userId, + string $viewBucketDate, + string $dashboardUuid, + ): bool { + $hash = $this->hashUserForDate( + userId: $userId, + viewBucketDate: $viewBucketDate + ); + $cacheKey = $this->buildCacheKey( + dashboardUuid: $dashboardUuid, + viewerHash: $hash + ); - $cache = $this->getCache(); + $cache = $this->getCache(); - if ($cache->hasKey(key: $cacheKey) === true) { - return false; - } + if ($cache->hasKey(key: $cacheKey) === true) { + return false; + } - $cache->set( - key: $cacheKey, - value: '1', - ttl: self::secondsUntilNextUtcMidnight() - ); + $cache->set( + key: $cacheKey, + value: '1', + ttl: self::secondsUntilNextUtcMidnight() + ); - return true; - }//end isNewUniqueViewer() + return true; + }//end isNewUniqueViewer() - /** - * Build the canonical dedup cache key for `(dashboardUuid, - * viewerHash)`. - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $viewerHash The 64-char hex SHA-256 digest. - * - * @return string The cache key. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - public function buildCacheKey( - string $dashboardUuid, - string $viewerHash - ): string { - return $dashboardUuid.'_'.$viewerHash; - }//end buildCacheKey() + /** + * Build the canonical dedup cache key for `(dashboardUuid, + * viewerHash)`. + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $viewerHash The 64-char hex SHA-256 digest. + * + * @return string The cache key. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + public function buildCacheKey( + string $dashboardUuid, + string $viewerHash, + ): string { + return $dashboardUuid . '_' . $viewerHash; + }//end buildCacheKey() - /** - * Lazily resolve the underlying ICache instance (memcache when - * available, otherwise the local in-process fallback). - * - * @return ICache The cache instance. - */ - private function getCache(): ICache - { - if ($this->cache === null) { - $this->cache = $this->cacheFactory - ->createDistributed(prefix: self::CACHE_NAMESPACE.'_'); - } + /** + * Lazily resolve the underlying ICache instance (memcache when + * available, otherwise the local in-process fallback). + * + * @return ICache The cache instance. + */ + private function getCache(): ICache { + if ($this->cache === null) { + $this->cache = $this->cacheFactory + ->createDistributed(prefix: self::CACHE_NAMESPACE . '_'); + } - return $this->cache; - }//end getCache() + return $this->cache; + }//end getCache() }//end class diff --git a/lib/Service/UrlSafetyValidator.php b/lib/Service/UrlSafetyValidator.php index 5c22bce1e..21f4be994 100644 --- a/lib/Service/UrlSafetyValidator.php +++ b/lib/Service/UrlSafetyValidator.php @@ -1,4 +1,5 @@ + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -32,113 +33,110 @@ * @spec openspec/specs/news-widget/spec.md * @spec openspec/specs/calendar-widget/spec.md */ -class UrlSafetyValidator -{ - /** - * Constructor. - * - * @param IAppConfig $appConfig App configuration (used by checkAllowList). - */ - public function __construct( - private readonly IAppConfig $appConfig, - ) { - }//end __construct() - - /** - * Validate an outbound URL against SSRF rules. - * - * Accepts only HTTPS URLs whose hostname resolves exclusively to - * public IPs (no private/reserved/loopback ranges). - * - * @param string $url The URL to validate. - * - * @return bool True when the URL passes all checks. - * - * @spec openspec/changes/launchpad-legacy-quality-cleanup/tasks.md#task-1 - */ - public function isSafe(string $url): bool - { - $parts = parse_url(url: $url); - if (is_array(value: $parts) === false) { - return false; - } - - if (($parts['scheme'] ?? '') !== 'https') { - return false; - } - - $host = (string) ($parts['host'] ?? ''); - if ($host === '') { - return false; - } - - $ips = gethostbynamel(hostname: $host); - if ($ips === false || $ips === []) { - return false; - } - - foreach ($ips as $ip) { - $publicIp = filter_var( - value: $ip, - filter: FILTER_VALIDATE_IP, - options: (FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) - ); - if ($publicIp === false) { - return false; - } - } - - return true; - }//end isSafe() - - /** - * Check whether the URL host is permitted by an admin allow-list - * stored in IAppConfig. - * - * An empty / missing list means ALL hosts are allowed (open policy). - * When the list is non-empty the host MUST appear in it (exact, - * case-insensitive; no wildcard subdomain expansion). - * - * @param string $url The URL to check. - * @param string $appId The app whose config key to read. - * @param string $configKey The IAppConfig key holding the JSON - * array of allowed hostnames. - * - * @return bool True when the host passes the allow-list check. - * - * @spec openspec/changes/launchpad-legacy-quality-cleanup/tasks.md#task-1 - */ - public function checkAllowList(string $url, string $appId, string $configKey): bool - { - $raw = $this->appConfig->getValueString( - app: $appId, - key: $configKey, - default: '' - ); - - if (trim(string: $raw) === '') { - return true; - } - - $decoded = json_decode(json: $raw, associative: true); - if (is_array(value: $decoded) === false || $decoded === []) { - return true; - } - - $host = parse_url(url: $url, component: PHP_URL_HOST); - if (is_string(value: $host) === false || $host === '') { - 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 checkAllowList() +class UrlSafetyValidator { + /** + * Constructor. + * + * @param IAppConfig $appConfig App configuration (used by checkAllowList). + */ + public function __construct( + private readonly IAppConfig $appConfig, + ) { + }//end __construct() + + /** + * Validate an outbound URL against SSRF rules. + * + * Accepts only HTTPS URLs whose hostname resolves exclusively to + * public IPs (no private/reserved/loopback ranges). + * + * @param string $url The URL to validate. + * + * @return bool True when the URL passes all checks. + * + * @spec openspec/changes/launchpad-legacy-quality-cleanup/tasks.md#task-1 + */ + public function isSafe(string $url): bool { + $parts = parse_url(url: $url); + if (is_array(value: $parts) === false) { + return false; + } + + if (($parts['scheme'] ?? '') !== 'https') { + return false; + } + + $host = (string)($parts['host'] ?? ''); + if ($host === '') { + return false; + } + + $ips = gethostbynamel(hostname: $host); + if ($ips === false || $ips === []) { + return false; + } + + foreach ($ips as $ip) { + $publicIp = filter_var( + value: $ip, + filter: FILTER_VALIDATE_IP, + options: (FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) + ); + if ($publicIp === false) { + return false; + } + } + + return true; + }//end isSafe() + + /** + * Check whether the URL host is permitted by an admin allow-list + * stored in IAppConfig. + * + * An empty / missing list means ALL hosts are allowed (open policy). + * When the list is non-empty the host MUST appear in it (exact, + * case-insensitive; no wildcard subdomain expansion). + * + * @param string $url The URL to check. + * @param string $appId The app whose config key to read. + * @param string $configKey The IAppConfig key holding the JSON + * array of allowed hostnames. + * + * @return bool True when the host passes the allow-list check. + * + * @spec openspec/changes/launchpad-legacy-quality-cleanup/tasks.md#task-1 + */ + public function checkAllowList(string $url, string $appId, string $configKey): bool { + $raw = $this->appConfig->getValueString( + app: $appId, + key: $configKey, + default: '' + ); + + if (trim(string: $raw) === '') { + return true; + } + + $decoded = json_decode(json: $raw, associative: true); + if (is_array(value: $decoded) === false || $decoded === []) { + return true; + } + + $host = parse_url(url: $url, component: PHP_URL_HOST); + if (is_string(value: $host) === false || $host === '') { + 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 checkAllowList() }//end class diff --git a/lib/Service/UserAttributeResolver.php b/lib/Service/UserAttributeResolver.php index b39cfe95c..cd51984a6 100644 --- a/lib/Service/UserAttributeResolver.php +++ b/lib/Service/UserAttributeResolver.php @@ -26,90 +26,89 @@ /** * Service for resolving user attribute values and evaluating operators. */ -class UserAttributeResolver -{ - /** - * Constructor - * - * @param IUserManager $userManager The user manager interface. - * @param IConfig $config Config service for per-user prefs (e.g. language). - */ - public function __construct( - private readonly IUserManager $userManager, - private readonly IConfig $config, - ) { - }//end __construct() +class UserAttributeResolver { + /** + * Constructor + * + * @param IUserManager $userManager The user manager interface. + * @param IConfig $config Config service for per-user prefs (e.g. language). + */ + public function __construct( + private readonly IUserManager $userManager, + private readonly IConfig $config, + ) { + }//end __construct() - /** - * Get a user attribute value by name. - * - * Backs the attribute lookup half of REQ-VIS-008 (attribute-based - * conditional rules). The matching operator-evaluation half lives in - * {@see self::evaluateOperator()}. - * - * @param string $userId The user ID. - * @param string $attribute The attribute name. - * - * @return string|null The attribute value or null. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-12 - * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-2 - */ - public function getUserAttributeValue( - string $userId, - string $attribute - ): ?string { - $user = $this->userManager->get(uid: $userId); - if ($user === null) { - return null; - } + /** + * Get a user attribute value by name. + * + * Backs the attribute lookup half of REQ-VIS-008 (attribute-based + * conditional rules). The matching operator-evaluation half lives in + * {@see self::evaluateOperator()}. + * + * @param string $userId The user ID. + * @param string $attribute The attribute name. + * + * @return string|null The attribute value or null. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-12 + * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-2 + */ + public function getUserAttributeValue( + string $userId, + string $attribute, + ): ?string { + $user = $this->userManager->get(uid: $userId); + if ($user === null) { + return null; + } - return match ($attribute) { - 'locale' => $this->config->getUserValue( - userId: $userId, - appName: 'core', - key: 'lang', - default: 'en' - ), - 'email' => $user->getEMailAddress(), - 'displayName' => $user->getDisplayName(), - 'quota' => (string) $user->getQuota(), - default => null, - }; - }//end getUserAttributeValue() + return match ($attribute) { + 'locale' => $this->config->getUserValue( + userId: $userId, + appName: 'core', + key: 'lang', + default: 'en' + ), + 'email' => $user->getEMailAddress(), + 'displayName' => $user->getDisplayName(), + 'quota' => (string)$user->getQuota(), + default => null, + }; + }//end getUserAttributeValue() - /** - * Evaluate a comparison operator against a value. - * - * @param string $userValue The user's attribute value. - * @param string $operator The comparison operator. - * @param string|null $value The target comparison value. - * - * @return bool Whether the comparison matches. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-2 - */ - public function evaluateOperator( - string $userValue, - string $operator, - ?string $value - ): bool { - return match ($operator) { - 'equals' => $userValue === $value, - 'not_equals' => $userValue !== $value, - 'contains' => str_contains( - haystack: $userValue, - needle: $value ?? '' - ), - 'starts_with' => str_starts_with( - haystack: $userValue, - needle: $value ?? '' - ), - 'ends_with' => str_ends_with( - haystack: $userValue, - needle: $value ?? '' - ), - default => false, - }; - }//end evaluateOperator() + /** + * Evaluate a comparison operator against a value. + * + * @param string $userValue The user's attribute value. + * @param string $operator The comparison operator. + * @param string|null $value The target comparison value. + * + * @return bool Whether the comparison matches. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-infrastructure-helpers/tasks.md#task-2 + */ + public function evaluateOperator( + string $userValue, + string $operator, + ?string $value, + ): bool { + return match ($operator) { + 'equals' => $userValue === $value, + 'not_equals' => $userValue !== $value, + 'contains' => str_contains( + haystack: $userValue, + needle: $value ?? '' + ), + 'starts_with' => str_starts_with( + haystack: $userValue, + needle: $value ?? '' + ), + 'ends_with' => str_ends_with( + haystack: $userValue, + needle: $value ?? '' + ), + default => false, + }; + }//end evaluateOperator() }//end class diff --git a/lib/Service/VisibilityChecker.php b/lib/Service/VisibilityChecker.php index 98d3146d6..64a6b6e8e 100644 --- a/lib/Service/VisibilityChecker.php +++ b/lib/Service/VisibilityChecker.php @@ -18,132 +18,186 @@ namespace OCA\LaunchPad\Service; +use DateTimeInterface; use OCA\LaunchPad\Db\ConditionalRule; /** * Service for checking widget visibility based on conditional rules. */ -class VisibilityChecker -{ - /** - * Constructor - * - * @param RuleEvaluatorService $ruleEvaluator The rule evaluator service. - */ - public function __construct( - private readonly RuleEvaluatorService $ruleEvaluator, - ) { - }//end __construct() - - /** - * Check rules to determine visibility. - * - * Include rules use OR logic (at least one must match). - * Exclude rules use AND logic (any match hides the widget). - * - * @param ConditionalRule[] $rules The rules to check. - * @param string $userId The user ID. - * - * @return bool Whether the widget should be visible. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-13 - */ - public function checkRules(array $rules, string $userId): bool - { - $includeRules = $this->filterByType( - rules: $rules, - isInclude: true - ); - $excludeRules = $this->filterByType( - rules: $rules, - isInclude: false - ); - - if ($this->passesIncludeRules( - rules: $includeRules, - userId: $userId - ) === false - ) { - return false; - } - - return $this->passesExcludeRules( - rules: $excludeRules, - userId: $userId - ); - }//end checkRules() - - /** - * Filter rules by include/exclude type. - * - * @param ConditionalRule[] $rules The rules to filter. - * @param bool $isInclude Whether to get include rules. - * - * @return ConditionalRule[] The filtered rules. - */ - private function filterByType(array $rules, bool $isInclude): array - { - $filtered = []; - foreach ($rules as $rule) { - if ($rule->getIsInclude() === $isInclude) { - $filtered[] = $rule; - } - } - - return $filtered; - }//end filterByType() - - /** - * Check if include rules pass (at least one must match). - * - * @param ConditionalRule[] $rules The include rules. - * @param string $userId The user ID. - * - * @return bool Whether include rules pass. - */ - private function passesIncludeRules( - array $rules, - string $userId - ): bool { - if (empty($rules) === true) { - return true; - } - - foreach ($rules as $rule) { - if ($this->ruleEvaluator->evaluateRule( - rule: $rule, - userId: $userId - ) === true - ) { - return true; - } - } - - return false; - }//end passesIncludeRules() - - /** - * Check if exclude rules pass (none must match). - * - * @param ConditionalRule[] $rules The exclude rules. - * @param string $userId The user ID. - * - * @return bool Whether exclude rules pass. - */ - private function passesExcludeRules( - array $rules, - string $userId - ): bool { - foreach ($rules as $rule) { - if ($this->ruleEvaluator->evaluateRule( - rule: $rule, - userId: $userId - ) === true - ) { - return false; - } - } - - return true; - }//end passesExcludeRules() +class VisibilityChecker { + /** + * Constructor + * + * @param RuleEvaluatorService $ruleEvaluator The rule evaluator service. + */ + public function __construct( + private readonly RuleEvaluatorService $ruleEvaluator, + ) { + }//end __construct() + + /** + * Check rules to determine visibility. + * + * Include rules use OR logic (at least one must match). + * Exclude rules use AND logic (any match hides the widget). + * + * Thin boolean-only wrapper around {@see self::evaluateRuleSet()} — the + * ONLY place the OR/exclude-AND combination logic lives. Render-time + * callers (`ConditionalService::checkRulesForPlacement()`) keep calling + * this method unchanged. + * + * @param ConditionalRule[] $rules The rules to check. + * @param string $userId The user ID. + * + * @return bool Whether the widget should be visible. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-13 + */ + public function checkRules(array $rules, string $userId): bool { + return $this->evaluateRuleSet( + rules: $rules, + userId: $userId + )['visible']; + }//end checkRules() + + /** + * Evaluate a rule set and report which rules matched, in addition to the + * final visibility verdict. + * + * Reuses the exact same include=OR / exclude=AND combination logic as + * {@see self::checkRules()} (both call this method — `checkRules()` just + * discards the matched-id detail) so the preview path + * (conditional-visibility-editor spec, REQ-CVUI-005) can never diverge + * from render-time visibility. `$groupsOverride` / `$nowOverride` are + * forwarded to {@see RuleEvaluatorService::evaluateRule()} and are only + * ever non-null when called from the preview controller. + * + * @param ConditionalRule[] $rules The rules to check. + * @param string $userId The user ID. + * @param string[]|null $groupsOverride Preview-only group + * override. + * @param DateTimeInterface|null $nowOverride Preview-only clock + * override. + * + * @return array{visible: bool, matchedIncludeRuleIds: int[], matchedExcludeRuleIds: int[]} + * + * @spec openspec/specs/conditional-visibility-editor/spec.md#requirement-req-cvui-005-preview-endpoint-reuses-the-render-time-evaluation-path-and-never-persists + */ + public function evaluateRuleSet( + array $rules, + string $userId, + ?array $groupsOverride = null, + ?DateTimeInterface $nowOverride = null, + ): array { + $includeRules = $this->filterByType( + rules: $rules, + isInclude: true + ); + $excludeRules = $this->filterByType( + rules: $rules, + isInclude: false + ); + + $include = $this->evaluateGroup( + rules: $includeRules, + userId: $userId, + groupsOverride: $groupsOverride, + nowOverride: $nowOverride, + isIncludeGroup: true + ); + + if ($include['passed'] === false) { + return [ + 'visible' => false, + 'matchedIncludeRuleIds' => $include['matchedIds'], + 'matchedExcludeRuleIds' => [], + ]; + } + + $exclude = $this->evaluateGroup( + rules: $excludeRules, + userId: $userId, + groupsOverride: $groupsOverride, + nowOverride: $nowOverride, + isIncludeGroup: false + ); + + return [ + 'visible' => $exclude['passed'], + 'matchedIncludeRuleIds' => $include['matchedIds'], + 'matchedExcludeRuleIds' => $exclude['matchedIds'], + ]; + }//end evaluateRuleSet() + + /** + * Filter rules by include/exclude type. + * + * @param ConditionalRule[] $rules The rules to filter. + * @param bool $isInclude Whether to get include rules. + * + * @return ConditionalRule[] The filtered rules. + */ + private function filterByType(array $rules, bool $isInclude): array { + $filtered = []; + foreach ($rules as $rule) { + if ($rule->getIsInclude() === $isInclude) { + $filtered[] = $rule; + } + } + + return $filtered; + }//end filterByType() + + /** + * Evaluate one include/exclude group of rules, collecting the ids of + * every rule that matched. + * + * Include group: passes when empty OR at least one rule matched (OR). + * Exclude group: passes when NO rule matched (AND — any match fails + * it). This single helper backs both `checkRules()` (via + * `evaluateRuleSet()`, which discards `matchedIds`) and the preview + * path, so the OR/AND semantics exist in exactly one place. + * + * @param ConditionalRule[] $rules The rules in this group. + * @param string $userId The user ID. + * @param string[]|null $groupsOverride Preview-only group + * override. + * @param DateTimeInterface|null $nowOverride Preview-only clock + * override. + * @param bool $isIncludeGroup True for the include + * group, false for + * exclude. + * + * @return array{passed: bool, matchedIds: int[]} + */ + private function evaluateGroup( + array $rules, + string $userId, + ?array $groupsOverride, + ?DateTimeInterface $nowOverride, + bool $isIncludeGroup, + ): array { + $matchedIds = []; + foreach ($rules as $rule) { + if ($this->ruleEvaluator->evaluateRule( + rule: $rule, + userId: $userId, + groupsOverride: $groupsOverride, + nowOverride: $nowOverride + ) === true + ) { + $matchedIds[] = $rule->getId(); + } + } + + $passed = empty($matchedIds) === true; + if ($isIncludeGroup === true) { + $passed = empty($rules) === true || empty($matchedIds) === false; + } + + return [ + 'passed' => $passed, + 'matchedIds' => $matchedIds, + ]; + }//end evaluateGroup() }//end class diff --git a/lib/Service/WeatherService.php b/lib/Service/WeatherService.php new file mode 100644 index 000000000..7b3f73844 --- /dev/null +++ b/lib/Service/WeatherService.php @@ -0,0 +1,767 @@ + + * @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 OCP\IConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Service for resolving, caching, and normalising weather-widget readings. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Combines provider + * resolution (weather_status reuse + fallback provider URL), + * locale-derived units/language, caching, and stale-fallback in one + * cohesive unit — mirrors NewsWidgetService's shape for the same + * class of capability. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Same cause as the complexity + * above: provider resolution reaches weather_status, the HTTP client, the + * cache, the config and the l10n factory. The collaborators are the feature. + * @spec openspec/specs/clock-weather-widgets/spec.md + */ +class WeatherService { + + /** + * Default reading cache TTL in seconds (REQ-WEATHER-002). Overridden + * at runtime by the app-config key `weather_cache_ttl_seconds`. + * + * @var integer + */ + public const DEFAULT_CACHE_TTL = 900; + + /** + * HTTP connect timeout in seconds for the fallback provider fetch. + * + * @var integer + */ + public const CONNECT_TIMEOUT = 10; + + /** + * HTTP total request timeout in seconds for the fallback provider + * fetch. + * + * @var integer + */ + public const REQUEST_TIMEOUT = 15; + + /** + * IAppConfig key — cache TTL override, in seconds. + * + * @var string + */ + public const CONFIG_KEY_CACHE_TTL = 'weather_cache_ttl_seconds'; + + /** + * IAppConfig key — the fallback provider URL template. May contain the + * placeholders `{location}`, `{apiKey}`, `{units}`, `{lang}`, which are + * substituted (URL-encoded) before the request is made. + * + * @var string + */ + public const CONFIG_KEY_PROVIDER_URL = 'weather_provider_url'; + + /** + * IAppConfig key — the fallback provider's server-held API key. Never + * exposed in any response (REQ-WEATHER-001 "key never exposed"); ONLY + * substituted into the outbound request URL server-side. MUST be + * written with `sensitive: true` by whichever admin-settings surface + * sets it. + * + * @var string + */ + public const CONFIG_KEY_PROVIDER_API_KEY = 'weather_provider_api_key'; + + /** + * FQCN of the `weather_status` app's forecast service, referenced only + * as a string so this file never hard-requires the class to exist — + * it is only resolved through the container when the app is enabled + * (REQ-WEATHER-002 "reuse weather_status when present"). + * + * @var string + */ + private const WEATHER_STATUS_SERVICE_CLASS = 'OCA\\WeatherStatus\\Service\\WeatherStatusService'; + + /** + * ISO 3166-1 alpha-2 country codes that use imperial units. Matched + * against the trailing territory of a Nextcloud locale (e.g. `en_US`). + * The rest of the world uses metric. + * + * @var array + */ + private const IMPERIAL_TERRITORIES = ['US', 'LR', 'MM']; + + /** + * Met.no `symbol_code` prefixes (weather_status's upstream) mapped to + * our normalised `condition` codes. Suffixes like `_day`/`_night`/ + * `_polartwilight` are stripped before lookup. + * + * @var array + */ + private const METNO_CONDITION_MAP = [ + 'clearsky' => 'clear', + 'fair' => 'clear', + 'partlycloudy' => 'partly-cloudy', + 'cloudy' => 'cloudy', + 'fog' => 'fog', + 'lightrain' => 'rain', + 'rain' => 'rain', + 'lightrainshowers' => 'rain', + 'rainshowers' => 'rain', + 'heavyrain' => 'heavy-rain', + 'heavyrainshowers' => 'heavy-rain', + 'lightsnow' => 'snow', + 'snow' => 'snow', + 'snowshowers' => 'snow', + 'heavysnow' => 'snow', + 'sleet' => 'snow', + 'thunder' => 'thunderstorm', + 'rainandthunder' => 'thunderstorm', + 'sleetandthunder' => 'thunderstorm', + ]; + + /** + * OpenWeatherMap-family `weather[0].main` values mapped to our + * normalised `condition` codes (the common default shape for the + * configurable-provider-URL fallback). + * + * @var array + */ + private const OWM_CONDITION_MAP = [ + 'clear' => 'clear', + 'clouds' => 'cloudy', + 'mist' => 'fog', + 'fog' => 'fog', + 'haze' => 'fog', + 'drizzle' => 'rain', + 'rain' => 'rain', + 'snow' => 'snow', + 'thunderstorm' => 'thunderstorm', + 'tornado' => 'windy', + 'squall' => 'windy', + ]; + + /** + * Lazily resolved {@see ICache} backing the per-reading cache. + * + * @var ICache|null + */ + private ?ICache $cache = null; + + /** + * Constructor. + * + * @param IAppManager $appManager Detects whether `weather_status` is enabled. + * @param ContainerInterface $container App container used to optionally resolve + * `weather_status`'s forecast service + * (REQ-WEATHER-002 provider reuse). + * @param IClientService $clientService HTTP client factory for the fallback provider fetch. + * @param ICacheFactory $cacheFactory Backing factory for the distributed reading cache. + * @param IAppConfig $appConfig Admin config: cache TTL, provider URL, API key. + * @param WidgetPlacementMapper $placementMapper Resolves placements by id. + * @param IConfig $config Reads the requesting user's locale/language and + * the system default. + * @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 IConfig $config, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Resolve the weather reading for one placement, on behalf of one + * viewing user. Never throws — every failure path returns either a + * stale cached reading or the `{error: ...}` shape (REQ-WEATHER-002 + * "upstream failure degrades gracefully"). + * + * @param integer $placementId The widget placement id. + * @param string $userId The viewing user's UID (drives locale-derived units/language). + * + * @return array `{location, tempValue, units, condition, conditionText, language, fetchedAt, stale}` or `{error: string}`. + * + * @spec openspec/specs/clock-weather-widgets/spec.md + */ + public function resolveForPlacement(int $placementId, string $userId): array { + try { + $placement = $this->placementMapper->find(id: $placementId); + } catch (Throwable $exception) { + return ['error' => 'placement_not_found']; + } + + $config = $this->readPlacementConfig(placement: $placement); + $location = trim(string: (string)($config['location'] ?? '')); + $override = (string)($config['unitsOverride'] ?? ''); + + $units = $this->resolveUnits(userId: $userId, override: $override); + $language = $this->resolveLanguage(userId: $userId); + + $cacheKey = $this->buildCacheKey(location: $location, units: $units, language: $language); + $cache = $this->getCache(); + $cached = $this->readCache(cache: $cache, cacheKey: $cacheKey); + + if ($this->isCacheFresh(cached: $cached) === true) { + return $this->publicShape(reading: $cached, stale: false); + } + + $fresh = $this->fetchFresh(location: $location, units: $units, language: $language); + if ($fresh !== null) { + $fresh['fetchedAtTs'] = time(); + if ($cache !== null) { + $cache->set(key: $cacheKey, value: json_encode($fresh), ttl: $this->cacheTtl()); + } + + return $this->publicShape(reading: $fresh, stale: false); + } + + if ($cached !== null) { + // Upstream failed but a previous reading exists — degrade + // gracefully rather than error (REQ-WEATHER-002). + return $this->publicShape(reading: $cached, stale: true); + } + + return ['error' => 'weather_unavailable']; + }//end resolveForPlacement() + + /** + * Whether a cached reading exists and is still inside its TTL. + * + * A negative age (clock skew, or a reading stamped in the future) is + * treated as stale so a bad timestamp can never pin a stale reading. + * + * @param array|null $cached The cached reading, if any. + * + * @return bool True when `$cached` may be served as fresh. + */ + private function isCacheFresh(?array $cached): bool { + if ($cached === null) { + return false; + } + + $age = (time() - (int)($cached['fetchedAtTs'] ?? 0)); + + return $age >= 0 && $age < $this->cacheTtl(); + }//end isCacheFresh() + + /** + * Fetch a fresh reading from whichever upstream suits the placement. + * + * With no author-configured location, the viewer's own weather_status + * personal location is preferred; otherwise the configured provider URL + * is used. + * + * @param string $location The author-configured location, or `''`. + * @param string $units `'metric'` or `'imperial'`. + * @param string $language The resolved forecast language. + * + * @return array|null The reading, or null when unavailable. + */ + private function fetchFresh(string $location, string $units, string $language): ?array { + if ($location === '') { + return $this->fetchFromWeatherStatus(units: $units); + } + + return $this->fetchFromProviderUrl(location: $location, units: $units, language: $language); + }//end fetchFresh() + + /** + * Read the placement's `{location, unitsOverride}` config, falling + * back to the legacy `style_config.content` slot for pre-column rows + * (mirrors {@see \OCA\LaunchPad\Controller\FilesWidgetController::loadConfig()}). + * + * @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() + + /** + * Derive the units to use: the author's explicit override when set, + * otherwise metric/imperial from the viewing user's Nextcloud locale + * (REQ-WEATHER-003). + * + * @param string $userId The viewing user's UID. + * @param string $override `metric`, `imperial`, or `''` (follow locale). + * + * @return string `metric` or `imperial`. + */ + private function resolveUnits(string $userId, string $override): string { + if ($override === 'metric' || $override === 'imperial') { + return $override; + } + + $locale = (string)$this->config->getUserValue(userId: $userId, appName: 'core', key: 'locale', default: ''); + if ($locale === '') { + $locale = (string)$this->config->getSystemValue(key: 'default_locale', default: 'en'); + } + + $parts = preg_split(pattern: '/[_-]/', subject: $locale); + if ($parts === false) { + $parts = []; + } + + $territory = strtoupper(string: (string)($parts[1] ?? '')); + + if (in_array(needle: $territory, haystack: self::IMPERIAL_TERRITORIES, strict: true) === true) { + return 'imperial'; + } + + return 'metric'; + }//end resolveUnits() + + /** + * Derive the forecast language from the viewing user's Nextcloud + * language preference (REQ-WEATHER-003), falling back to the system + * default, then `en`. + * + * @param string $userId The viewing user's UID. + * + * @return string A two-letter (or `xx_YY`) language code. + */ + private function resolveLanguage(string $userId): string { + $lang = (string)$this->config->getUserValue(userId: $userId, appName: 'core', key: 'lang', default: ''); + if ($lang === '') { + $lang = (string)$this->config->getSystemValue(key: 'default_language', default: 'en'); + } + + if ($lang !== '') { + return $lang; + } + + return 'en'; + }//end resolveLanguage() + + /** + * Attempt to resolve a reading via the `weather_status` app's own + * forecast service (REQ-WEATHER-002 "reuse weather_status when + * present"). Returns `null` — never throws — when the app is + * disabled, its service cannot be resolved, or the forecast call + * fails or is empty, so the caller falls through to the provider-URL + * path. + * + * @param string $units `metric` or `imperial` — met.no returns Celsius; + * converted to Fahrenheit when `imperial`. + * + * @return array|null `{location, tempValue, units, condition, conditionText, language}` or `null`. + */ + private function fetchFromWeatherStatus(string $units): ?array { + try { + if ($this->appManager->isEnabledForUser(appId: 'weather_status') === false) { + return null; + } + + if ($this->container->has(id: self::WEATHER_STATUS_SERVICE_CLASS) === false) { + return null; + } + + $service = $this->container->get(id: self::WEATHER_STATUS_SERVICE_CLASS); + if (method_exists(object_or_class: $service, method: 'getForecast') === false + || method_exists(object_or_class: $service, method: 'getLocation') === false + ) { + return null; + } + + $forecast = $service->getForecast(); + if (is_array(value: $forecast) === false || $forecast === [] || isset($forecast['error']) === true) { + return null; + } + + $locationInfo = $service->getLocation(); + } catch (Throwable $exception) { + $this->logger->info( + message: 'WeatherService: weather_status provider unavailable, falling back', + context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] + ); + return null; + }//end try + + return $this->normaliseMetNoForecast(forecast: $forecast, locationInfo: $locationInfo, units: $units); + }//end fetchFromWeatherStatus() + + /** + * Normalise met.no's compact-format timeseries (weather_status's + * `getForecast()` return shape) into our reading shape, using the + * nearest ("current") timeseries entry. + * + * @param array $forecast The timeseries list. + * @param array $locationInfo weather_status's `getLocation()` result. + * @param string $units `metric` or `imperial`. + * + * @return array|null + */ + private function normaliseMetNoForecast(array $forecast, array $locationInfo, string $units): ?array { + $current = $forecast[0] ?? null; + if (is_array(value: $current) === false) { + return null; + } + + $details = ($current['data']['instant']['details'] ?? null); + if (is_array(value: $details) === false || isset($details['air_temperature']) === false) { + return null; + } + + $tempC = (float)$details['air_temperature']; + $temp = $tempC; + if ($units === 'imperial') { + $temp = (($tempC * 9 / 5) + 32); + } + + $symbolCode = (string)( + $current['data']['next_1_hours']['summary']['symbol_code'] ?? $current['data']['next_6_hours']['summary']['symbol_code'] ?? '' + ); + $condition = $this->mapMetNoSymbol(symbolCode: $symbolCode); + + $label = (string)($locationInfo['address'] ?? ''); + if ($label === '') { + $label = 'Current location'; + } + + return [ + 'location' => $label, + 'tempValue' => $temp, + 'units' => $units, + 'condition' => $condition, + 'conditionText' => $this->humaniseCondition(code: $condition), + // Met.no/weather_status carries no per-language forecast text — + // the humanised fallback above is always English (REQ-WEATHER-003 + // "English MUST be the fallback when the provider has no + // localisation"). `language` still reports what was requested so + // the frontend never re-guesses. + 'language' => 'en', + ]; + }//end normaliseMetNoForecast() + + /** + * Map a met.no `symbol_code` (e.g. `partlycloudy_day`) to our + * normalised condition code, stripping the day/night/twilight suffix + * before lookup. + * + * @param string $symbolCode The raw symbol code. + * + * @return string One of `self::METNO_CONDITION_MAP`'s values, or `unknown`. + */ + private function mapMetNoSymbol(string $symbolCode): string { + $prefix = preg_replace(pattern: '/_(day|night|polartwilight)$/', replacement: '', subject: $symbolCode); + return self::METNO_CONDITION_MAP[$prefix] ?? 'unknown'; + }//end mapMetNoSymbol() + + /** + * Fetch from the admin-configured fallback provider URL + * (REQ-WEATHER-002 "Fallback to configurable provider URL"). Returns + * `null` — never throws — on any failure: no template configured, + * invalid scheme, transport error, non-2xx, or unparseable body. + * + * @param string $location The author-configured location string. + * @param string $units `metric` or `imperial`. + * @param string $language The resolved forecast language. + * + * @return array|null + */ + private function fetchFromProviderUrl(string $location, string $units, string $language): ?array { + $template = $this->appConfig->getValueString( + app: Application::APP_ID, + key: self::CONFIG_KEY_PROVIDER_URL, + default: '' + ); + if ($template === '') { + return null; + } + + $apiKey = $this->appConfig->getValueString( + app: Application::APP_ID, + key: self::CONFIG_KEY_PROVIDER_API_KEY, + default: '' + ); + + $url = str_replace( + search: ['{location}', '{apiKey}', '{units}', '{lang}'], + replace: [rawurlencode(string: $location), rawurlencode(string: $apiKey), $units, $language], + subject: $template + ); + + $scheme = strtolower(string: (string)parse_url(url: $url, component: PHP_URL_SCHEME)); + if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) { + $this->logger->warning( + message: 'WeatherService: provider URL has an invalid scheme, rejecting', + 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, + // C5 (mirrors FeedRefreshService): no auto-redirect — a 3xx to + // an unexpected host would bypass the scheme check above. + 'allow_redirects' => false, + ] + ); + } catch (Throwable $exception) { + $this->logger->info( + message: 'WeatherService: provider 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; + } + + return $this->normaliseProviderPayload(payload: $decoded, location: $location, units: $units, language: $language); + }//end fetchFromProviderUrl() + + /** + * Normalise an OpenWeatherMap-family JSON payload (the common default + * shape for the configurable-provider fallback) into our reading + * shape. Returns `null` when the payload carries no usable + * temperature field. + * + * @param array $payload The decoded JSON body. + * @param string $location The configured location (label fallback). + * @param string $units `metric` or `imperial`. + * @param string $language The resolved forecast language. + * + * @return array|null + */ + private function normaliseProviderPayload(array $payload, string $location, string $units, string $language): ?array { + $temp = ($payload['main']['temp'] ?? ($payload['temp'] ?? null)); + if (is_numeric(value: $temp) === false) { + return null; + } + + $description = ''; + $condition = 'unknown'; + $weather0 = ($payload['weather'][0] ?? null); + if (is_array(value: $weather0) === true) { + $description = (string)($weather0['description'] ?? ''); + $main = strtolower(string: (string)($weather0['main'] ?? '')); + $condition = self::OWM_CONDITION_MAP[$main] ?? 'unknown'; + } + + $label = (string)($payload['name'] ?? ''); + if ($label === '') { + $label = $location; + } + + $conditionText = $this->humaniseCondition(code: $condition); + if ($description !== '') { + $conditionText = ucfirst(string: $description); + } + + return [ + 'location' => $label, + 'tempValue' => (float)$temp, + 'units' => $units, + 'condition' => $condition, + 'conditionText' => $conditionText, + 'language' => $language, + ]; + }//end normaliseProviderPayload() + + /** + * Title-cased English fallback description for a condition code, used + * when a provider carries no localised description string + * (REQ-WEATHER-003 "English MUST be the fallback"). + * + * @param string $code One of the normalised condition codes. + * + * @return string A human-readable English label. + */ + private function humaniseCondition(string $code): string { + if ($code === 'unknown' || $code === '') { + return 'Unknown conditions'; + } + + return ucwords(string: str_replace(search: '-', replace: ' ', subject: $code)); + }//end humaniseCondition() + + /** + * Build the reading cache key — location + units + language, per + * REQ-WEATHER-002 ("cached ... for the same location + units + + * language"). + * + * @param string $location The configured (or resolved) location. + * @param string $units `metric` or `imperial`. + * @param string $language The resolved forecast language. + * + * @return string The cache key. + */ + private function buildCacheKey(string $location, string $units, string $language): string { + return 'reading_' . hash(algo: 'sha256', data: strtolower(string: $location) . '|' . $units . '|' . $language); + }//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() + + /** + * Shape an internal reading array (which also carries the internal + * `fetchedAtTs` unix timestamp) into the public response contract + * (REQ-WEATHER-001): `{location, tempValue, units, condition, + * conditionText, language, fetchedAt, stale}`. NEVER includes the + * provider API key or URL. + * + * @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 { + $fetchedAtTs = (int)($reading['fetchedAtTs'] ?? time()); + + return [ + 'location' => (string)($reading['location'] ?? ''), + 'tempValue' => (float)($reading['tempValue'] ?? 0), + 'units' => (string)($reading['units'] ?? 'metric'), + 'condition' => (string)($reading['condition'] ?? 'unknown'), + 'conditionText' => (string)($reading['conditionText'] ?? ''), + 'language' => (string)($reading['language'] ?? 'en'), + 'fetchedAt' => (new DateTime('@' . $fetchedAtTs))->format(format: DATE_ATOM), + 'stale' => $stale, + ]; + }//end publicShape() + + /** + * 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_weather_'); + } catch (Throwable $exception) { + $this->logger->info( + message: 'WeatherService: cache subsystem unavailable, falling back to direct fetch', + context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] + ); + $this->cache = null; + } + + return $this->cache; + }//end getCache() + + /** + * Resolve the configured cache TTL in seconds, clamped to a sane + * positive minimum. + * + * @return integer The TTL in seconds. + */ + private function cacheTtl(): int { + $ttl = $this->appConfig->getValueInt( + app: Application::APP_ID, + key: self::CONFIG_KEY_CACHE_TTL, + default: self::DEFAULT_CACHE_TTL + ); + + if ($ttl > 0) { + return $ttl; + } + + return self::DEFAULT_CACHE_TTL; + }//end cacheTtl() +}//end class diff --git a/lib/Service/WidgetFormatter.php b/lib/Service/WidgetFormatter.php index 72ec7f007..bdc51be41 100644 --- a/lib/Service/WidgetFormatter.php +++ b/lib/Service/WidgetFormatter.php @@ -31,168 +31,163 @@ /** * Service for formatting Nextcloud widgets into API response arrays. */ -class WidgetFormatter -{ - /** - * Format a widget for API response. - * - * @param IWidget $widget The widget to format. - * @param string $userId The current user ID. - * - * @return array The formatted widget data. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-32 - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - public function format(IWidget $widget, string $userId): array - { - $data = $this->buildBaseData(widget: $widget); +class WidgetFormatter { + /** + * Format a widget for API response. + * + * @param IWidget $widget The widget to format. + * @param string $userId The current user ID. + * + * @return array The formatted widget data. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-32 + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + public function format(IWidget $widget, string $userId): array { + $data = $this->buildBaseData(widget: $widget); - $this->applyIconUrl(widget: $widget, data: $data); - $this->applyApiVersions(widget: $widget, data: $data); - $this->applyButtons( - widget: $widget, - userId: $userId, - data: $data - ); - $this->applyOptions(widget: $widget, data: $data); - $this->applyReloadInterval(widget: $widget, data: $data); + $this->applyIconUrl(widget: $widget, data: $data); + $this->applyApiVersions(widget: $widget, data: $data); + $this->applyButtons( + widget: $widget, + userId: $userId, + data: $data + ); + $this->applyOptions(widget: $widget, data: $data); + $this->applyReloadInterval(widget: $widget, data: $data); - return $data; - }//end format() + return $data; + }//end format() - /** - * Build base widget data array. - * - * @param IWidget $widget The widget. - * - * @return array The base data. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function buildBaseData(IWidget $widget): array - { - return [ - 'id' => $widget->getId(), - 'title' => $widget->getTitle(), - 'order' => $widget->getOrder(), - 'iconClass' => $widget->getIconClass(), - 'iconUrl' => null, - 'widgetUrl' => $widget->getUrl(), - 'itemIconsRound' => false, - 'itemApiVersions' => [], - 'reloadInterval' => 0, - 'buttons' => [], - ]; - }//end buildBaseData() + /** + * Build base widget data array. + * + * @param IWidget $widget The widget. + * + * @return array The base data. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function buildBaseData(IWidget $widget): array { + return [ + 'id' => $widget->getId(), + 'title' => $widget->getTitle(), + 'order' => $widget->getOrder(), + 'iconClass' => $widget->getIconClass(), + 'iconUrl' => null, + 'widgetUrl' => $widget->getUrl(), + 'itemIconsRound' => false, + 'itemApiVersions' => [], + 'reloadInterval' => 0, + 'buttons' => [], + ]; + }//end buildBaseData() - /** - * Apply icon URL if widget supports it. - * - * @param IWidget $widget The widget. - * @param array $data The data array (passed by reference). - * - * @return void - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function applyIconUrl(IWidget $widget, array &$data): void - { - if ($widget instanceof IIconWidget) { - $data['iconUrl'] = $widget->getIconUrl(); - } - }//end applyIconUrl() + /** + * Apply icon URL if widget supports it. + * + * @param IWidget $widget The widget. + * @param array $data The data array (passed by reference). + * + * @return void + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function applyIconUrl(IWidget $widget, array &$data): void { + if ($widget instanceof IIconWidget) { + $data['iconUrl'] = $widget->getIconUrl(); + } + }//end applyIconUrl() - /** - * Apply API versions supported by the widget. - * - * @param IWidget $widget The widget. - * @param array $data The data array (passed by reference). - * - * @return void - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function applyApiVersions( - IWidget $widget, - array &$data - ): void { - if ($widget instanceof IAPIWidget) { - $data['itemApiVersions'][] = 1; - } + /** + * Apply API versions supported by the widget. + * + * @param IWidget $widget The widget. + * @param array $data The data array (passed by reference). + * + * @return void + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function applyApiVersions( + IWidget $widget, + array &$data, + ): void { + if ($widget instanceof IAPIWidget) { + $data['itemApiVersions'][] = 1; + } - if ($widget instanceof IAPIWidgetV2) { - $data['itemApiVersions'][] = 2; - } - }//end applyApiVersions() + if ($widget instanceof IAPIWidgetV2) { + $data['itemApiVersions'][] = 2; + } + }//end applyApiVersions() - /** - * Apply button configuration if widget supports it. - * - * @param IWidget $widget The widget. - * @param string $userId The user ID. - * @param array $data The data array (passed by reference). - * - * @return void - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function applyButtons( - IWidget $widget, - string $userId, - array &$data - ): void { - if ($widget instanceof IButtonWidget === false) { - return; - } + /** + * Apply button configuration if widget supports it. + * + * @param IWidget $widget The widget. + * @param string $userId The user ID. + * @param array $data The data array (passed by reference). + * + * @return void + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function applyButtons( + IWidget $widget, + string $userId, + array &$data, + ): void { + if ($widget instanceof IButtonWidget === false) { + return; + } - $buttons = $widget->getWidgetButtons(userId: $userId); - $data['buttons'] = array_map( - callback: function ($btn) { - return [ - 'type' => $btn->getType(), - 'text' => $btn->getText(), - 'link' => $btn->getLink(), - ]; - }, - array: $buttons - ); - }//end applyButtons() + $buttons = $widget->getWidgetButtons(userId: $userId); + $data['buttons'] = array_map( + callback: function ($btn) { + return [ + 'type' => $btn->getType(), + 'text' => $btn->getText(), + 'link' => $btn->getLink(), + ]; + }, + array: $buttons + ); + }//end applyButtons() - /** - * Apply widget options if supported. - * - * @param IWidget $widget The widget. - * @param array $data The data array (passed by reference). - * - * @return void - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function applyOptions(IWidget $widget, array &$data): void - { - if ($widget instanceof IOptionWidget) { - $options = $widget->getWidgetOptions(); - $data['itemIconsRound'] = $options->withRoundItemIcons(); - } - }//end applyOptions() + /** + * Apply widget options if supported. + * + * @param IWidget $widget The widget. + * @param array $data The data array (passed by reference). + * + * @return void + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function applyOptions(IWidget $widget, array &$data): void { + if ($widget instanceof IOptionWidget) { + $options = $widget->getWidgetOptions(); + $data['itemIconsRound'] = $options->withRoundItemIcons(); + } + }//end applyOptions() - /** - * Apply reload interval if widget supports it. - * - * @param IWidget $widget The widget. - * @param array $data The data array (passed by reference). - * - * @return void - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 - */ - private function applyReloadInterval( - IWidget $widget, - array &$data - ): void { - if ($widget instanceof IReloadableWidget) { - $data['reloadInterval'] = $widget->getReloadInterval(); - } - }//end applyReloadInterval() + /** + * Apply reload interval if widget supports it. + * + * @param IWidget $widget The widget. + * @param array $data The data array (passed by reference). + * + * @return void + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-1 + */ + private function applyReloadInterval( + IWidget $widget, + array &$data, + ): void { + if ($widget instanceof IReloadableWidget) { + $data['reloadInterval'] = $widget->getReloadInterval(); + } + }//end applyReloadInterval() }//end class diff --git a/lib/Service/WidgetItemLoader.php b/lib/Service/WidgetItemLoader.php index e6ecf81ef..4ee52b82d 100644 --- a/lib/Service/WidgetItemLoader.php +++ b/lib/Service/WidgetItemLoader.php @@ -28,146 +28,145 @@ /** * Service for loading widget items from Nextcloud widget APIs. */ -class WidgetItemLoader -{ - /** - * Load items for the specified widget IDs. - * - * @param array $widgets Map of widget ID to IWidget instances. - * @param string $userId The user ID. - * @param array $widgetIds The widget IDs to load. - * @param int $limit Maximum items per widget. - * - * @return array The widget items keyed by widget ID. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-33 - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-2 - */ - public function loadItems( - array $widgets, - string $userId, - array $widgetIds, - int $limit=7 - ): array { - $result = []; +class WidgetItemLoader { + /** + * Load items for the specified widget IDs. + * + * @param array $widgets Map of widget ID to IWidget instances. + * @param string $userId The user ID. + * @param array $widgetIds The widget IDs to load. + * @param int $limit Maximum items per widget. + * + * @return array The widget items keyed by widget ID. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-33 + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-2 + */ + public function loadItems( + array $widgets, + string $userId, + array $widgetIds, + int $limit = 7, + ): array { + $result = []; - foreach ($widgetIds as $widgetId) { - if (isset($widgets[$widgetId]) === false) { - continue; - } + foreach ($widgetIds as $widgetId) { + if (isset($widgets[$widgetId]) === false) { + continue; + } - $result[$widgetId] = $this->loadSingleWidget( - widget: $widgets[$widgetId], - userId: $userId, - limit: $limit - ); - } + $result[$widgetId] = $this->loadSingleWidget( + widget: $widgets[$widgetId], + userId: $userId, + limit: $limit + ); + } - return $result; - }//end loadItems() + return $result; + }//end loadItems() - /** - * Load items for a single widget. - * - * @param IWidget $widget The widget instance. - * @param string $userId The user ID. - * @param int $limit Maximum items. - * - * @return array The widget items data. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-2 - */ - private function loadSingleWidget( - IWidget $widget, - string $userId, - int $limit - ): array { - if ($widget instanceof IAPIWidgetV2) { - return $this->loadV2Items( - widget: $widget, - userId: $userId, - limit: $limit - ); - } + /** + * Load items for a single widget. + * + * @param IWidget $widget The widget instance. + * @param string $userId The user ID. + * @param int $limit Maximum items. + * + * @return array The widget items data. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-2 + */ + private function loadSingleWidget( + IWidget $widget, + string $userId, + int $limit, + ): array { + if ($widget instanceof IAPIWidgetV2) { + return $this->loadV2Items( + widget: $widget, + userId: $userId, + limit: $limit + ); + } - if ($widget instanceof IAPIWidget) { - return $this->loadV1Items( - widget: $widget, - userId: $userId, - limit: $limit - ); - } + if ($widget instanceof IAPIWidget) { + return $this->loadV1Items( + widget: $widget, + userId: $userId, + limit: $limit + ); + } - return [ - 'items' => [], - 'emptyContentMessage' => '', - 'halfEmptyContentMessage' => '', - ]; - }//end loadSingleWidget() + return [ + 'items' => [], + 'emptyContentMessage' => '', + 'halfEmptyContentMessage' => '', + ]; + }//end loadSingleWidget() - /** - * Load items using the V2 API. - * - * @param IAPIWidgetV2 $widget The V2 widget. - * @param string $userId The user ID. - * @param int $limit Maximum items. - * - * @return array The serialized items data. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-3 - */ - private function loadV2Items( - IAPIWidgetV2 $widget, - string $userId, - int $limit - ): array { - $items = $widget->getItemsV2( - userId: $userId, - since: null, - limit: $limit - ); - $serializedItems = []; - foreach ($items->getItems() as $item) { - $serializedItems[] = $item->jsonSerialize(); - } + /** + * Load items using the V2 API. + * + * @param IAPIWidgetV2 $widget The V2 widget. + * @param string $userId The user ID. + * @param int $limit Maximum items. + * + * @return array The serialized items data. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-3 + */ + private function loadV2Items( + IAPIWidgetV2 $widget, + string $userId, + int $limit, + ): array { + $items = $widget->getItemsV2( + userId: $userId, + since: null, + limit: $limit + ); + $serializedItems = []; + foreach ($items->getItems() as $item) { + $serializedItems[] = $item->jsonSerialize(); + } - return [ - 'items' => $serializedItems, - 'emptyContentMessage' => $items->getEmptyContentMessage(), - 'halfEmptyContentMessage' => $items->getHalfEmptyContentMessage(), - ]; - }//end loadV2Items() + return [ + 'items' => $serializedItems, + 'emptyContentMessage' => $items->getEmptyContentMessage(), + 'halfEmptyContentMessage' => $items->getHalfEmptyContentMessage(), + ]; + }//end loadV2Items() - /** - * Load items using the V1 API. - * - * @param IAPIWidget $widget The V1 widget. - * @param string $userId The user ID. - * @param int $limit Maximum items. - * - * @return array The serialized items data. - * - * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-3 - */ - private function loadV1Items( - IAPIWidget $widget, - string $userId, - int $limit - ): array { - $items = $widget->getItems( - userId: $userId, - since: null, - limit: $limit - ); - $serializedItems = []; - foreach ($items as $item) { - $serializedItems[] = $item->jsonSerialize(); - } + /** + * Load items using the V1 API. + * + * @param IAPIWidget $widget The V1 widget. + * @param string $userId The user ID. + * @param int $limit Maximum items. + * + * @return array The serialized items data. + * + * @spec openspec/changes/archive/2026-05-24-retrofit-widgets/tasks.md#task-3 + */ + private function loadV1Items( + IAPIWidget $widget, + string $userId, + int $limit, + ): array { + $items = $widget->getItems( + userId: $userId, + since: null, + limit: $limit + ); + $serializedItems = []; + foreach ($items as $item) { + $serializedItems[] = $item->jsonSerialize(); + } - return [ - 'items' => $serializedItems, - 'emptyContentMessage' => '', - 'halfEmptyContentMessage' => '', - ]; - }//end loadV1Items() + return [ + 'items' => $serializedItems, + 'emptyContentMessage' => '', + 'halfEmptyContentMessage' => '', + ]; + }//end loadV1Items() }//end class diff --git a/lib/Service/WidgetPlacementService.php b/lib/Service/WidgetPlacementService.php index 56e4b59f0..bff848fab 100644 --- a/lib/Service/WidgetPlacementService.php +++ b/lib/Service/WidgetPlacementService.php @@ -14,8 +14,8 @@ * @copyright 2026 Conduction b.v. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * - * 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); @@ -27,86 +27,105 @@ /** * Server-side validators for widget placement payloads. */ -class WidgetPlacementService -{ - /** - * Maximum container nesting depth (REQ-CONT-006). - * - * Depth counts the number of nested CONTAINER levels — not the total - * number of children. A container holding a container holding a - * container holding a label has depth 3 (allowed). Adding a fourth - * nested container makes it depth 4 (rejected). - * - * @var int - */ - public const MAX_CONTAINER_DEPTH = 3; +class WidgetPlacementService { + /** + * Maximum container nesting depth (REQ-CONT-006). + * + * Depth counts the number of nested CONTAINER levels — not the total + * number of children. A container holding a container holding a + * container holding a label has depth 3 (allowed). Adding a fourth + * nested container makes it depth 4 (rejected). + * + * @var int + */ + public const MAX_CONTAINER_DEPTH = 3; - /** - * Recursively walk a widget placement's `content` blob and reject - * payloads whose nested-container depth exceeds - * {@see self::MAX_CONTAINER_DEPTH} (REQ-CONT-006). - * - * Tolerant of non-container payloads — when `$content` has no - * `placements[]` array (i.e. the widget is not a container) the - * method returns immediately without raising. This keeps the - * controller wiring trivial: every save can call - * `validateContainerDepth` regardless of widget type. - * - * @param array $content The widget placement's `content` blob. - * @param int $depth Current nesting depth (0 at the top level). - * - * @return void - * - * @throws InvalidArgumentException When the depth limit is exceeded. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-15 - */ - public function validateContainerDepth(array $content, int $depth=0): void - { - if (isset($content['placements']) === false - || is_array($content['placements']) === false - ) { - return; - } + /** + * Recursively walk a widget placement's `content` blob and reject + * payloads whose nested-container depth exceeds + * {@see self::MAX_CONTAINER_DEPTH} (REQ-CONT-006). + * + * Tolerant of non-container payloads — when `$content` has no + * `placements[]` array (i.e. the widget is not a container) the + * method returns immediately without raising. This keeps the + * controller wiring trivial: every save can call + * `validateContainerDepth` regardless of widget type. + * + * @param array $content The widget placement's `content` blob. + * @param int $depth Current nesting depth (0 at the top level). + * + * @return void + * + * @throws InvalidArgumentException When the depth limit is exceeded. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-15 + */ + public function validateContainerDepth(array $content, int $depth = 0): void { + if (isset($content['placements']) === false + || is_array($content['placements']) === false + ) { + return; + } - // The `placements[]` key marks this content as a container — - // the current node IS one container level. If we're already at - // (or beyond) the cap, having any placements at all violates - // the invariant: a container at depth MAX cannot contain a - // container child (which would push the tree to MAX + 1). - // We only reject when one of those children is itself another - // container, matching the proposal wording: "depth > 3 AND any - // placements[] child is also a container". - $children = $content['placements']; - foreach ($children as $child) { - if (is_array($child) === false) { - continue; - } + // The `placements[]` key marks this content as a container — + // the current node IS one container level. If we're already at + // (or beyond) the cap, having any placements at all violates + // the invariant: a container at depth MAX cannot contain a + // container child (which would push the tree to MAX + 1). + // We only reject when one of those children is itself another + // container, matching the proposal wording: "depth > 3 AND any + // placements[] child is also a container". + $children = $content['placements']; + foreach ($children as $child) { + if (is_array($child) === false) { + continue; + } - $childContent = $child['content'] ?? null; - if (is_array($childContent) === false) { - continue; - } + $this->validateChildDepth(child: $child, depth: $depth); + }//end foreach + }//end validateContainerDepth() - $isChildContainer = ( - ($child['type'] ?? null) === 'container' - || (isset($childContent['placements']) === true - && is_array($childContent['placements']) === true) - ); + /** + * Validate a single `placements[]` child of a container node. + * + * Non-container children are ignored — only a child that is itself a + * container adds a nesting level, and only then can the cap be + * breached. Container children are charged one level and recursed + * into via {@see self::validateContainerDepth()}. + * + * @param array $child The child placement to inspect. + * @param int $depth The parent container's nesting depth. + * + * @return void + * + * @throws InvalidArgumentException When the depth limit is exceeded. + */ + private function validateChildDepth(array $child, int $depth): void { + $childContent = $child['content'] ?? null; + if (is_array($childContent) === false) { + return; + } - if ($isChildContainer === true) { - $childDepth = ($depth + 1); - if ($childDepth > self::MAX_CONTAINER_DEPTH) { - throw new InvalidArgumentException( - message: 'container_depth_exceeded' - ); - } + $isChildContainer = ( + ($child['type'] ?? null) === 'container' + || (isset($childContent['placements']) === true + && is_array($childContent['placements']) === true) + ); - $this->validateContainerDepth( - content: $childContent, - depth: $childDepth - ); - } - }//end foreach - }//end validateContainerDepth() + if ($isChildContainer === false) { + return; + } + + $childDepth = ($depth + 1); + if ($childDepth > self::MAX_CONTAINER_DEPTH) { + throw new InvalidArgumentException( + message: 'container_depth_exceeded' + ); + } + + $this->validateContainerDepth( + content: $childContent, + depth: $childDepth + ); + }//end validateChildDepth() }//end class diff --git a/lib/Service/WidgetService.php b/lib/Service/WidgetService.php index 25f2d1a8b..faf06f3be 100644 --- a/lib/Service/WidgetService.php +++ b/lib/Service/WidgetService.php @@ -20,256 +20,264 @@ use OCA\LaunchPad\Db\WidgetPlacement; use OCP\Dashboard\IManager; -use OCP\Dashboard\IWidget; -use OCP\Dashboard\IAPIWidget; -use OCP\Dashboard\IAPIWidgetV2; use OCP\IUserSession; /** * Service for discovering and querying Nextcloud dashboard widgets. */ -class WidgetService -{ - /** - * Constructor - * - * @param IManager $dashboardManager Dashboard manager interface. - * @param PlacementService $placementService Placement service for CRUD. - * @param WidgetFormatter $widgetFormatter Widget formatter service. - * @param WidgetItemLoader $widgetItemLoader Widget item loader service. - * @param IUserSession $userSession User session interface. - * @param MenuService $menuService Validator for `menu` widgets (REQ-MENU-002). - */ - public function __construct( - private readonly IManager $dashboardManager, - private readonly PlacementService $placementService, - private readonly WidgetFormatter $widgetFormatter, - private readonly WidgetItemLoader $widgetItemLoader, - private readonly IUserSession $userSession, - private readonly MenuService $menuService=new MenuService(), - ) { - }//end __construct() +class WidgetService { + /** + * Constructor + * + * @param IManager $dashboardManager Dashboard manager interface. + * @param PlacementService $placementService Placement service for CRUD. + * @param WidgetFormatter $widgetFormatter Widget formatter service. + * @param WidgetItemLoader $widgetItemLoader Widget item loader service. + * @param IUserSession $userSession User session interface. + * @param MenuService $menuService Validator for `menu` widgets (REQ-MENU-002). + */ + public function __construct( + private readonly IManager $dashboardManager, + private readonly PlacementService $placementService, + private readonly WidgetFormatter $widgetFormatter, + private readonly WidgetItemLoader $widgetItemLoader, + private readonly IUserSession $userSession, + private readonly MenuService $menuService = new MenuService(), + ) { + }//end __construct() - /** - * Validate the `content` blob of a widget placement before save. - * - * REQ-MENU-002 server-side hook. Currently only the `menu` widget type - * has a server-side validator; any future widget that needs save-time - * validation should add its own branch here so the dispatcher stays - * single-responsibility. - * - * @param string $widgetType Widget type identifier (e.g. `menu`). - * @param array $content Widget content blob. - * - * @return void - * @throws \InvalidArgumentException When the content blob is invalid. - * - * @spec openspec/specs/widgets/spec.md - */ - public function validateWidgetContent(string $widgetType, array $content): void - { - if ($widgetType !== 'menu') { - return; - } + /** + * Validate the `content` blob of a widget placement before save. + * + * REQ-MENU-002 server-side hook. Currently only the `menu` widget type + * has a server-side validator; any future widget that needs save-time + * validation should add its own branch here so the dispatcher stays + * single-responsibility. + * + * @param string $widgetType Widget type identifier (e.g. `menu`). + * @param array $content Widget content blob. + * + * @return void + * @throws \InvalidArgumentException When the content blob is invalid. + * + * @spec openspec/specs/widgets/spec.md + */ + public function validateWidgetContent(string $widgetType, array $content): void { + if ($widgetType !== 'menu') { + return; + } - $items = []; - if (isset($content['items']) === true && is_array($content['items']) === true) { - $items = $content['items']; - } + $items = []; + if (isset($content['items']) === true && is_array($content['items']) === true) { + $items = $content['items']; + } - $this->menuService->validateMenuConfig(content: $content); - $this->menuService->validateMenuItems(items: $items); - }//end validateWidgetContent() + $this->menuService->validateMenuConfig(content: $content); + $this->menuService->validateMenuItems(items: $items); + }//end validateWidgetContent() - /** - * Get all available widgets from Nextcloud. - * - * @return array The list of available widgets. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-32 - */ - public function getAvailableWidgets(): array - { - $widgets = $this->dashboardManager->getWidgets(); - $result = []; + /** + * Get all available widgets from Nextcloud. + * + * @return array The list of available widgets. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-32 + */ + public function getAvailableWidgets(): array { + $widgets = $this->dashboardManager->getWidgets(); + $result = []; - foreach ($widgets as $widget) { - $user = $this->userSession->getUser(); - $userId = ''; - if ($user !== null) { - $userId = $user->getUID(); - } + foreach ($widgets as $widget) { + $user = $this->userSession->getUser(); + $userId = ''; + if ($user !== null) { + $userId = $user->getUID(); + } - $result[] = $this->widgetFormatter->format( - widget: $widget, - userId: $userId - ); - } + $result[] = $this->widgetFormatter->format( + widget: $widget, + userId: $userId + ); + } - usort( - array: $result, - callback: function ($a, $b) { - return $a['order'] - $b['order']; - } - ); + usort( + array: $result, + callback: function ($a, $b) { + return $a['order'] - $b['order']; + } + ); - return $result; - }//end getAvailableWidgets() + return $result; + }//end getAvailableWidgets() - /** - * Get widget items for multiple widgets. - * - * @param string $userId The user ID. - * @param array $widgetIds The widget IDs. - * @param int $limit Maximum number of items per widget. - * - * @return array The widget items. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-33 - */ - public function getWidgetItems( - string $userId, - array $widgetIds, - int $limit=7 - ): array { - $widgets = $this->dashboardManager->getWidgets(); + /** + * Get widget items for multiple widgets. + * + * @param string $userId The user ID. + * @param array $widgetIds The widget IDs. + * @param int $limit Maximum number of items per widget. + * + * @return array The widget items. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-33 + */ + public function getWidgetItems( + string $userId, + array $widgetIds, + int $limit = 7, + ): array { + $widgets = $this->dashboardManager->getWidgets(); - return $this->widgetItemLoader->loadItems( - widgets: $widgets, - userId: $userId, - widgetIds: $widgetIds, - limit: $limit - ); - }//end getWidgetItems() + return $this->widgetItemLoader->loadItems( + widgets: $widgets, + userId: $userId, + widgetIds: $widgetIds, + limit: $limit + ); + }//end getWidgetItems() - /** - * Add a widget to a dashboard. - * - * @param int $dashboardId Dashboard ID. - * @param string $widgetId Widget ID. - * @param int $gridX Grid X position. - * @param int $gridY Grid Y position. - * @param int $gridWidth Grid width. - * @param int $gridHeight Grid height. - * @param array|null $content Optional per-type content payload for - * registry-driven custom widgets. - * - * @return WidgetPlacement The created widget placement. - * - * @spec openspec/specs/widgets/spec.md - */ - public function addWidget( - int $dashboardId, - string $widgetId, - int $gridX=0, - int $gridY=0, - int $gridWidth=4, - int $gridHeight=4, - ?array $content=null - ): WidgetPlacement { - if ($content !== null) { - $this->validateWidgetContent( - widgetType: $widgetId, - content: $content - ); - } + /** + * Add a widget to a dashboard. + * + * @param int $dashboardId Dashboard ID. + * @param string $widgetId Widget ID. + * @param int $gridX Grid X position. + * @param int $gridY Grid Y position. + * @param int $gridWidth Grid width. + * @param int $gridHeight Grid height. + * @param array|null $content Optional per-type content payload for + * registry-driven custom widgets. + * + * @return WidgetPlacement The created widget placement. + * + * @spec openspec/specs/widgets/spec.md + */ + public function addWidget( + int $dashboardId, + string $widgetId, + int $gridX = 0, + int $gridY = 0, + int $gridWidth = 4, + int $gridHeight = 4, + ?array $content = null, + ): WidgetPlacement { + if ($content !== null) { + $this->validateWidgetContent( + widgetType: $widgetId, + content: $content + ); + } - return $this->placementService->addWidget( - dashboardId: $dashboardId, - widgetId: $widgetId, - gridX: $gridX, - gridY: $gridY, - gridWidth: $gridWidth, - gridHeight: $gridHeight, - content: $content - ); - }//end addWidget() + return $this->placementService->addWidget( + dashboardId: $dashboardId, + widgetId: $widgetId, + gridX: $gridX, + gridY: $gridY, + gridWidth: $gridWidth, + gridHeight: $gridHeight, + content: $content + ); + }//end addWidget() - /** - * Add a tile to a dashboard using an array of tile data. - * - * @param int $dashboardId Dashboard ID. - * @param array $tileData Tile configuration data array. - * - * @return WidgetPlacement The created tile placement. - * - * @spec openspec/specs/widgets/spec.md - */ - public function addTileFromArray( - int $dashboardId, - array $tileData - ): WidgetPlacement { - return $this->placementService->addTileFromArray( - dashboardId: $dashboardId, - tileData: $tileData - ); - }//end addTileFromArray() + /** + * Add a tile to a dashboard using an array of tile data. + * + * @param int $dashboardId Dashboard ID. + * @param array $tileData Tile configuration data array. + * + * @return WidgetPlacement The created tile placement. + * + * @spec openspec/specs/widgets/spec.md + */ + public function addTileFromArray( + int $dashboardId, + array $tileData, + ): WidgetPlacement { + return $this->placementService->addTileFromArray( + dashboardId: $dashboardId, + tileData: $tileData + ); + }//end addTileFromArray() - /** - * Update a widget placement. - * - * @param int $placementId The placement ID. - * @param array $data The data to update. - * - * @return WidgetPlacement The updated widget placement. - * - * @spec openspec/specs/widgets/spec.md - */ - public function updatePlacement( - int $placementId, - array $data - ): WidgetPlacement { - return $this->placementService->updatePlacement( - placementId: $placementId, - data: $data - ); - }//end updatePlacement() + /** + * Update a widget placement. + * + * When the update carries a `content` payload, run the same save-time + * content validation as the create path ({@see self::addWidget()}) — + * the placement-update route became a content-write path once `content` + * was added to the extracted fields, and without this it would bypass + * the menu depth / required-field checks (REQ-MENU-002) that the create + * path enforces. The widget type is read from the stored placement. + * + * @param int $placementId The placement ID. + * @param array $data The data to update. + * + * @return WidgetPlacement The updated widget placement. + * + * @spec openspec/specs/widgets/spec.md + */ + public function updatePlacement( + int $placementId, + array $data, + ): WidgetPlacement { + if (isset($data['content']) === true && is_array($data['content']) === true) { + $placement = $this->placementService->getPlacement( + placementId: $placementId + ); + $this->validateWidgetContent( + widgetType: $placement->getWidgetId(), + content: $data['content'] + ); + } - /** - * Remove a widget placement. - * - * @param int $placementId The placement ID. - * - * @return void - * - * @spec openspec/specs/widgets/spec.md - */ - public function removePlacement(int $placementId): void - { - $this->placementService->removePlacement( - placementId: $placementId - ); - }//end removePlacement() + return $this->placementService->updatePlacement( + placementId: $placementId, + data: $data + ); + }//end updatePlacement() - /** - * Get placement by ID. - * - * @param int $placementId The placement ID. - * - * @return WidgetPlacement The widget placement. - * - * @spec openspec/specs/widgets/spec.md - */ - public function getPlacement(int $placementId): WidgetPlacement - { - return $this->placementService->getPlacement( - placementId: $placementId - ); - }//end getPlacement() + /** + * Remove a widget placement. + * + * @param int $placementId The placement ID. + * + * @return void + * + * @spec openspec/specs/widgets/spec.md + */ + public function removePlacement(int $placementId): void { + $this->placementService->removePlacement( + placementId: $placementId + ); + }//end removePlacement() - /** - * Get all placements for a dashboard. - * - * @param int $dashboardId The dashboard ID. - * - * @return WidgetPlacement[] The list of placements. - * - * @spec openspec/specs/widgets/spec.md - */ - public function getDashboardPlacements(int $dashboardId): array - { - return $this->placementService->getDashboardPlacements( - dashboardId: $dashboardId - ); - }//end getDashboardPlacements() + /** + * Get placement by ID. + * + * @param int $placementId The placement ID. + * + * @return WidgetPlacement The widget placement. + * + * @spec openspec/specs/widgets/spec.md + */ + public function getPlacement(int $placementId): WidgetPlacement { + return $this->placementService->getPlacement( + placementId: $placementId + ); + }//end getPlacement() + + /** + * Get all placements for a dashboard. + * + * @param int $dashboardId The dashboard ID. + * + * @return WidgetPlacement[] The list of placements. + * + * @spec openspec/specs/widgets/spec.md + */ + public function getDashboardPlacements(int $dashboardId): array { + return $this->placementService->getDashboardPlacements( + dashboardId: $dashboardId + ); + }//end getDashboardPlacements() }//end class diff --git a/lib/Settings/LaunchPadAdmin.php b/lib/Settings/LaunchPadAdmin.php index 6c37cdbd0..768bc6813 100644 --- a/lib/Settings/LaunchPadAdmin.php +++ b/lib/Settings/LaunchPadAdmin.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); @@ -39,148 +39,142 @@ use OCP\Settings\IDelegatedSettings; use OCP\Util; -class LaunchPadAdmin implements IDelegatedSettings -{ - /** - * Constructor. - * - * @param IInitialState $initialState The Nextcloud initial-state service. - * @param IGroupManager $groupManager Group manager (full group list). - * @param WidgetService $widgetService Available-widgets descriptor formatter. - * @param AdminSettingMapper $settingMapper Admin settings store - * (legacy configured-groups list). - * @param AdminSettingsService $settingsService Admin-settings service exposing - * the `group_order` setting - * (REQ-ASET-012). - * @param DashboardService $dashboardService Dashboard service exposing - * the `allow_user_dashboards` - * flag (REQ-ASET-003). - * @param FileService $fileService link-button-widget extension - * allow-list reader. - */ - public function __construct( - private readonly IInitialState $initialState, - private readonly IGroupManager $groupManager, - private readonly WidgetService $widgetService, - private readonly AdminSettingMapper $settingMapper, - private readonly AdminSettingsService $settingsService, - private readonly DashboardService $dashboardService, - private readonly FileService $fileService, - ) { - }//end __construct() +class LaunchPadAdmin implements IDelegatedSettings { + /** + * Constructor. + * + * @param IInitialState $initialState The Nextcloud initial-state service. + * @param IGroupManager $groupManager Group manager (full group list). + * @param WidgetService $widgetService Available-widgets descriptor formatter. + * @param AdminSettingMapper $settingMapper Admin settings store + * (legacy configured-groups list). + * @param AdminSettingsService $settingsService Admin-settings service exposing + * the `group_order` setting + * (REQ-ASET-012). + * @param DashboardService $dashboardService Dashboard service exposing + * the `allow_user_dashboards` + * flag (REQ-ASET-003). + * @param FileService $fileService link-button-widget extension + * allow-list reader. + */ + public function __construct( + private readonly IInitialState $initialState, + private readonly IGroupManager $groupManager, + private readonly WidgetService $widgetService, + private readonly AdminSettingMapper $settingMapper, + private readonly AdminSettingsService $settingsService, + private readonly DashboardService $dashboardService, + private readonly FileService $fileService, + ) { + }//end __construct() - /** - * Get the admin settings form. - * - * Wires the full admin initial-state contract (REQ-INIT-002) before - * rendering the template — every required key is set on the builder - * so the page never renders with a partial payload. - * - * NOTE: Nextcloud admin-UI registration boilerplate; behaviour defined - * by the OCP\Settings\ISettings contract, not a LaunchPad spec. Intentionally - * left without an `@spec` tag (see - * `openspec/changes/archive/2026-05-03-spec-annotation-pass/design.md`). - * - * @return TemplateResponse The template response. - */ - public function getForm(): TemplateResponse - { - Util::addScript( - application: Application::APP_ID, - file: 'launchpad-admin' - ); + /** + * Get the admin settings form. + * + * Wires the full admin initial-state contract (REQ-INIT-002) before + * rendering the template — every required key is set on the builder + * so the page never renders with a partial payload. + * + * NOTE: Nextcloud admin-UI registration boilerplate; behaviour defined + * by the OCP\Settings\ISettings contract, not a LaunchPad spec. Intentionally + * left without an `@spec` tag (see + * `openspec/changes/archive/2026-05-03-spec-annotation-pass/design.md`). + * + * @return TemplateResponse The template response. + */ + public function getForm(): TemplateResponse { + Util::addScript( + application: Application::APP_ID, + file: 'launchpad-admin' + ); - $allGroups = []; - foreach ($this->groupManager->search(search: '') as $group) { - $allGroups[] = [ - 'id' => $group->getGID(), - 'displayName' => $group->getDisplayName(), - ]; - } + $allGroups = []; + foreach ($this->groupManager->search(search: '') as $group) { + $allGroups[] = [ + 'id' => $group->getGID(), + 'displayName' => $group->getDisplayName(), + ]; + } - // REQ-ASET-012: prefer the new `group_order` setting (defensive - // read returns []). Falls back to the legacy `configured_groups` - // key for installs that wrote it before the cutover so the - // initial render still shows the admin's previous selection. - $configuredGroups = $this->settingsService->getGroupOrder(); - if ($configuredGroups === []) { - $legacy = $this->settingMapper->getValue( - key: 'configured_groups', - default: [] - ); - if (is_array($legacy) === true) { - $filtered = array_filter( - array: $legacy, - callback: static function ($entry) { - return is_string($entry) === true && $entry !== ''; - } - ); - $configuredGroups = array_values(array: $filtered); - } - } + // REQ-ASET-012: prefer the new `group_order` setting (defensive + // read returns []). Falls back to the legacy `configured_groups` + // key for installs that wrote it before the cutover so the + // initial render still shows the admin's previous selection. + $configuredGroups = $this->settingsService->getGroupOrder(); + if ($configuredGroups === []) { + $legacy = $this->settingMapper->getValue( + key: 'configured_groups', + default: [] + ); + if (is_array($legacy) === true) { + $filtered = array_filter( + array: $legacy, + callback: static function ($entry) { + return is_string($entry) === true && $entry !== ''; + } + ); + $configuredGroups = array_values(array: $filtered); + } + } - $allowUserDashboards = $this->dashboardService->getAllowUserDashboards(); + $allowUserDashboards = $this->dashboardService->getAllowUserDashboards(); - (new InitialStateBuilder( - initialState: $this->initialState, - page: Page::ADMIN - )) - ->setAllGroups($allGroups) - ->setConfiguredGroups($configuredGroups) - ->setWidgets($this->widgetService->getAvailableWidgets()) - ->setAllowUserDashboards($allowUserDashboards) - ->setLinkCreateFileExtensions( - $this->fileService->getAllowedExtensions() - ) - ->apply(); + (new InitialStateBuilder( + initialState: $this->initialState, + page: Page::ADMIN + )) + ->setAllGroups($allGroups) + ->setConfiguredGroups($configuredGroups) + ->setWidgets($this->widgetService->getAvailableWidgets()) + ->setAllowUserDashboards($allowUserDashboards) + ->setLinkCreateFileExtensions( + $this->fileService->getAllowedExtensions() + ) + ->apply(); - return new TemplateResponse( - appName: Application::APP_ID, - templateName: 'settings/admin' - ); - }//end getForm() + return new TemplateResponse( + appName: Application::APP_ID, + templateName: 'settings/admin' + ); + }//end getForm() - /** - * Get the settings section ID. - * - * @return string The section ID. - */ - public function getSection(): string - { - return 'launchpad'; - }//end getSection() + /** + * Get the settings section ID. + * + * @return string The section ID. + */ + public function getSection(): string { + return 'launchpad'; + }//end getSection() - /** - * Get the settings priority. - * - * @return int The priority. - */ - public function getPriority(): int - { - return 10; - }//end getPriority() + /** + * Get the settings priority. + * + * @return int The priority. + */ + public function getPriority(): int { + return 10; + }//end getPriority() - /** - * Human-readable name of the delegated settings section. - * - * @return string|null The section name, or null to use the section default. - */ - public function getName(): ?string - { - return null; - }//end getName() + /** + * Human-readable name of the delegated settings section. + * + * @return string|null The section name, or null to use the section default. + */ + public function getName(): ?string { + return null; + }//end getName() - /** - * App config keys an authorized (delegated) admin may manage. - * - * Returned as a map of appId => list of allowed config keys. LaunchPad - * exposes no delegatable sub-keys yet, so this is intentionally empty; - * the attribute still scopes the endpoint to full admins. - * - * @return array Map of appId to allowed config keys. - */ - public function getAuthorizedAppConfig(): array - { - return []; - }//end getAuthorizedAppConfig() + /** + * App config keys an authorized (delegated) admin may manage. + * + * Returned as a map of appId => list of allowed config keys. LaunchPad + * exposes no delegatable sub-keys yet, so this is intentionally empty; + * the attribute still scopes the endpoint to full admins. + * + * @return array Map of appId to allowed config keys. + */ + public function getAuthorizedAppConfig(): array { + return []; + }//end getAuthorizedAppConfig() }//end class diff --git a/lib/Settings/LaunchPadAdminSection.php b/lib/Settings/LaunchPadAdminSection.php index 1768461fd..67d0f7d53 100644 --- a/lib/Settings/LaunchPadAdminSection.php +++ b/lib/Settings/LaunchPadAdminSection.php @@ -23,65 +23,60 @@ use OCP\IURLGenerator; use OCP\Settings\IIconSection; -class LaunchPadAdminSection implements IIconSection -{ - /** - * Constructor - * - * @param IL10N $l The localization service. - * @param IURLGenerator $urlGenerator The URL generator. - */ - public function __construct( - private readonly IL10N $l, - private readonly IURLGenerator $urlGenerator, - ) { - }//end __construct() +class LaunchPadAdminSection implements IIconSection { + /** + * Constructor + * + * @param IL10N $l The localization service. + * @param IURLGenerator $urlGenerator The URL generator. + */ + public function __construct( + private readonly IL10N $l, + private readonly IURLGenerator $urlGenerator, + ) { + }//end __construct() - /** - * Get the section ID. - * - * NOTE: Nextcloud admin-UI registration boilerplate; behaviour defined - * by the OCP\Settings\IIconSection contract, not a LaunchPad spec. - * Intentionally left without an `@spec` tag (see - * `openspec/changes/archive/2026-05-03-spec-annotation-pass/design.md`). - * - * @return string The section ID. - */ - public function getID(): string - { - return 'launchpad'; - }//end getID() + /** + * Get the section ID. + * + * NOTE: Nextcloud admin-UI registration boilerplate; behaviour defined + * by the OCP\Settings\IIconSection contract, not a LaunchPad spec. + * Intentionally left without an `@spec` tag (see + * `openspec/changes/archive/2026-05-03-spec-annotation-pass/design.md`). + * + * @return string The section ID. + */ + public function getID(): string { + return 'launchpad'; + }//end getID() - /** - * Get the section name. - * - * @return string The section name. - */ - public function getName(): string - { - return $this->l->t(text: 'LaunchPad'); - }//end getName() + /** + * Get the section name. + * + * @return string The section name. + */ + public function getName(): string { + return $this->l->t(text: 'LaunchPad'); + }//end getName() - /** - * Get the section priority. - * - * @return int The priority. - */ - public function getPriority(): int - { - return 80; - }//end getPriority() + /** + * Get the section priority. + * + * @return int The priority. + */ + public function getPriority(): int { + return 80; + }//end getPriority() - /** - * Get the section icon URL. - * - * @return string The icon URL. - */ - public function getIcon(): string - { - return $this->urlGenerator->imagePath( - appName: Application::APP_ID, - file: 'app-dark.svg' - ); - }//end getIcon() + /** + * Get the section icon URL. + * + * @return string The icon URL. + */ + public function getIcon(): string { + return $this->urlGenerator->imagePath( + appName: Application::APP_ID, + file: 'app-dark.svg' + ); + }//end getIcon() }//end class diff --git a/lib/Settings/launchpad_mock_register.json b/lib/Settings/launchpad_mock_register.json new file mode 100644 index 000000000..665377e16 --- /dev/null +++ b/lib/Settings/launchpad_mock_register.json @@ -0,0 +1,360 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "launchpad demo data", + "version": "1.0.0", + "description": "Demo data covering every schema this app supplies, offered as the first step of the app's setup walkthrough. Generated from the schemas themselves, so every object satisfies the schema that will validate it." + }, + "x-openregister": { + "type": "mock", + "app": "launchpad", + "description": "Demo data for launchpad. NOT installed automatically — a mock register is imported on demand, from the setup walkthrough or `occ openregister:descriptors:list --app=launchpad --import=`." + }, + "paths": {}, + "components": { + "registers": { + "launchpad": { + "slug": "launchpad", + "title": "LaunchPad Register (demo)", + "version": "1.0.1", + "description": "Demo data for LaunchPad Register. Generated from the register's own schemas — see hydra-gates/scripts/lib/generate_mock_register.py." + } + }, + "schemas": { + "Dashboard": { + "slug": "dashboard", + "icon": "ViewDashboardOutline", + "version": "1.0.0", + "title": "Dashboard", + "description": "A user dashboard containing widget placements and layout configuration", + "type": "object", + "x-openregister": { + "schemaType": "schema:WebPage", + "active": true, + "hardDelete": false, + "searchable": false, + "mailEnabled": false + }, + "required": [ + "slug", + "title" + ], + "properties": { + "slug": { + "title": "Slug", + "type": "string", + "description": "URL-safe identifier unique within the owner's space", + "example": "sales-overview" + }, + "title": { + "title": "Title", + "type": "string", + "description": "Human-readable dashboard title", + "example": "Sales Overview" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Optional dashboard description", + "example": "Key sales metrics and pipeline status" + }, + "version": { + "title": "Version", + "type": "string", + "description": "Dashboard schema version", + "default": "1.0.0", + "example": "1.0.0" + }, + "widgets": { + "title": "Widgets", + "type": "array", + "description": "Ordered list of v2 widget entries for this dashboard", + "default": [], + "items": { + "type": "object", + "description": "A widget placement entry (widgetKey, slot, grid position, optional props/dataSource/tabGroup)" + } + }, + "sharedWith": { + "title": "Shared With", + "type": "array", + "description": "Nextcloud user IDs the dashboard is shared with", + "default": [], + "items": { + "type": "string" + } + }, + "isDefault": { + "title": "Is Default", + "type": "boolean", + "description": "Whether this is the user's landing dashboard", + "default": false + }, + "type": { + "title": "Dashboard Type", + "type": "string", + "description": "Dashboard scope type: 'user' (personal), 'admin_template', or 'group_shared'", + "enum": [ + "user", + "admin_template", + "group_shared" + ], + "default": "user", + "example": "group_shared" + }, + "groupId": { + "title": "Group ID", + "type": "string", + "description": "Nextcloud group ID for group_shared dashboards. Reserved value 'default' means visible to every user (REQ-DASH-012). Absent for user and admin_template dashboards — the property is not in `required`, which is how OpenRegister models optionality. A union type such as [\"string\", \"null\"] is REJECTED by the schema importer (only scalar types are accepted), and that rejection is what silently prevented this whole schema — and therefore the register's reference to it — from ever being created.", + "example": "marketing" + } + }, + "x-openregister-seeds": [ + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "gemeente-overzicht" + }, + "slug": "gemeente-overzicht", + "title": "Gemeente Overzicht", + "description": "Dagelijks overzicht voor gemeentemedewerkers met taken, berichten en kalender", + "version": "1.0.0", + "widgets": [ + { + "widgetKey": "recommendations", + "slot": "main", + "gridX": 0, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + }, + { + "widgetKey": "calendar", + "slot": "main", + "gridX": 4, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + }, + { + "widgetKey": "activity", + "slot": "main", + "gridX": 8, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + } + ], + "sharedWith": [], + "isDefault": true + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "consultancy-werkplek" + }, + "slug": "consultancy-werkplek", + "title": "Consultancy Werkplek", + "description": "Werkplek voor consultants met projectoverzicht, taken en tijdregistratie", + "version": "1.0.0", + "widgets": [ + { + "widgetKey": "recommendations", + "slot": "main", + "gridX": 0, + "gridY": 0, + "gridWidth": 6, + "gridHeight": 4 + }, + { + "widgetKey": "activity", + "slot": "main", + "gridX": 6, + "gridY": 0, + "gridWidth": 6, + "gridHeight": 4 + } + ], + "sharedWith": [], + "isDefault": false + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "welcome-to-launchpad" + }, + "slug": "welcome-to-launchpad", + "title": "Welcome to LaunchPad", + "description": "Default landing dashboard visible to every user — powered by the 'default' synthetic group (REQ-DASH-012)", + "version": "1.0.0", + "type": "group_shared", + "groupId": "default", + "widgets": [ + { + "widgetKey": "announcements", + "slot": "main", + "gridX": 0, + "gridY": 0, + "gridWidth": 6, + "gridHeight": 4 + }, + { + "widgetKey": "activity", + "slot": "main", + "gridX": 6, + "gridY": 0, + "gridWidth": 6, + "gridHeight": 4 + } + ], + "sharedWith": [], + "isDefault": true + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "active-campaigns" + }, + "slug": "active-campaigns", + "title": "Active Campaigns", + "description": "Marketing team shared dashboard — scoped to the 'marketing' group (REQ-DASH-011)", + "version": "1.0.0", + "type": "group_shared", + "groupId": "marketing", + "widgets": [ + { + "widgetKey": "kpi-tile", + "slot": "main", + "gridX": 0, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + }, + { + "widgetKey": "chart-widget", + "slot": "main", + "gridX": 4, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + }, + { + "widgetKey": "activity", + "slot": "main", + "gridX": 8, + "gridY": 0, + "gridWidth": 4, + "gridHeight": 4 + } + ], + "sharedWith": [], + "isDefault": false + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "sprint-overview" + }, + "slug": "sprint-overview", + "title": "Sprint Overview", + "description": "Engineering team shared dashboard — scoped to the 'engineering' group (REQ-DASH-011)", + "version": "1.0.0", + "type": "group_shared", + "groupId": "engineering", + "widgets": [ + { + "widgetKey": "burndown", + "slot": "main", + "gridX": 0, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 4 + }, + { + "widgetKey": "open-prs", + "slot": "main", + "gridX": 3, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 4 + }, + { + "widgetKey": "ci-status", + "slot": "main", + "gridX": 6, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 4 + }, + { + "widgetKey": "recommendations", + "slot": "main", + "gridX": 9, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 4 + } + ], + "sharedWith": [], + "isDefault": false + } + ] + } + }, + "objects": [ + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "dashboard-voorbeeld-title-1-1" + }, + "slug": "Voorbeeld Slug 1", + "title": "Voorbeeld Title 1", + "description": "Voorbeeld Description 1", + "version": "1.0.0", + "widgets": [], + "sharedWith": [], + "isDefault": false, + "type": "user", + "groupId": "Voorbeeld Groupid 1" + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "dashboard-voorbeeld-title-2-2" + }, + "slug": "Voorbeeld Slug 2", + "title": "Voorbeeld Title 2", + "description": "Voorbeeld Description 2", + "version": "1.0.0", + "widgets": [], + "sharedWith": [], + "isDefault": false, + "type": "admin_template", + "groupId": "Voorbeeld Groupid 2" + }, + { + "@self": { + "register": "launchpad", + "schema": "Dashboard", + "slug": "dashboard-voorbeeld-title-3-3" + }, + "slug": "Voorbeeld Slug 3", + "title": "Voorbeeld Title 3", + "description": "Voorbeeld Description 3", + "version": "1.0.0", + "widgets": [], + "sharedWith": [], + "isDefault": false, + "type": "group_shared", + "groupId": "Voorbeeld Groupid 3" + } + ] + } +} diff --git a/lib/Settings/launchpad_register.json b/lib/Settings/launchpad_register.json index 13391acf8..0c0c97a1d 100644 --- a/lib/Settings/launchpad_register.json +++ b/lib/Settings/launchpad_register.json @@ -3,7 +3,7 @@ "info": { "title": "LaunchPad Register", "description": "Drag-and-drop dashboard configuration storage for LaunchPad", - "version": "1.0.0" + "version": "1.0.1" }, "x-openregister": { "type": "application", @@ -13,14 +13,26 @@ }, "paths": {}, "components": { + "registers": { + "launchpad": { + "slug": "launchpad", + "title": "LaunchPad Register", + "version": "1.0.1", + "description": "LaunchPad dashboards stored as OpenRegister objects — the source of the v2 runtime manifest (ADR-036 Decision 8).", + "published": true, + "schemas": ["dashboard"], + "folder": "LaunchPad" + } + }, "schemas": { "Dashboard": { "slug": "dashboard", - "icon": "ViewDashboard", + "icon": "ViewDashboardOutline", "version": "1.0.0", "title": "Dashboard", "description": "A user dashboard containing widget placements and layout configuration", "type": "object", + "x-schema-org": "schema:WebPage", "x-openregister": { "schemaType": "schema:WebPage", "active": true, @@ -34,27 +46,32 @@ ], "properties": { "slug": { + "title": "Slug", "type": "string", "description": "URL-safe identifier unique within the owner's space", "example": "sales-overview" }, "title": { + "title": "Title", "type": "string", "description": "Human-readable dashboard title", "example": "Sales Overview" }, "description": { + "title": "Description", "type": "string", "description": "Optional dashboard description", "example": "Key sales metrics and pipeline status" }, "version": { + "title": "Version", "type": "string", "description": "Dashboard schema version", "default": "1.0.0", "example": "1.0.0" }, "widgets": { + "title": "Widgets", "type": "array", "description": "Ordered list of v2 widget entries for this dashboard", "default": [], @@ -64,6 +81,7 @@ } }, "sharedWith": { + "title": "Shared With", "type": "array", "description": "Nextcloud user IDs the dashboard is shared with", "default": [], @@ -72,11 +90,13 @@ } }, "isDefault": { + "title": "Is Default", "type": "boolean", "description": "Whether this is the user's landing dashboard", "default": false }, "type": { + "title": "Dashboard Type", "type": "string", "description": "Dashboard scope type: 'user' (personal), 'admin_template', or 'group_shared'", "enum": ["user", "admin_template", "group_shared"], @@ -84,8 +104,9 @@ "example": "group_shared" }, "groupId": { - "type": ["string", "null"], - "description": "Nextcloud group ID for group_shared dashboards. Reserved value 'default' means visible to every user (REQ-DASH-012). Null for user and admin_template dashboards.", + "title": "Group ID", + "type": "string", + "description": "Nextcloud group ID for group_shared dashboards. Reserved value 'default' means visible to every user (REQ-DASH-012). Absent for user and admin_template dashboards — the property is not in `required`, which is how OpenRegister models optionality. A union type such as [\"string\", \"null\"] is REJECTED by the schema importer (only scalar types are accepted), and that rejection is what silently prevented this whole schema — and therefore the register's reference to it — from ever being created.", "example": "marketing" } }, diff --git a/lib/actions.seed.json b/lib/actions.seed.json index 890edba98..919e81564 100644 --- a/lib/actions.seed.json +++ b/lib/actions.seed.json @@ -1,5 +1,5 @@ { - "$comment": "ADR-023 action matrix seed for launchpad. Default admin-only; broaden via Admin Settings > LaunchPad > Action authorization.", + "$comment": "ADR-023 action matrix seed for launchpad. Administrative actions are admin-only; the ordinary end-user surface carries the \"@all\" sentinel (every authenticated user) so a fresh install is usable by non-admins. Every \"@all\" action below is still gated per object by PermissionService (ownership / share level / group membership) — \"@all\" grants the right to CALL the endpoint, never the right to touch someone else's dashboard. Narrow or broaden via Admin Settings > LaunchPad > Action authorization.", "actions": { "admin.get-my-role": ["admin"], "admin-org-navigation.get-org-navigation": ["admin"], @@ -8,72 +8,76 @@ "analytics.dashboard-detail": ["admin"], "analytics.instance-summary": ["admin"], "analytics.export-csv": ["admin"], - "dashboard.list": ["admin"], - "dashboard.visible": ["admin"], - "dashboard.get-active": ["admin"], - "dashboard.show": ["admin"], - "dashboard.create": ["admin"], - "dashboard.update": ["admin"], - "dashboard.delete": ["admin"], - "dashboard.tree": ["admin"], - "dashboard.by-path": ["admin"], - "dashboard.compute-path": ["admin"], - "dashboard.activate": ["admin"], - "dashboard.list-group": ["admin"], - "dashboard.get-group": ["admin"], - "dashboard.set-active-dashboard": ["admin"], - "dashboard.set-default-dashboard": ["admin"], - "dashboard.get-default-dashboard": ["admin"], + "tile-analytics.record-click": ["admin", "@all"], + "tile-analytics.top-tiles": ["admin"], + "tile-analytics.dashboard-breakdown": ["admin"], + "tile-analytics.export-csv": ["admin"], + "dashboard.list": ["admin", "@all"], + "dashboard.visible": ["admin", "@all"], + "dashboard.get-active": ["admin", "@all"], + "dashboard.show": ["admin", "@all"], + "dashboard.create": ["admin", "@all"], + "dashboard.update": ["admin", "@all"], + "dashboard.delete": ["admin", "@all"], + "dashboard.tree": ["admin", "@all"], + "dashboard.by-path": ["admin", "@all"], + "dashboard.compute-path": ["admin", "@all"], + "dashboard.activate": ["admin", "@all"], + "dashboard.list-group": ["admin", "@all"], + "dashboard.get-group": ["admin", "@all"], + "dashboard.set-active-dashboard": ["admin", "@all"], + "dashboard.set-default-dashboard": ["admin", "@all"], + "dashboard.get-default-dashboard": ["admin", "@all"], "dashboard.publish": ["admin"], "dashboard.unpublish": ["admin"], "dashboard.schedule": ["admin"], - "dashboard.view-event": ["admin"], - "dashboard-lock.acquire": ["admin"], - "dashboard-lock.heartbeat": ["admin"], - "dashboard-lock.release": ["admin"], + "dashboard.view-event": ["admin", "@all"], + "dashboard-lock.acquire": ["admin", "@all"], + "dashboard-lock.heartbeat": ["admin", "@all"], + "dashboard-lock.release": ["admin", "@all"], "dashboard-lock.force-release": ["admin"], - "dashboard-lock.get": ["admin"], - "dashboard-metadata.get-metadata": ["admin"], + "dashboard-lock.get": ["admin", "@all"], + "dashboard-metadata.get-metadata": ["admin", "@all"], "dashboard-metadata.set-metadata": ["admin"], - "dashboard-reaction.get-reactions": ["admin"], - "dashboard-reaction.add-reaction": ["admin"], - "dashboard-reaction.remove-reaction": ["admin"], - "dashboard-reaction.get-reactors-by-emoji": ["admin"], - "dashboard-translation.list": ["admin"], + "dashboard-reaction.get-reactions": ["admin", "@all"], + "dashboard-reaction.add-reaction": ["admin", "@all"], + "dashboard-reaction.remove-reaction": ["admin", "@all"], + "dashboard-reaction.get-reactors-by-emoji": ["admin", "@all"], + "dashboard-translation.list": ["admin", "@all"], "dashboard-translation.create": ["admin"], "dashboard-translation.update": ["admin"], "dashboard-translation.destroy": ["admin"], "dashboard-translation.set-primary": ["admin"], - "dashboard-translation.resolved": ["admin"], + "dashboard-translation.resolved": ["admin", "@all"], "dashboard-version.list-versions": ["admin"], "dashboard-version.fetch-version": ["admin"], "dashboard-version.create-version": ["admin"], "dashboard-version.restore-version": ["admin"], - "manifest.index": ["admin"], + "manifest.index": ["admin", "@all"], "metadata-admin.list-fields": ["admin"], "metadata-admin.create-field": ["admin"], "metadata-admin.get-field": ["admin"], "metadata-admin.update-field": ["admin"], "metadata-admin.delete-field": ["admin"], "people-widget.get-users": ["admin"], - "resource-serve.get-resource": ["admin"], - "resource-serve.list-resources": ["admin"], + "resource-serve.get-resource": ["admin", "@all"], + "resource-serve.list-resources": ["admin", "@all"], "rule.get-rules": ["admin"], "rule.add-rule": ["admin"], "rule.update-rule": ["admin"], "rule.delete-rule": ["admin"], - "template.gallery": ["admin"], - "tile.index": ["admin"], + "template.gallery": ["admin", "@all"], + "tile.index": ["admin", "@all"], "tile.create": ["admin"], "tile.update": ["admin"], "tile.destroy": ["admin"], - "widget.list-available": ["admin"], - "widget.get-items": ["admin"], - "widget.add-widget": ["admin"], - "widget.add-tile": ["admin"], - "widget.update-placement": ["admin"], - "widget.remove-placement": ["admin"], - "widget.news-items": ["admin"], - "widget.calendar-events": ["admin"] + "widget.list-available": ["admin", "@all"], + "widget.get-items": ["admin", "@all"], + "widget.add-widget": ["admin", "@all"], + "widget.add-tile": ["admin", "@all"], + "widget.update-placement": ["admin", "@all"], + "widget.remove-placement": ["admin", "@all"], + "widget.news-items": ["admin", "@all"], + "widget.calendar-events": ["admin", "@all"] } } diff --git a/openspec/architecture/adr-023-action-authorization.md b/openspec/architecture/adr-023-action-authorization.md new file mode 100644 index 000000000..7e9c5d70d --- /dev/null +++ b/openspec/architecture/adr-023-action-authorization.md @@ -0,0 +1,168 @@ +# ADR-023: Action-level authorization (LaunchPad adoption record) + +**Status:** accepted +**Date:** 2026-08-08 +**Canonical decision:** `hydra/openspec/architecture/adr-023-action-authorization.md` + +## Why this file exists + +Twenty-seven `@spec` tags across six files already pointed at this path: + +| file | tags | +|---|---| +| `lib/Service/ActionAuthService.php` | 6 | +| `lib/Controller/ActionMatrixController.php` | 4 | +| `lib/Repair/ApplyActionBaseline.php` | 4 | +| `lib/Repair/InitializeActions.php` | 4 | +| `src/components/admin/ActionAuthMatrix.vue` | 8 | +| `src/services/api.js` | 1 | + +Nothing was here. A whole implemented capability — a service, a controller, two +repair steps, an admin matrix UI and its API client — was annotated against a +document that had never been written, so every one of those tags was a dangling +reference (`gate-46 spec-anchor-existence`). + +The decision itself is **not** LaunchPad's to make: it is a company-wide ADR and +its canonical home is hydra. This file is the **adoption record** — what +LaunchPad concretely does to satisfy ADR-023, expressed against this repo's own +code so the anchors resolve to something that actually describes what they +annotate. When the two disagree, hydra wins and this file is the bug. + +Anchors cannot simply point at hydra: gate-46 matches `@spec openspec/…` and +resolves it inside the repo, so a cross-repo path would not be a reference — it +would be invisible. + +## Context + +ADR-023 splits authorization in two: + +- **Data RBAC** — who may read/write which objects — is OpenRegister's job + (ADR-022). Apps never roll their own. +- **Action RBAC** — who may *invoke* which controller method — is the app's job, + and must be declarative, admin-visible without a code change, and mechanically + checkable. + +LaunchPad has both audiences in one app: end users living on the dashboard +canvas, and tenant admins governing sharing, retention and ops (ADR-001). Action +RBAC is what keeps the second group's surface out of the first group's reach +without hardcoding `isAdmin()` into controller bodies. + +## Decision — how LaunchPad implements it + +### 1. One service, one entry point + +`OCA\LaunchPad\Service\ActionAuthService::requireAction(IUser, string $action)` +is the only action-authorization decision point. It throws +`OCSForbiddenException` when the caller may not proceed. There are **80 call +sites across 18 controllers**. + +Resolution order, and it matters: + +1. **Nextcloud admin passes** — break-glass for ops and debugging. +2. **`@all` sentinel passes** — checked *before* the admin-only short-circuit, + so an entry of `["admin", "@all"]` reads as "everyone", not "admin only". +3. An empty entry, or exactly `["admin"]`, **denies** every non-admin. +4. Otherwise the user's group ids must intersect the entry, with `admin` and + `@all` removed from it first — neither is a real group membership to match + against. + +Group ids come from `AdminTemplateService::getUserGroupIdsFor()`, the +single-source-of-truth accessor (REQ-TMPL-013), not from `IGroupManager` +directly. + +### 2. The matrix is data, not code + +The action → groups mapping is a JSON document in `IAppConfig` under +`launchpad`/`actions`. Both `getMatrix()` and `setMatrix()` normalise on the way +through: non-string action keys, non-array entries, non-string and empty group +ids are discarded, and group lists are de-duplicated. + +**Default-deny.** `getAllowedGroups()` falls back to `["admin"]` for any action +not in the matrix, and malformed or unparseable JSON returns an empty matrix — +which, through that same fallback, means admin-only for everything. A corrupt +config fails closed. + +### 3. `@all` exists because Nextcloud has no "everyone" group + +Nextcloud has no real group containing every account, so a matrix that can only +name groups cannot express "ordinary users may list their own dashboards" — +which is why the first shipped default locked every non-admin out of the app. +`ActionAuthService::GROUP_ALL_USERS` (`@all`) closes that gap. + +The `@` prefix is load-bearing: group ids created through the UI or the +provisioning API never start with it, so the sentinel can neither shadow nor be +shadowed by a real group. + +**`@all` grants the right to CALL an endpoint, never the right to touch someone +else's dashboard.** Every `@all` action that mutates a dashboard still passes +through `PermissionService` for the per-object ownership / share-level check. +Action RBAC and data RBAC are both required; neither substitutes for the other. + +### 4. Editing the matrix is admin-only, and does not use the matrix + +`ActionMatrixController::getMatrix()` / `setMatrix()` carry +`#[AuthorizedAdminSetting(LaunchPadAdmin::class)]` and perform **no** in-body +`requireAction()` call. Per ADR-023, operations that configure the authorization +system itself are gated at the route layer instead — otherwise the matrix would +govern who may rewrite the matrix. + +`ActionAuthService::setMatrix()` deliberately does not gate its own writes; its +docblock says so, and its only caller is that admin-only endpoint. + +### 5. Seeding and upgrades: seed once, then never re-broaden + +Two repair steps, with different jobs: + +- **`InitializeActions`** seeds the matrix from `lib/actions.seed.json` on + install. +- **`ApplyActionBaseline`** is the upgrade path. It is versioned + (`BASELINE_VERSION` vs a stored applied-version) and it broadens **only** + entries still holding the pristine `["admin"]` default, or absent entirely. + Any entry an admin has changed is counted as `preserved` and left alone. + +That asymmetry is the point: an admin who has deliberately narrowed an action +back to admin-only must not have it re-broadened by the next upgrade. If the seed +is unreadable the step warns and logs, and leaves the matrix untouched — it never +falls back to a permissive default. + +### 6. The seed is the complete enforced set + +`lib/actions.seed.json` declares **78** actions: 36 admin-only, 42 carrying +`@all`. Administrative surfaces (analytics, tile catalogue, metadata field +definitions, conditional rules, publication workflow, version history, org +navigation, `dashboard-lock.force-release`) are admin-only; the ordinary +end-user surface ships with `@all` so a fresh install is usable. + +The seed and the code agree **exactly**, in both directions — every seeded action +is enforced by a `requireAction()` call, and every enforced action is seeded: + +``` +seeded not enforced: [] +enforced not seeded: [] +seed=78 used=78 +``` + +Either kind of drift is a defect. A seeded-but-unenforced action is a +configuration surface that governs nothing — an admin narrows it and nothing +changes. An enforced-but-unseeded action falls through `getAllowedGroups()` to +`["admin"]` and silently becomes admin-only on every install, which is how an +end-user endpoint disappears for non-admins without anything reporting an error. + +## Consequences + +- Admins retune action access from Admin Settings → LaunchPad → Action + authorization; no code change, no deploy. +- Adding a routed action means three edits, not one: the `requireAction()` call, + the `actions.seed.json` entry, and a `BASELINE_VERSION` bump if existing + installs should receive it. Skipping the second leaves the action admin-only by + default-deny. +- Admin break-glass means a Nextcloud admin can invoke every action regardless of + the matrix. That is deliberate, and it is why the matrix is not a substitute + for the per-object `PermissionService` checks. + +## References + +- Canonical: `hydra/openspec/architecture/adr-023-action-authorization.md` +- ADR-022 — apps consume OpenRegister abstractions (data RBAC) +- ADR-001 — LaunchPad information architecture (the two-audience split) +- REQ-TMPL-013 — single-source-of-truth group-ids accessor diff --git a/openspec/changes/2026-04-30-dashboard-sharing-followups/context-brief.md b/openspec/changes/2026-04-30-dashboard-sharing-followups/context-brief.md index 16e76ee78..de0596a82 100644 --- a/openspec/changes/2026-04-30-dashboard-sharing-followups/context-brief.md +++ b/openspec/changes/2026-04-30-dashboard-sharing-followups/context-brief.md @@ -782,8 +782,8 @@ This is **the rule for any future minimum-cap persona**: if you drop DAC_OVERRID ### ADR-014-licensing - Licence: EUPL-1.2 (European Union Public Licence). -- `appinfo/info.xml`: MUST use `agpl` — Nextcloud app store does not recognise EUPL. -- This is intentional dual-tagging, NOT a conflict. Do NOT change info.xml to eupl. Do NOT flag as review finding. +- `appinfo/info.xml`: MUST use `EUPL-1.2`, matching `LICENSE`, `composer.json`, `package.json` and every SPDX header. +- There is no dual-tagging. A `agpl` element here IS a conflict and SHOULD be flagged as a review finding. ## PHP files — PHPDoc tags only diff --git a/openspec/changes/add-dashboard-sharing-e2e-coverage/.openspec.yaml b/openspec/changes/add-dashboard-sharing-e2e-coverage/.openspec.yaml new file mode 100644 index 000000000..aee4ef1e1 --- /dev/null +++ b/openspec/changes/add-dashboard-sharing-e2e-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/add-dashboard-sharing-e2e-coverage/proposal.md b/openspec/changes/add-dashboard-sharing-e2e-coverage/proposal.md new file mode 100644 index 000000000..d1afd6e18 --- /dev/null +++ b/openspec/changes/add-dashboard-sharing-e2e-coverage/proposal.md @@ -0,0 +1,86 @@ +--- +kind: code +--- + +# Add real e2e coverage for the dashboard-sharing UI — the spec's blanket `@e2e exclude` is stale + +## Why + +`openspec/specs/dashboard-sharing/spec.md:64` carries a single blanket +exclusion covering **all** requirements in the spec: + +``` +@e2e exclude all scenarios test PHP DashboardShareService REST API — sharing UI modals not present in v1.0.5 +``` + +This claim is false at HEAD. `src/components/DashboardConfigModal.vue` +ships a complete sharing tab (`data-test="config-panel-sharing"`, lines +115-183): a sharee search picker (`NcSelect` + `@search="onShareeSearch"` ++ `@input="onShareeSelected"`, lines 128-147), a live list of existing +shares with a per-share permission-level `NcSelect` +(`permissionOptionFor`, `onShareLevelChange`, lines 149-169), and a +remove-share button (`onShareRemove`, lines 168-172). This is exactly the +"sharing UI modal" the exclusion says does not exist — it does, and per +`git log`/file timestamps it has existed since well before this sweep. + +Coverage that *does* exist for this capability is backend-only: +`tests/Unit/Controller/DashboardShareApiControllerFollowupsTest.php`, +`tests/Unit/Service/DashboardShareServiceFollowupsTest.php` (PHPUnit, +calls the controller/service directly — bypasses NC's routing/middleware +stack), and `tests/integration/launchpad.postman_collection.json` +(Newman, HTTP-level but not browser-driven). Searching the entire +`tests/e2e/` tree for any reference to the sharing tab's test hooks +(`config-panel-sharing`, `dashboard-config__shares`, "Share with users +and groups") returns **zero matches** — including +`tests/e2e/docs-screenshots.spec.ts`, which screenshots many other +modals but not this one. + +Net effect: a user-facing, security-relevant flow (owner-only share +grant/revoke, permission-level changes — REQ-SHARE-001 and neighbors) +that a real user drives entirely through the browser has never been +exercised by a test that drives the real router → middleware → controller +→ Vue re-render path. The Vitest unit test for `DashboardConfigModal.vue` +(`src/components/__tests__/DashboardConfigModal.spec.js`) mounts the +component in isolation with mocked stores, which validates the tab-split +rendering logic but not the real save/persist/re-fetch round trip a +Playwright test would catch (e.g. a share silently failing to persist, or +the sharee search hitting a broken endpoint). + +## What Changes + +- Add a Playwright e2e spec, e.g. `tests/e2e/dashboard-sharing.spec.ts`, + that drives the real UI flow: open a personal dashboard's config modal, + switch to the Sharing tab, search for and add a user share, change its + permission level, remove a share, and reload to confirm persistence. + Follow the existing house pattern in `tests/e2e/` for auth/fixture + setup (see `tests/e2e/active-dashboard-resolution.spec.ts` or + `tests/e2e/nc-dashboard-widget.spec.ts` for the admin-fixture + convention already in use). +- Update `openspec/specs/dashboard-sharing/spec.md:64` to remove the + blanket exclusion and instead tag the specific scenarios that + genuinely have no UI surface (if any remain) with a precise, + per-scenario `@e2e exclude `, while scenarios now covered by + the new Playwright spec reference it directly (e.g. + `@e2e tests/e2e/dashboard-sharing.spec.ts`). +- No production code changes required — this is test-only, closing a + coverage gap on already-shipped UI. +- **BREAKING**: none. + +## Capabilities + +### Modified Capabilities + +- `dashboard-sharing`: the sharing UI flow (add/change/remove a share + from `DashboardConfigModal`'s Sharing tab) MUST have real Playwright + e2e coverage; the spec's e2e-exclusion annotation MUST accurately + reflect what is and is not UI-testable, not a stale "no UI exists" + claim. + +## Impact + +**Affected code:** new `tests/e2e/dashboard-sharing.spec.ts`; +`openspec/specs/dashboard-sharing/spec.md` annotation update. + +**Affected APIs:** none — test-only change. + +**Dependencies:** none. diff --git a/openspec/changes/add-dashboard-sharing-e2e-coverage/specs/dashboard-sharing/spec.md b/openspec/changes/add-dashboard-sharing-e2e-coverage/specs/dashboard-sharing/spec.md new file mode 100644 index 000000000..ec74d34f5 --- /dev/null +++ b/openspec/changes/add-dashboard-sharing-e2e-coverage/specs/dashboard-sharing/spec.md @@ -0,0 +1,35 @@ +# Dashboard Sharing — E2E Coverage Delta + +## MODIFIED Requirements + +### Requirement: Owner-only share management (REQ-SHARE-001) + +Only the owner of a dashboard MUST be allowed to list, create, update, or delete shares on that dashboard. All share-management endpoints MUST return HTTP 403 for any caller that is not the dashboard owner, including users who themselves have a `full`-level share on the dashboard. The owner-driven sharing UI flow (open `DashboardConfigModal`'s Sharing tab, add a share, change its permission level, remove a share) MUST be covered by a real Playwright e2e test that drives the actual router → middleware → controller → Vue re-render path, not only by PHPUnit tests that call the controller/service directly. + +#### Scenario: Owner adds a share + +- GIVEN a logged-in user "alice" who owns dashboard id `5` +- WHEN she sends `POST /api/dashboard/5/shares` with body `{"shareType": "user", "shareWith": "bob", "permissionLevel": "view_only"}` +- THEN the system MUST insert a row in `oc_launchpad_dashboard_shares` with the four fields plus `createdAt = now()` +- AND respond with HTTP 201 and the new share's id, displayName, and serialized fields + +#### Scenario: Recipient cannot manage shares + +- GIVEN dashboard `5` is owned by "alice" and shared with "bob" at `full` level +- WHEN bob sends `POST /api/dashboard/5/shares` to add another recipient +- THEN the system MUST return HTTP 403 +- AND no share row MUST be created + +#### Scenario: Updating an existing share replaces, does not duplicate + +- GIVEN alice has shared dashboard `5` with "bob" at `view_only` +- WHEN she sends `POST /api/dashboard/5/shares` with the same `shareType` and `shareWith` but `permissionLevel: "full"` +- THEN the system MUST update the existing share row, not create a second one +- AND only one share row MUST exist for `(dashboardId=5, shareType=user, shareWith=bob)` + +#### Scenario: A user drives the full sharing flow through the browser + +- GIVEN alice is logged in and opens the config modal for a dashboard she owns +- WHEN she switches to the Sharing tab, searches for and adds "bob" at `view_only`, changes his permission level to `full`, then removes his share, and reloads the page +- THEN `tests/e2e/dashboard-sharing.spec.ts` MUST assert each step persists correctly end to end (the added share appears, the level change is reflected, the removal survives a reload) +- AND the spec's e2e-exclusion annotation MUST be scoped to only the scenarios (if any) that genuinely have no UI surface, not a blanket exclusion of the whole capability diff --git a/openspec/changes/add-dashboard-sharing-e2e-coverage/tasks.md b/openspec/changes/add-dashboard-sharing-e2e-coverage/tasks.md new file mode 100644 index 000000000..f6e678d66 --- /dev/null +++ b/openspec/changes/add-dashboard-sharing-e2e-coverage/tasks.md @@ -0,0 +1,49 @@ +# Tasks — add-dashboard-sharing-e2e-coverage + +## New e2e spec + +- [ ] Task 1: Create `tests/e2e/dashboard-sharing.spec.ts` following the + fixture/auth setup convention used in `tests/e2e/nc-dashboard-widget.spec.ts` + or `tests/e2e/active-dashboard-resolution.spec.ts` (login as the admin + fixture user, or seed a second test user if the flow requires a real + distinct recipient). +- [ ] Task 2: Test 1 — "Owner adds a user share": create/open a personal + dashboard, open `DashboardConfigModal`, switch to the Sharing tab + (`data-test="config-panel-sharing"`), use the sharee `NcSelect` + (search + select) to add a user, save, and assert the new share appears + in `.dashboard-config__shares`. +- [ ] Task 3: Test 2 — "Owner changes a share's permission level": with an + existing share present, change its permission-level `NcSelect` value, + save, reload the modal, and assert the change persisted. +- [ ] Task 4: Test 3 — "Owner removes a share": click the remove-share + button (`Close` icon `NcButton`, `aria-label` "Remove share"), save, + reload, and assert the share no longer appears. +- [ ] Task 5: Test 4 (if feasible with the test fixture) — "Recipient sees + the shared dashboard": log in as the share recipient and confirm the + shared dashboard appears in their dashboard switcher with the correct + permission level enforced (read-only vs full, per REQ-SHARE-00x). + If a second seeded user is not available in the current e2e fixture, + use `test.skip(..., 'reason')` with a concrete, honest reason (matching + the house pattern already used elsewhere in `tests/e2e/`) rather than + silently omitting the scenario. + +## Spec annotation cleanup + +- [ ] Task 6: In `openspec/specs/dashboard-sharing/spec.md`, remove the + blanket `@e2e exclude all scenarios test PHP DashboardShareService REST + API — sharing UI modals not present in v1.0.5` line (line 64). +- [ ] Task 7: For each `### Requirement:` in the same spec, add either a + direct `@e2e tests/e2e/dashboard-sharing.spec.ts` reference (for the + scenarios covered by Tasks 2-5) or a specific, honest + `@e2e exclude ` for any requirement that genuinely has no UI + surface (e.g. purely internal permission-ranking logic with no directly + observable UI state). + +## Verification + +- [ ] Task 8: Run `npx playwright test dashboard-sharing` and confirm all + new tests pass against a local dev instance. +- [ ] Task 9: Run the `gate-19`/`hydra-gate-e2e-coverage` check (or + `openspec/coverage-report.json` regeneration) against the diff and + confirm the `dashboard-sharing` capability's scenarios are no longer + flagged as blanket-excluded without per-scenario justification. diff --git a/openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.md b/openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.md index 3b0dde6fd..cd296cc13 100644 --- a/openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.md +++ b/openspec/changes/archive/2026-06-14-groupfolder-storage-backend/tasks.md @@ -6,7 +6,7 @@ - [x] Task 2: Implement `DbContentStorage` against the existing `DashboardMapper` (reads/writes the entity `content` field, idempotent `write`, soft `delete`) - [x] Task 3: Implement `GroupFolderContentStorage` against `IRootFolder`/`IGroupManager`/`IAppManager` with `ensureLaunchPadGroupFolder()` bootstrap, `resolvePath()` (`LaunchPad//.json`), and 503-wrapping for all I/O failures - [x] Task 4: Implement `DashboardContentStorageFactory::getStorage()` reading the `launchpad.content_storage` admin setting (`db` default, `groupfolder` opt-in) -- [x] Task 5: Wire `DashboardContentStorageFactory` into `DashboardService` so `get/create/update/delete` route through the active backend; catch storage exceptions and rethrow with user-friendly messages +- [ ] Task 5: Wire `DashboardContentStorageFactory` into `DashboardService` so `get/create/update/delete` route through the active backend; catch storage exceptions and rethrow with user-friendly messages — **NEVER DONE; TICK CORRECTED 2026-08-11.** The three facade methods (`readDashboardContent`, `writeDashboardContent`, `deleteDashboardContent`) were written, but no `get/create/update/delete` path was ever routed through them: each had zero callers, which left the factory and both backends unreachable and the whole capability inert. Found by gate-57 (orphaned-write-capability) and recorded in launchpad#87. The capability is now **withdrawn** — see `openspec/specs/groupfolder-storage-backend/spec.md`. Tasks 1-4 and 6-14 did land, which is why this looked complete from every angle except the one that mattered - [x] Task 6: Update `lib/Db/Dashboard.php` to add the optional `locale` property and document the now-optional `content` column; keep `jsonSerialize()` returning `content` - [x] Task 7: Register `launchpad.content_storage` as an admin setting (enum `db|groupfolder`, default `db`) with GET/POST validation returning 400 on invalid values - [x] Task 8: Set restrictive ACL on the auto-created `LaunchPad` GroupFolder (admins full, all others denied; per-dashboard ACL stays in the API layer) and document the layout in code comments diff --git a/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/proposal.md b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/proposal.md new file mode 100644 index 000000000..6939d7df0 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/proposal.md @@ -0,0 +1,89 @@ +# Align source licence headers and README badge to EUPL-1.2 + +## Why + +LaunchPad's canonical licence is **EUPL-1.2**. Every authoritative declaration +says so: + +- `LICENSE` — the full EUROPEAN UNION PUBLIC LICENCE v. 1.2 text +- `composer.json` — `"license": "EUPL-1.2"` +- `publiccode.yml` — `license: EUPL-1.2` +- `REUSE.toml` — `SPDX-License-Identifier = "EUPL-1.2"` for `**/*.php`, `**/*.vue`, `**/*.js`, `**/*.ts`, `**/*.css` +- `appinfo/info.xml` description — "Free and open source under the EUPL-1.2 license." +- `README.md` §License — "This project is licensed under the [EUPL-1.2]" + +But the **source files and one badge contradict every one of those**, and +LaunchPad is the only app in the Conduction fleet that does so: + +1. **Source-file SPDX headers say the wrong licence.** Every PHP file under + `lib/` carries `SPDX-License-Identifier: AGPL-3.0-or-later`. This contradicts + `REUSE.toml` (which declares those same files EUPL-1.2), `composer.json`, and + the `LICENSE` file. Sibling apps openregister, opencatalogi, docudesk and + pipelinq all emit `SPDX-License-Identifier: EUPL-1.2` — launchpad is the + outlier. + +2. **The SPDX line contradicts the PHPDoc in the same file.** e.g. + `lib/Service/PermissionService.php` carries + `@license EUPL-1.2 https://joinup.ec.europa.eu/...` **and** + `SPDX-License-Identifier: AGPL-3.0-or-later` — two conflicting licence + statements in one docblock. + +3. **The copyright holder is inconsistent.** SPDX headers say + `SPDX-FileCopyrightText: 2024 LaunchPad Contributors`, while the PHPDoc + `@copyright` and `REUSE.toml`/`publiccode.yml` say **Conduction B.V.** + +4. **The README badge contradicts the README text.** The header badge reads + `license-AGPL--3.0` while README §License says EUPL-1.2. + +This is exactly the "README metadata drift" flagged in the 2026-06-11 readiness +verdict, plus the deeper source-header drift underneath it. A reader auditing +the repo for reuse/compliance gets a different answer depending on which file +they open — an honesty defect for a project that markets itself as EUPL-1.2 and +sells into public-sector procurement where licence provenance is scrutinised. + +**Out of scope on purpose:** `appinfo/info.xml`'s `agpl` +element. That token is a fleet-wide convention driven by the Nextcloud +app-store `info.xsd` / appstore acceptance list (openregister, opencatalogi, +docudesk and procest all set `agpl` while shipping EUPL-1.2 +source), so changing it in isolation would fight a deliberate fleet decision. +This change reconciles the artefacts LaunchPad fully controls and that are +unambiguously wrong; the `info.xml` token is left to a fleet-level decision. + +## What Changes + +- Every source-file `SPDX-License-Identifier` under LaunchPad's own code + (`lib/**` PHP, plus any `src/**` Vue/JS/TS/CSS that carry a header) MUST read + `EUPL-1.2`, matching `REUSE.toml` and `composer.json`. No file's SPDX line + and PHPDoc `@license` may state different licences. +- Every source-file `SPDX-FileCopyrightText` MUST name the same copyright holder + as `REUSE.toml` and the PHPDoc `@copyright` — **Conduction B.V.** — rather + than "LaunchPad Contributors". +- The README licence badge MUST state EUPL-1.2, matching the README §License + text and the rest of the fleet. +- A lightweight repo check MUST assert no `AGPL-3.0` SPDX identifier remains in + LaunchPad's own source tree, so the drift cannot silently return. + +## Capabilities + +### New Capabilities + +- `license-header-consistency` — a single canonical licence (EUPL-1.2) and a + single canonical copyright holder (Conduction B.V.) across every source-file + header, the README badge, and `REUSE.toml`, with a guard preventing + regression. + +### Modified Capabilities + +(none) + +## Impact + +**Affected files (reconciliation only — no behavioural code change):** + +- `lib/**/*.php` — SPDX header licence `AGPL-3.0-or-later` → `EUPL-1.2`; copyright text → `Conduction B.V.` +- `src/**` source files that carry an SPDX header — same reconciliation +- `appinfo/info.xml` — the file's own docblock SPDX header (not the `` element) +- `README.md` — licence badge `AGPL-3.0` → `EUPL-1.2` +- CI / a small script — assert no `AGPL-3.0` SPDX identifier remains and `reuse lint` passes + +**No runtime behaviour changes.** This is a metadata/honesty reconciliation. diff --git a/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/specs/license-header-consistency/spec.md b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/specs/license-header-consistency/spec.md new file mode 100644 index 000000000..98cf186e0 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/specs/license-header-consistency/spec.md @@ -0,0 +1,86 @@ +--- +capability: license-header-consistency +delta: true +status: draft +--- + +# Licence Header Consistency — Delta from change `align-source-license-headers-to-eupl` + +## ADDED Requirements + +### Requirement: REQ-LIC-001 Source-file SPDX identifier is EUPL-1.2 + +LaunchPad-authored source files MUST declare `EUPL-1.2` as their +`SPDX-License-Identifier`, matching `REUSE.toml`, `composer.json`, +`publiccode.yml`, and the `LICENSE` file. No LaunchPad-authored source file MUST +carry an `AGPL-3.0-or-later` (or any non-`EUPL-1.2`) `SPDX-License-Identifier`. + +#### Scenario: Every PHP file under lib/ declares EUPL-1.2 + +- **GIVEN** the LaunchPad source tree +- **WHEN** the `SPDX-License-Identifier` lines under `lib/**/*.php` are collected +- **THEN** every one MUST read `EUPL-1.2` +- **AND** none MUST read `AGPL-3.0-or-later` + +#### Scenario: Frontend source headers declare EUPL-1.2 + +- **GIVEN** a `src/**` Vue/JS/TS/CSS file that carries an SPDX header +- **WHEN** its `SPDX-License-Identifier` is read +- **THEN** it MUST read `EUPL-1.2` + +### Requirement: REQ-LIC-002 No in-file licence contradiction + +A source file MUST NOT declare two different licences. When a file carries both +a PHPDoc `@license` tag and an `SPDX-License-Identifier`, the two MUST name the +same licence (`EUPL-1.2`). + +#### Scenario: PHPDoc @license and SPDX line agree + +- **GIVEN** `lib/Service/PermissionService.php`, whose PHPDoc reads `@license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12` +- **WHEN** its `SPDX-License-Identifier` is read +- **THEN** it MUST read `EUPL-1.2` (not `AGPL-3.0-or-later`) +- **AND** the file MUST contain no other licence identifier + +### Requirement: REQ-LIC-003 Consistent copyright holder + +Every LaunchPad-authored source file's `SPDX-FileCopyrightText` MUST name the +same copyright holder as `REUSE.toml`, `publiccode.yml`, and the PHPDoc +`@copyright` tag — `Conduction B.V.` — rather than "LaunchPad Contributors". + +#### Scenario: SPDX copyright matches REUSE and PHPDoc + +- **GIVEN** a PHP file whose PHPDoc reads `@copyright 2024 Conduction b.v.` +- **WHEN** its `SPDX-FileCopyrightText` line is read +- **THEN** it MUST name `Conduction B.V.` +- **AND** it MUST NOT name "LaunchPad Contributors" + +### Requirement: REQ-LIC-004 README licence badge matches the declared licence + +The README licence badge MUST state the same licence as the README §License +prose and the rest of the repository (`EUPL-1.2`). + +#### Scenario: Badge and prose agree + +- **GIVEN** `README.md` whose §License prose reads "licensed under the [EUPL-1.2]" +- **WHEN** the header licence badge is read +- **THEN** it MUST display `EUPL-1.2` +- **AND** it MUST NOT display `AGPL-3.0` + +### Requirement: REQ-LIC-005 Regression guard for licence drift + +The repository MUST carry an automated check that fails if a LaunchPad-authored +source file reintroduces an `AGPL-3.0` `SPDX-License-Identifier`, and `reuse +lint` MUST pass against the reconciled tree. + +#### Scenario: CI fails on a reintroduced AGPL header + +- **GIVEN** the regression check is wired into CI +- **WHEN** a commit adds a file with `SPDX-License-Identifier: AGPL-3.0-or-later` under `lib/` +- **THEN** the check MUST fail the build +- **AND** the failure message MUST name the offending file and the expected `EUPL-1.2` identifier + +#### Scenario: reuse lint passes on the reconciled tree + +- **GIVEN** all source headers reconciled to EUPL-1.2 and Conduction B.V. +- **WHEN** `reuse lint` runs +- **THEN** it MUST report the tree as compliant diff --git a/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/tasks.md b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/tasks.md new file mode 100644 index 000000000..9bc3e85b7 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-align-source-license-headers-to-eupl/tasks.md @@ -0,0 +1,33 @@ +# Tasks — align-source-license-headers-to-eupl + +## Tasks + +- [x] Task 1: Replace `SPDX-License-Identifier: AGPL-3.0-or-later` with `SPDX-License-Identifier: EUPL-1.2` in every LaunchPad-authored PHP file under `lib/` (REQ-LIC-001, REQ-LIC-002). Do not touch vendored / third-party files. +- [x] Task 2: Apply the same SPDX reconciliation to any `src/**` Vue/JS/TS/CSS file that carries an `AGPL-3.0` header (REQ-LIC-001). +- [x] Task 3: Reconcile `appinfo/info.xml`'s own docblock `SPDX-License-Identifier` to `EUPL-1.2`; leave the `agpl` app-store element unchanged (documented fleet convention, out of scope). +- [x] Task 4: Replace `SPDX-FileCopyrightText: … LaunchPad Contributors` with `SPDX-FileCopyrightText: 2024 Conduction B.V. ` so the SPDX copyright holder matches `REUSE.toml`, `publiccode.yml`, and the PHPDoc `@copyright` (REQ-LIC-003). +- [x] Task 5: Change the README licence badge from `license-AGPL--3.0` to `license-EUPL--1.2` so the badge matches the README §License prose (REQ-LIC-004). +- [x] Task 6: Add a lightweight regression check (script wired into CI) that greps LaunchPad-authored source for an `AGPL-3.0` `SPDX-License-Identifier` and fails the build if any is found, naming the offending file (REQ-LIC-005). +- [x] Task 7: Confirm `reuse lint` passes against the reconciled tree (REQ-LIC-005). + +## Verification + +- `openspec validate align-source-license-headers-to-eupl --strict` exits clean. +- `grep -rl "SPDX-License-Identifier: AGPL-3.0-or-later" lib/ src/ appinfo/` returns nothing. +- No file carries both `@license EUPL-1.2` and `SPDX-License-Identifier: AGPL-3.0-or-later`. +- `grep -c "LaunchPad Contributors" $(git ls-files 'lib/**/*.php')` is `0`. +- The README badge and README §License prose both read EUPL-1.2. +- `reuse lint` reports the tree compliant. + +## Tests (company-wide ADR-009) + +- The regression guard from Task 6 is itself the test surface: a fixture commit adding an `AGPL-3.0` SPDX header MUST make the check fail; the reconciled tree MUST make it pass. +- No functional/unit tests required — this change alters only licence/copyright metadata and documentation, with no runtime behaviour change. + +## Documentation (company-wide ADR-010) + +- No user-facing docs beyond the README badge fix (Task 5). `REUSE.toml` already documents the intended EUPL-1.2 / Conduction B.V. state; this change makes the source headers match it. + +## i18n (company-wide ADR-005) + +- No user-facing strings introduced. diff --git a/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/design.md b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/design.md new file mode 100644 index 000000000..08b9192d9 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/design.md @@ -0,0 +1,98 @@ +# Design — dashboard-acknowledgements + +## Context + +LaunchPad competes in the intranet / employee-portal segment where +"mandatory read + read receipt" is table-stakes (Staffbase Forced Delivery, +Simpplr Mandatory reads, Unily Read receipts and sign-off, Interact/Powell +Mandatory acknowledgement) and doubles as a compliance control for gemeenten +(policy attestation with an auditable per-employee trail). LaunchPad already +has `isCompulsory` (widget cannot be removed) but nothing that gates *reading* +or that *proves* it. + +## Decisions + +### D1 — Extend compulsory widgets, do not build a new widget engine + +The requirement is expressed as additive fields on the existing widget +placement (`requiresAcknowledgement`, `acknowledgementPrompt`, +`acknowledgementDeadline`, `reacknowledgeOnChange`, +`acknowledgementContentVersion`, `announcementKey`). This keeps the feature +inside the ADR-049 widgets-as-config model and means any widget type (header, +text, news, image) can carry an acknowledgement — no bespoke "announcement +widget" and no parallel rendering path. When `requiresAcknowledgement = 0` the +placement behaves exactly as today. + +### D2 — `announcementKey` is the aggregation identity, not the placement uuid + +Admin templates clone one blueprint placement into N per-user placements, each +with its own uuid (`admin-templates`: `createDashboardFromTemplate()` copies all +placements). Keying receipts by the per-user placement uuid would make an +org-wide report impossible. Instead a stable `announcementKey` (UUID) is minted +on the **template** placement when acknowledgement is first required and copied +to every clone. All receipts and the report key off `announcementKey`, so one +announcement has one identity across all recipients. + +### D3 — Local table, not OpenRegister + +`launchpad-adopt-or-abstractions` mandates that LaunchPad stays installable and +runnable without OpenRegister and owns its own tables (it already owns five). +Acknowledgement receipts are LaunchPad-local operational data, so they live in a +new local table `oc_launchpad_acknowledgements`. No install-time OR dependency +is added. (If a deployment later wants receipts in OR for cross-app reporting, +that can be an OPTIONAL runtime delegation, mirroring the optional +`permissions.delegate` hook in `launchpad-adopt-or-abstractions` — out of scope +here.) + +### D4 — Idempotency via a unique key, not read-modify-write + +Receipts carry a unique index on `(announcement_key, user_id, content_version)`. +The service treats a duplicate acknowledgement as success without a second +insert (REQ-ACK-003). This is race-safe (the DB enforces it) and keeps the +activity feed clean — only the first insert emits `dashboard_acknowledged`. + +### D5 — Audience resolved live, pending computed by difference + +The report resolves the audience from the template's group routing via +`IGroupManager` **at report time** (not a frozen snapshot), so a newly added +group member automatically shows as pending and a removed member drops out. +`pending = audience − acknowledged(currentVersion)`. This matches how the rest +of LaunchPad resolves group membership (`admin-templates`, +`role-feature-permissions`, `PermissionService`). + +### D6 — Re-acknowledge-on-change keyed by content version + +Authors bump `acknowledgementContentVersion` when the content materially +changes. With `reacknowledgeOnChange = 1`, receipts for prior versions are +retained as history but do not satisfy the new version, so everyone re-attests +(REQ-ACK-005). This is the compliance-critical path (a re-issued policy needs +fresh sign-off) and is opt-in to avoid nagging users on trivial edits. + +## Explicit differentiation from `launchpad-compliance-audit-panel` + +| | compliance-audit-panel `content.acknowledgements` | this change | +| --- | --- | --- | +| Scope | one widget's own deadline alerts | any compulsory placement | +| Identity | `deadlineId` inside one widget's content | stable `announcementKey` across all recipients | +| Direction | user dismisses (snooze) their own alert | user attests; admin gets a receipt | +| Admin view | none | read-receipt report + CSV, audience-scoped | +| Persistence | JSON sub-field on the widget | dedicated idempotent receipt table | + +The two are complementary; this change does not modify the compliance panel and +reuses its "acknowledgement" vocabulary only. + +## Authorization (ADR-005) + +- Setting/changing/clearing the requirement: admin or template owner only (403 otherwise). +- Writing a receipt: authenticated user, own `userId` only — the endpoint ignores/rejects a body `userId` that is not the caller (no IDOR). +- Reading the report: admin or template owner only. +- Report payload: user id + timestamp + status only; no other PII. + +## Non-goals + +- No email/push transport of the acknowledgement request (delivery is on the + dashboard; notification transport is owned by + `2026-04-30-dashboard-sharing-followups` and NC's notification channels). +- No public/anonymous acknowledgement (receipts require an authenticated + identity; published dashboards remain read-only per + `public-dashboard-publication`). diff --git a/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/proposal.md b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/proposal.md new file mode 100644 index 000000000..906719507 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/proposal.md @@ -0,0 +1,136 @@ +# Dashboard mandatory-read acknowledgements and read receipts + +## Why + +LaunchPad already lets an admin **pin** a widget so a user cannot remove it +(`isCompulsory`, see the `widgets` and `admin-templates` capabilities). That +guarantees the widget is *present* on the user's start page — it does **not** +guarantee the user has *read* it, and it produces **no evidence** that anyone +did. + +For the intranet / employee-portal use case that LaunchPad competes in, "the +user must confirm they read this and I can prove who did" is a table-stakes +capability, not a nice-to-have. The competitor scan (intelligence DB, +digital-workplace segment) shows it recurring across every major vendor as a +first-class feature under different names: + +| Capability (competitor wording) | Vendors | +| --- | --- | +| Forced Delivery (critical comms) | Staffbase | +| Mandatory reads and acknowledgements | Simpplr | +| Read receipts and sign-off | Unily | +| Mandatory read / acknowledgement | Interact, Powell | + +It is also a **compliance** feature (the intelligence DB files it under the +`compliance` category alongside ISO 27001 / GDPR posture): a gemeente rolling +out a new integriteitscode, a privacy policy update, or a safety notice needs +to record — per employee, with a timestamp — who has attested that they read +it, and to chase the ones who have not before a deadline. + +LaunchPad has one adjacent mechanism today, and it is deliberately *not* this: +the `launchpad-compliance-audit-panel` widget stores a per-user +`content.acknowledgements: {deadlineId: dismissedAt}` map so a single user can +**dismiss their own** deadline alert. That is a private, per-user "snooze" with +no forced-delivery gate, no stable announcement identity across a template's +recipients, and — critically — **no admin-facing read-receipt report**. It +cannot answer "who in the Sociaal Domein team has *not* acknowledged the new +integriteitscode?". This change adds exactly that missing capability and +reuses the compliance-panel's acknowledgement vocabulary where it fits. + +The capability extends the existing **compulsory-widget** concept rather than +inventing a new widget engine — it stays inside the ADR-049 widgets-as-config +model and the local-first, owns-its-tables architecture mandated by +`launchpad-adopt-or-abstractions` (no new install-time OpenRegister +dependency). + +## What Changes + +### Requiring acknowledgement (admin / template author) + +- Add an **acknowledgement requirement** to a widget placement, expressed as + new placement fields alongside the existing `isCompulsory`: + `requiresAcknowledgement` (0/1), `acknowledgementPrompt` (the sign-off text, + e.g. "I have read and understood the 2026 integriteitscode"), + `acknowledgementDeadline` (nullable date), `reacknowledgeOnChange` (0/1), and + `acknowledgementContentVersion` (integer, bumped by the author when the + content materially changes). +- A stable `announcementKey` (UUID) is minted the first time acknowledgement is + required on a **template** placement and is copied to every user's cloned + placement, so all recipients of one announcement share one identity and the + admin report can aggregate across them. +- Only users who may author the template (admin / template owner per the + `permissions` and `role-feature-permissions` model) can set, change, or clear + an acknowledgement requirement. + +### Forced delivery + acknowledging (recipient) + +- An unacknowledged mandatory item is surfaced with **forced delivery**: it is + presented prominently (a blocking acknowledgement prompt in the widget, and a + dashboard-level unacknowledged-count indicator) and the recipient must click + the sign-off affordance to clear it. `isCompulsory` already prevents removal; + this adds the read-gate on top. +- Acknowledging writes a receipt server-side: `(announcementKey, userId, + contentVersion, acknowledgedAt)`. Writes are **idempotent** — re-clicking or + a double request never produces a second row for the same + `(announcementKey, userId, contentVersion)`. +- When `reacknowledgeOnChange` is set and the author bumps + `acknowledgementContentVersion`, the item returns to the unacknowledged state + for everyone until they acknowledge the new version; prior-version receipts + are retained as history. + +### Read-receipt report (admin) + +- An admin / template owner can open a **read-receipt report** for an + announcement that returns, for the current content version: acknowledged + count, pending count, the list of pending user ids, and each acknowledgement's + timestamp. The audience is resolved from the template's group routing + (`admin-templates`) via `IGroupManager` at report time, and pending = + (current audience) − (users with a receipt for the current version). +- The report is exportable (CSV) for the compliance file and each + acknowledgement raises an entry in the existing Activity feed + (`activity-feed-integration`) so the audit trail is uniform with the rest of + LaunchPad. + +### Data + authorization + +- One new local table `oc_launchpad_acknowledgements` — consistent with the + five tables LaunchPad already owns; keyed to be idempotent. No OpenRegister + install-time dependency is introduced (`launchpad-adopt-or-abstractions`). +- Endpoints enforce ADR-005 authorization: a recipient may read/write **only + their own** receipt (no IDOR on `userId`); only an admin / template owner may + set the requirement or read the aggregate report; the report exposes no PII + beyond user id + timestamp. + +## Capabilities + +### New Capabilities + +- `dashboard-acknowledgements` — mandatory-read acknowledgement requirement on + widget placements, forced-delivery read-gate, idempotent per-user receipts, + re-acknowledge-on-change, and an admin read-receipt report scoped to the + announcement's audience. + +### Modified Capabilities + +(none — the requirement is expressed as additive placement fields; existing +`widgets` / `admin-templates` behaviour is unchanged when +`requiresAcknowledgement = 0`) + +## Impact + +**Affected code (indicative — implemented in a later apply pass):** + +- `lib/Db/Acknowledgement.php`, `lib/Db/AcknowledgementMapper.php` — new local entity + mapper +- `lib/Migration/VersionXXXX` — creates `oc_launchpad_acknowledgements` +- `lib/Db/WidgetPlacement.php` — additive acknowledgement fields on the placement +- `lib/Service/AcknowledgementService.php` — receipt write (idempotent), report aggregation, audience resolution via `IGroupManager` +- `lib/Controller/AcknowledgementController.php` — `POST /api/acknowledgements`, `GET /api/acknowledgements/report/{announcementKey}`, `GET /api/acknowledgements/pending` +- `lib/Activity/Extension.php` — one new activity event (`dashboard_acknowledged`) +- `src/components/**` — forced-delivery acknowledgement prompt on compulsory widgets, dashboard unacknowledged indicator, admin read-receipt report view + +**Affected capabilities / integrations:** + +- `widgets`, `admin-templates` — read the new placement fields (behaviour unchanged when unset) +- `activity-feed-integration` — surfaces the acknowledgement event +- `launchpad-compliance-audit-panel` — differentiated (per-user dismissal, not org read-receipt); vocabulary reused, no overlap +- No OpenRegister install-time dependency (`launchpad-adopt-or-abstractions`) diff --git a/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md new file mode 100644 index 000000000..0f83e7ab8 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md @@ -0,0 +1,208 @@ +--- +capability: dashboard-acknowledgements +delta: true +status: draft +--- + +# Dashboard Acknowledgements — Delta from change `dashboard-acknowledgements` + +## ADDED Requirements + +### Requirement: REQ-ACK-001 Declare an acknowledgement requirement on a placement + +An admin or template owner MUST be able to mark a widget placement as requiring +acknowledgement. The requirement is expressed as additive fields on the widget +placement: `requiresAcknowledgement` (0/1, default 0), `acknowledgementPrompt` +(the sign-off text shown to the recipient), `acknowledgementDeadline` (nullable +date), `reacknowledgeOnChange` (0/1, default 0), and +`acknowledgementContentVersion` (integer, default 1). When +`requiresAcknowledgement` is set on a **template** placement for the first time, +the system MUST mint a stable `announcementKey` (UUID) and MUST copy it — with +all acknowledgement fields — to every user placement cloned from that template +placement, so all recipients of one announcement share one identity. A caller +who is not an admin or the template owner MUST be rejected with `403` and the +placement MUST be unchanged. + +@e2e exclude Configuration + persistence + clone-propagation path; covered by PlacementUpdater / TemplateService / WidgetPlacementMapper PHPUnit and the non-author 403 by AcknowledgementControllerTest — no dedicated admin form UI in this pass. + +#### Scenario: Admin requires acknowledgement on a template announcement + +- **GIVEN** an admin editing template dashboard `t-hr-2026` with a header widget placement `p-integriteitscode` +- **WHEN** the admin sets `requiresAcknowledgement = 1`, `acknowledgementPrompt = "Ik heb de 2026 integriteitscode gelezen en begrepen"`, and `acknowledgementDeadline = 2026-08-01` +- **THEN** the placement MUST persist those fields with `acknowledgementContentVersion = 1` +- **AND** the system MUST mint a non-empty `announcementKey` UUID on the placement +- **AND** every user dashboard later cloned from `t-hr-2026` MUST carry a placement with the same `announcementKey` and the same acknowledgement fields + +#### Scenario: Non-author cannot require acknowledgement + +- **GIVEN** a user "bob" with `view_only` permission on template `t-hr-2026` +- **WHEN** bob sends a request setting `requiresAcknowledgement = 1` on `p-integriteitscode` +- **THEN** the system MUST return `403` +- **AND** the placement's `requiresAcknowledgement` MUST remain `0` + +#### Scenario: Clearing the requirement stops forcing delivery + +- **GIVEN** placement `p-integriteitscode` with `requiresAcknowledgement = 1` +- **WHEN** the template owner sets `requiresAcknowledgement = 0` +- **THEN** the placement MUST no longer force delivery +- **AND** existing acknowledgement receipts MUST be retained as history (not deleted) + +### Requirement: REQ-ACK-002 Forced delivery of unacknowledged mandatory items + +The system MUST apply forced delivery to a widget placement with +`requiresAcknowledgement = 1` for which the current user has no receipt at the +current `acknowledgementContentVersion`: the widget MUST render a blocking +acknowledgement prompt carrying the `acknowledgementPrompt` text and a single +sign-off affordance, and the recipient MUST NOT be able to dismiss the prompt by +any means other than acknowledging (consistent with `isCompulsory`, which +already prevents removal). The dashboard MUST expose a count of the user's +outstanding (unacknowledged) mandatory items. + +#### Scenario: Unacknowledged item blocks with a sign-off prompt + +- **GIVEN** user "alice" opens a dashboard containing placement `p-integriteitscode` (`requiresAcknowledgement = 1`, `acknowledgementContentVersion = 1`) for which she has no receipt +- **WHEN** the dashboard renders +- **THEN** the widget MUST display the `acknowledgementPrompt` text and a sign-off affordance +- **AND** the widget MUST NOT offer any dismiss / close / snooze affordance that bypasses acknowledgement +- **AND** the dashboard MUST report an outstanding-acknowledgements count of at least `1` + +#### Scenario: Already-acknowledged item renders normally + +@e2e exclude Asserted in AcknowledgementServiceTest::isOutstanding (no-receipt vs receipt) and the dashboard store vitest — the "already acknowledged" state renders the plain widget, the same negative path the covered sign-off scenario exits into. + +- **GIVEN** user "alice" has a receipt for `announcementKey` `ak-1` at `acknowledgementContentVersion = 1` +- **WHEN** she reopens the dashboard and the placement is still at version `1` +- **THEN** the widget MUST render its normal content with no forced-delivery prompt +- **AND** the outstanding-acknowledgements count MUST NOT include this item + +#### Scenario: Deadline is presented but does not auto-acknowledge + +@e2e exclude Deadline/overdue presentation is unit-covered (AcknowledgementService::isOverdue + AcknowledgementPrompt vitest) and requires a fixed system date; the prompt still requires an explicit sign-off, covered by the main gate scenario. + +- **GIVEN** placement `p-integriteitscode` with `acknowledgementDeadline = 2026-08-01` and the current date is `2026-08-02` +- **WHEN** an unacknowledged user opens the dashboard +- **THEN** the prompt MUST still require an explicit sign-off (a passed deadline MUST NOT auto-acknowledge) +- **AND** the item MUST be reportable as overdue in the read-receipt report (REQ-ACK-004) + +### Requirement: REQ-ACK-003 Record an idempotent acknowledgement receipt + +When a recipient acknowledges, the system MUST persist a receipt +`(announcementKey, userId, contentVersion, acknowledgedAt)` in the local +`oc_launchpad_acknowledgements` table. The write MUST be idempotent: a repeated +acknowledgement of the same `(announcementKey, userId, contentVersion)` MUST NOT +create a second row and MUST return success. A recipient MUST be able to write a +receipt **only for their own** `userId`; any attempt to write a receipt on +behalf of another user MUST be rejected with `403` (ADR-005, no IDOR). + +#### Scenario: First acknowledgement writes exactly one receipt + +- **GIVEN** user "alice" with no receipt for `announcementKey` `ak-1` at version `1` +- **WHEN** she `POST`s an acknowledgement for `ak-1` +- **THEN** the system MUST insert exactly one row `(ak-1, alice, 1, )` +- **AND** MUST return success with the stored `acknowledgedAt` + +#### Scenario: Repeated acknowledgement is idempotent + +@e2e exclude Row-level idempotency (no second row, original timestamp) is asserted in AcknowledgementServiceTest::testRepeatedAcknowledgeIsIdempotent + the race test — not observable through the UI. + +- **GIVEN** user "alice" already has a receipt for `(ak-1, alice, 1)` +- **WHEN** she `POST`s the same acknowledgement again +- **THEN** the system MUST NOT insert a second row +- **AND** MUST return success with the original `acknowledgedAt` unchanged + +#### Scenario: A user cannot acknowledge on behalf of another user + +@e2e exclude Cross-user 403 (no IDOR) is asserted in AcknowledgementControllerTest::testAcknowledgeRejectsCrossUser — a server-side auth contract, not a UI flow. + +- **GIVEN** authenticated user "alice" +- **WHEN** she `POST`s an acknowledgement whose body names `userId = "bob"` +- **THEN** the system MUST return `403` +- **AND** MUST NOT write any receipt for bob + +### Requirement: REQ-ACK-004 Admin read-receipt report scoped to the audience + +An admin or template owner MUST be able to retrieve a read-receipt report for an +`announcementKey`. The report MUST resolve the current audience from the source +template's group routing (`admin-templates`) via `IGroupManager` at report time +and, for the current `acknowledgementContentVersion`, MUST return: the +acknowledged count, the pending count, the list of pending user ids, and the +acknowledgement timestamp per acknowledged user. Pending MUST be computed as +`(current audience) − (users with a receipt for the current version)`. The +report MUST expose no PII beyond user id and timestamp. A caller who is neither +an admin nor the template owner MUST be rejected with `403`. + +#### Scenario: Report separates acknowledged from pending against the live audience + +- **GIVEN** announcement `ak-1` distributed to group "sociaal-domein" whose current members are `{alice, bob, carol}` +- **AND** only `alice` and `carol` have receipts for the current version +- **WHEN** the template owner requests the report for `ak-1` +- **THEN** the acknowledged count MUST be `2` with alice's and carol's timestamps +- **AND** the pending count MUST be `1` with pending user ids `[bob]` + +#### Scenario: A newly added group member becomes pending automatically + +@e2e exclude Live-audience resolution via IGroupManager is asserted in AcknowledgementServiceTest::testReportSeparatesAcknowledgedFromPending — mutating group membership mid-session is out of scope for a single UI run. + +- **GIVEN** the report above, and `dave` is subsequently added to group "sociaal-domein" +- **WHEN** the report is requested again +- **THEN** the audience MUST include `dave` +- **AND** `dave` MUST appear in the pending list until he acknowledges + +#### Scenario: Non-author cannot read the report + +@e2e exclude Non-author 403 on the report is asserted in AcknowledgementControllerTest::testReportRejectsNonOwner / testReportCsvRejectsNonManager — a server-side auth contract, not a UI flow. + +- **GIVEN** user "bob" who is a recipient of `ak-1` but not an admin or template owner +- **WHEN** bob requests the read-receipt report for `ak-1` +- **THEN** the system MUST return `403` + +### Requirement: REQ-ACK-005 Re-acknowledgement on content change + +The system MUST return an item to the unacknowledged state for every recipient +when an author bumps `acknowledgementContentVersion` on a placement whose +`reacknowledgeOnChange = 1`, until each recipient acknowledges the new version. +Receipts for prior versions MUST be retained as history and MUST NOT satisfy the +new version. When `reacknowledgeOnChange = 0`, bumping the version MUST NOT +re-force delivery for users who already acknowledged a prior version. + +@e2e exclude Version-bump re-force logic (both branches) is asserted in AcknowledgementServiceTest::isOutstanding — a content-version bump is not directly UI-observable in a single session. + +#### Scenario: Version bump re-forces delivery when re-acknowledge is on + +- **GIVEN** placement with `reacknowledgeOnChange = 1`, `announcementKey` `ak-1`, and user "alice" holding a receipt at version `1` +- **WHEN** the author bumps `acknowledgementContentVersion` to `2` +- **THEN** alice's item MUST render as unacknowledged (forced delivery) again +- **AND** her version-`1` receipt MUST be retained but MUST NOT count toward version `2` + +#### Scenario: Version bump does not re-force when re-acknowledge is off + +- **GIVEN** placement with `reacknowledgeOnChange = 0` and user "alice" holding a receipt at version `1` +- **WHEN** the author bumps `acknowledgementContentVersion` to `2` +- **THEN** alice's item MUST NOT re-force delivery +- **AND** the read-receipt report MAY report her against the latest version she acknowledged + +### Requirement: REQ-ACK-006 Acknowledgement events feed activity and export + +Each successful acknowledgement MUST raise one entry in the existing Activity +provider (`activity-feed-integration`) identifying the acknowledging user and +the announcement, and the read-receipt report MUST be exportable as CSV +containing one row per audience member with acknowledged/pending status and, for +acknowledged rows, the timestamp — so the result can be filed as compliance +evidence. + +@e2e exclude Single-shot activity emission and the CSV export body are asserted in AcknowledgementServiceTest / AcknowledgementControllerTest — the CSV `DataDownloadResponse` cannot be exercised under the OCP stub bootstrap and the Activity row is not UI-observable on the dashboard. + +#### Scenario: Acknowledging emits one activity event + +- **GIVEN** the Activity provider is registered +- **WHEN** user "alice" acknowledges `ak-1` +- **THEN** exactly one activity entry MUST be emitted for the acknowledgement (subject `dashboard_acknowledged`) naming alice and the announcement +- **AND** no activity entry MUST be emitted for an idempotent repeat acknowledgement (REQ-ACK-003) + +#### Scenario: Report exports as CSV compliance evidence + +- **GIVEN** announcement `ak-1` with audience `{alice, bob, carol}`, of whom `alice` and `carol` acknowledged +- **WHEN** the template owner exports the read-receipt report as CSV +- **THEN** the CSV MUST contain one row per audience member +- **AND** alice's and carol's rows MUST carry status `acknowledged` and their timestamps +- **AND** bob's row MUST carry status `pending` with an empty timestamp diff --git a/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/tasks.md b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/tasks.md new file mode 100644 index 000000000..cc3c14d52 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-dashboard-acknowledgements/tasks.md @@ -0,0 +1,35 @@ +# Tasks — dashboard-acknowledgements + +## Tasks + +- [x] Task 1: Add the local `oc_launchpad_acknowledgements` table via a migration — columns `id`, `announcement_key`, `user_id`, `content_version`, `acknowledged_at`; unique index on `(announcement_key, user_id, content_version)` to enforce REQ-ACK-003 idempotency. New table, no OpenRegister dependency (`launchpad-adopt-or-abstractions`). +- [x] Task 2: Add `Acknowledgement` entity + `AcknowledgementMapper` following the existing five-table Db pattern (typed getters, `findByAnnouncement`, `existsFor(announcementKey, userId, contentVersion)`). +- [x] Task 3: Extend `WidgetPlacement` with the additive fields `requiresAcknowledgement` (SMALLINT 0/1 default 0), `acknowledgementPrompt` (TEXT), `acknowledgementDeadline` (DATE null), `reacknowledgeOnChange` (SMALLINT 0/1 default 0), `acknowledgementContentVersion` (INT default 1), `announcementKey` (VARCHAR/UUID null) — REQ-ACK-001. Behaviour when `requiresAcknowledgement = 0` MUST be identical to today. +- [x] Task 4: Mint + propagate `announcementKey` — set it when `requiresAcknowledgement` is first enabled on a template placement, and copy it (with the acknowledgement fields) in `TemplateService::createDashboardFromTemplate()` when cloning placements to a user dashboard (REQ-ACK-001). +- [x] Task 5: `AcknowledgementService` — `acknowledge(announcementKey, userId, contentVersion)` (idempotent write, own-user only), `report(announcementKey)` (resolve audience via `IGroupManager` from the template's group routing, diff against receipts for the current version), and version-change handling for REQ-ACK-005. +- [x] Task 6: `AcknowledgementController` — `POST /api/acknowledgements` (REQ-ACK-003, reject cross-user `userId` with 403), `GET /api/acknowledgements/pending` (current user's outstanding items, REQ-ACK-002), `GET /api/acknowledgements/report/{announcementKey}` (admin/owner only, REQ-ACK-004), CSV export variant (REQ-ACK-006). Declare auth posture on every route (ADR-005); guard the report and requirement-setting to admin/template-owner. +- [x] Task 7: Frontend forced-delivery prompt — render the blocking `acknowledgementPrompt` + sign-off affordance on compulsory widgets with an outstanding requirement, remove any bypass/dismiss affordance, and surface the dashboard outstanding-count indicator (REQ-ACK-002). Reuse `@conduction/nextcloud-vue` primitives; no bespoke modal outside `src/modals/`. +- [x] Task 8: Admin read-receipt report view — acknowledged/pending/overdue counts, pending user list, per-user timestamps, CSV export button (REQ-ACK-004, REQ-ACK-006). Differentiate clearly from the `launchpad-compliance-audit-panel` per-user deadline dismissal. +- [x] Task 9: Register one new Activity event (`dashboard_acknowledged`) in `OCA\LaunchPad\Activity\Extension` and emit it on a first (non-idempotent) acknowledgement only (REQ-ACK-006). + +## Verification + +- `openspec validate dashboard-acknowledgements --strict` exits clean. +- With `requiresAcknowledgement = 0` on every placement, dashboards render exactly as before (no regression to `widgets` / `admin-templates`). +- Idempotency: two acknowledgements of the same `(announcementKey, userId, contentVersion)` leave exactly one row and emit exactly one activity event. +- The read-receipt report's pending set changes when group membership changes, proving audience is resolved live via `IGroupManager`. + +## Tests (company-wide ADR-009) + +- Unit: `AcknowledgementMapper::existsFor` uniqueness; `AcknowledgementService::acknowledge` idempotency; `report()` pending-set diff against a mocked `IGroupManager`; version-bump re-force logic (REQ-ACK-005 both branches). +- Controller: cross-user `userId` returns 403 (REQ-ACK-003); non-owner report request returns 403 (REQ-ACK-004). +- Frontend (Vitest): forced-delivery prompt has no bypass affordance; outstanding-count reflects unacknowledged items. +- e2e (Playwright): admin marks a widget mandatory → recipient sees blocking prompt → acknowledges → admin report shows them acknowledged and a second recipient pending. Traceable to REQ-ACK-002/003/004. + +## Documentation (company-wide ADR-010) + +- Document the acknowledgement fields on the widget/placement config and the admin read-receipt report + CSV export in the app docs, including how it differs from the compliance-audit-panel deadline dismissal. + +## i18n (company-wide ADR-005) + +- English source strings for the sign-off prompt scaffolding, the outstanding-count label, the report column headers, and the CSV headers; Dutch translations supplied. The per-announcement `acknowledgementPrompt` is author-supplied content, not an i18n key. diff --git a/openspec/changes/archive/2026-07-23-admin-template-resync/proposal.md b/openspec/changes/archive/2026-07-23-admin-template-resync/proposal.md new file mode 100644 index 000000000..017c55fcd --- /dev/null +++ b/openspec/changes/archive/2026-07-23-admin-template-resync/proposal.md @@ -0,0 +1,39 @@ +# Admin template re-sync — push template corrections to already-provisioned copies + +Admin templates provision **independent personal copies** on a user's first open (REQ-TMPL-005/006). This independence is deliberate — a user can customise their copy — but it has a sharp edge for the functioneel beheerder: when they correct a department template (fix a broken link, add a mandated widget, reposition the layout), the correction reaches only *future* first-logins. Every user who already has a copy keeps the stale layout. Today the single exception is compulsory-widget resolution and permission-level resolution, which resolve dynamically from the source template; everything else in an existing copy is frozen at creation time. + +Market research (Spectr `lp-template-resync`, priority **MUST**) identifies this as a functional gap: administrators expect that "update the template" also means "update the people who already have it". + +This change adds an explicit, admin-initiated **re-sync** action. An admin pushes an updated template to its already-provisioned copies with a strategy choice: + +- `overwrite` — replace each copy's layout with the current template layout. +- `merge` — apply the template's changes (added/moved/updated placements, compulsory flags) while **keeping each user's personal additions** (widgets the user added that are not in the template). + +The action is admin-guarded, **idempotent**, **dry-run capable** (report affected copies and planned changes before any mutation), records who/what/when as an audit record, and notifies affected users. It runs asynchronously via a background job for large target groups so the request returns promptly. + +## Affected code units + +- `lib/Service/TemplateResyncService.php` — new. Diffs the source template's placements against each provisioned copy, computes a per-copy plan under the chosen strategy (`overwrite` | `merge`), and applies it transactionally. Under `merge`, template-origin placements are reconciled while user-added placements are preserved; compulsory widgets are always reconciled regardless of strategy. Produces a dry-run report (affected copies + planned per-copy changes) without mutating. Writes an audit record. Idempotent: re-running with no template change is a no-op. +- `lib/Controller/AdminTemplateController.php` — add `POST /api/admin/templates/{id}/resync` with body `{strategy: "overwrite"|"merge", dryRun: bool}`, guarded to Nextcloud admins. Dry-run returns the report inline; a real run enqueues `TemplateResyncJob` (or applies inline for small groups) and returns the accepted plan. +- `lib/BackgroundJob/TemplateResyncJob.php` — new. Applies the computed plan asynchronously for large target groups (per-copy transactional apply, resumable, notifies each affected user on completion). +- Notification — dispatched via the canonical `x-openregister-notifications` dialect when OpenRegister is present, else Nextcloud `INotification`, informing each affected user their department dashboard was updated by an administrator. +- `src/components/admin/TemplateResyncDialog.vue` — new. Admin UI: a "Re-sync to existing copies" button in the template management view opening a dialog with the strategy choice (overwrite / merge), a **Dry-run** button that shows the affected-copy count and planned changes, and an **Apply** button (disabled until a dry-run has been reviewed). + +## Why a new change + +Copy independence (REQ-TMPL-006) is a shipped, load-bearing invariant — re-sync must be an *explicit, opt-in* override of it, never an implicit change to distribution. Isolating re-sync as its own capability keeps the first-access distribution path (REQ-TMPL-005) untouched, makes the overwrite-vs-merge semantics reviewable in one place, and lets the audit/notify/dry-run guarantees be specified and tested independently of template CRUD. The merge strategy — reconcile template changes while preserving user additions — is subtle enough to deserve its own scenarios and its own service boundary. + +## Approach + +- **Strategy semantics.** Each copy's placements are partitioned into *template-origin* (placement traces to the template, e.g. by a template-placement key/origin) and *user-added* (placement created by the user after provisioning). `overwrite` replaces the layout with the current template layout. `merge` reconciles template-origin placements (add new, update moved/changed, remove template placements the admin deleted) while leaving user-added placements in place. +- **Compulsory always wins.** Compulsory widgets are reconciled under both strategies — a compulsory widget missing from a copy MUST be restored, and a compulsory widget's position/flags MUST match the template — because compulsory widgets are the one thing the org retains central control over. +- **Dry-run first.** The controller and service both support `dryRun: true`, which computes and returns the full plan (affected copies, per-copy add/update/remove/preserve counts) and mutates nothing. The UI requires a dry-run before Apply is enabled. +- **Idempotent + transactional.** Applying a plan twice yields the same end state; re-syncing a template that has not changed since the last sync is a no-op. Each per-copy apply is atomic — partial placement failure rolls the copy back. +- **Async for scale.** Small target groups apply inline within the request; large groups enqueue `TemplateResyncJob`, which applies per-copy and notifies on completion, keeping the admin request fast (aligned with REQ-TMPL non-functional scalability for 1000+ users). +- **Audit + notify.** Every real run writes an audit record (who, template id, strategy, affected count, timestamp) and notifies each affected user via the canonical notification dialect. + +## Notes + +- Storage stays local (`oc_launchpad_dashboards` / `oc_launchpad_widget_placements`), consistent with the admin-templates storage policy — no OpenRegister dependency for the core action; notifications use the OR dialect only when OR is present. +- Out of scope: automatic re-sync on template edit (this change is explicit-only), scheduled/recurring re-sync, and per-user re-sync opt-out (follow-ups). +- Out of scope: re-syncing permission level or target groups — those already resolve dynamically (REQ-TMPL-003) and need no push. diff --git a/openspec/changes/archive/2026-07-23-admin-template-resync/specs/admin-templates/spec.md b/openspec/changes/archive/2026-07-23-admin-template-resync/specs/admin-templates/spec.md new file mode 100644 index 000000000..47d647292 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-admin-template-resync/specs/admin-templates/spec.md @@ -0,0 +1,128 @@ +## ADDED Requirements + +### Requirement: REQ-RESYNC-001 Re-sync action pushes template updates to existing copies + +Administrators MUST be able to push an updated admin template to its already-provisioned user copies via `POST /api/admin/templates/{id}/resync`, choosing a `strategy` of `overwrite` or `merge`. The action MUST be restricted to Nextcloud admins and MUST target only copies whose `basedOnTemplate` references the given template. Re-sync is an explicit, opt-in override of template copy independence (REQ-TMPL-006); first-access distribution (REQ-TMPL-005) MUST be unaffected. + +#### Scenario: Admin re-syncs a template to existing copies + +- GIVEN admin template id 1 has been provisioned to 12 users +- AND the admin has since added a widget and fixed a link on the template +- WHEN the admin sends `POST /api/admin/templates/1/resync` with body `{"strategy": "overwrite", "dryRun": false}` +- THEN the system MUST apply the template layout to all 12 provisioned copies +- AND the response MUST report the number of affected copies + +#### Scenario: Non-admin cannot re-sync + +- GIVEN admin template id 1 exists with provisioned copies +- WHEN regular user "alice" sends `POST /api/admin/templates/1/resync` +- THEN the system MUST return HTTP 403 +- AND no user copy MUST be modified + +#### Scenario: Re-sync rejects a non-template dashboard + +- GIVEN dashboard id 5 is a user dashboard (`type: "user"`), not an admin template +- WHEN the admin sends `POST /api/admin/templates/5/resync` +- THEN the system MUST return an error indicating "Not an admin template" +- AND no dashboards MUST be modified + +#### Scenario: Invalid strategy is rejected + +- GIVEN admin template id 1 exists +- WHEN the admin sends `POST /api/admin/templates/1/resync` with body `{"strategy": "replace-all"}` +- THEN the system MUST return HTTP 400 +- AND only `overwrite` and `merge` MUST be accepted + +### Requirement: REQ-RESYNC-002 Dry-run reports the plan without mutating + +The re-sync action MUST support a dry-run mode (`dryRun: true`) that computes and returns the planned changes — the set of affected copies and, per copy, the placements that would be added, updated, removed, and preserved — WITHOUT modifying any dashboard, placement, audit record, or notification. + +#### Scenario: Dry-run reports affected copies without mutating + +- GIVEN admin template id 1 has been provisioned to 8 users +- WHEN the admin sends `POST /api/admin/templates/1/resync` with body `{"strategy": "merge", "dryRun": true}` +- THEN the system MUST return HTTP 200 with a plan listing the 8 affected copies +- AND the plan MUST include, per copy, the counts of placements to add, update, remove, and preserve +- AND NO dashboard or widget placement MUST be modified +- AND NO audit record MUST be written and NO notification MUST be sent + +#### Scenario: Dry-run on an up-to-date template reports no changes + +- GIVEN admin template id 1 was already re-synced and has not changed since +- WHEN the admin sends `POST /api/admin/templates/1/resync` with `{"strategy": "overwrite", "dryRun": true}` +- THEN the plan MUST report zero placements to add, update, or remove for every copy + +### Requirement: REQ-RESYNC-003 Merge strategy preserves user-added widgets + +Under `strategy: "merge"`, the re-sync MUST reconcile template-origin placements onto each copy (add new template placements, update moved or changed template placements, remove placements the admin deleted from the template) while PRESERVING each user's personally-added widgets — placements the user added after provisioning that do not originate from the template. Under `strategy: "overwrite"`, the copy's layout MUST be replaced with the current template layout. + +#### Scenario: Merge keeps user additions while applying template changes + +- GIVEN user "alice" has a copy of template id 1 to which she added a personal "Notes" widget +- AND the admin added a new "Announcements" widget to the template and repositioned an existing one +- WHEN the admin re-syncs template id 1 with `{"strategy": "merge", "dryRun": false}` +- THEN alice's copy MUST gain the "Announcements" widget and reflect the repositioned template widget +- AND alice's personal "Notes" widget MUST remain on her copy unchanged + +#### Scenario: Overwrite replaces the layout + +- GIVEN user "bob" has a copy of template id 1 with a personally-added widget and a moved template widget +- WHEN the admin re-syncs template id 1 with `{"strategy": "overwrite", "dryRun": false}` +- THEN bob's copy layout MUST match the current template layout +- AND bob's personally-added widget MUST NOT be present after the overwrite + +#### Scenario: Template widget removed by admin is removed under merge + +- GIVEN template id 1 previously had a "Links" widget that all copies received +- AND the admin has since deleted the "Links" widget from the template +- WHEN the admin re-syncs with `{"strategy": "merge"}` +- THEN the template-origin "Links" widget MUST be removed from each copy +- AND user-added widgets on those copies MUST remain + +### Requirement: REQ-RESYNC-004 Compulsory widgets are always reconciled + +Regardless of the chosen strategy, re-sync MUST reconcile compulsory widgets against the template: a compulsory widget missing from a copy MUST be restored, and a compulsory widget's position and flags MUST be aligned to the template. Compulsory widgets are the org-controlled surface and MUST NOT be left stale by either strategy. + +#### Scenario: Compulsory widget restored under merge + +- GIVEN template id 1 has a compulsory "Company News" widget +- AND user "carol" managed to remove it from her copy +- WHEN the admin re-syncs template id 1 with `{"strategy": "merge"}` +- THEN the compulsory "Company News" widget MUST be restored to carol's copy at the template's position + +#### Scenario: Compulsory widget position aligned under both strategies + +- GIVEN template id 1 has a compulsory widget the admin has repositioned +- AND user "dave" has a copy where that compulsory widget is at the old position +- WHEN the admin re-syncs template id 1 (with either `overwrite` or `merge`) +- THEN the compulsory widget on dave's copy MUST match the template's position and flags + +### Requirement: REQ-RESYNC-005 Re-sync is idempotent, audited, async-capable, and notifies users + +A real (non-dry-run) re-sync MUST be idempotent — applying the same plan twice yields the same end state, and re-syncing an unchanged template is a no-op. Each per-copy apply MUST be transactional (partial placement failure rolls that copy back). Every real run MUST write an audit record (acting admin, template id, strategy, affected-copy count, timestamp) and MUST notify each affected user that an administrator updated their dashboard. For large target groups the apply MUST run asynchronously via `TemplateResyncJob` so the request returns promptly. + +#### Scenario: Re-sync is idempotent + +- GIVEN the admin re-synced template id 1 with `{"strategy": "overwrite"}` and the template has not changed +- WHEN the admin runs the same re-sync again +- THEN the resulting copies MUST be identical to the first run (no additional changes) +- AND the operation MUST NOT error + +#### Scenario: Audit record is written on a real run + +- GIVEN admin "admin1" re-syncs template id 1 with `{"strategy": "merge", "dryRun": false}` affecting 12 copies +- THEN the system MUST write an audit record capturing the acting admin, template id 1, strategy `merge`, an affected count of 12, and a timestamp + +#### Scenario: Affected users are notified + +- GIVEN a real re-sync of template id 1 modifies user "erin"'s copy +- WHEN the re-sync completes +- THEN erin MUST receive a notification that an administrator updated her dashboard +- AND the notification MUST be dispatched via the canonical `x-openregister-notifications` dialect when OpenRegister is present, otherwise via Nextcloud `INotification` + +#### Scenario: Large groups apply asynchronously + +- GIVEN admin template id 1 has been provisioned to 800 users +- WHEN the admin triggers a real re-sync +- THEN the system MUST enqueue `TemplateResyncJob` and return a prompt accepted response +- AND the job MUST apply the plan per copy and notify each affected user on completion diff --git a/openspec/changes/archive/2026-07-23-admin-template-resync/tasks.md b/openspec/changes/archive/2026-07-23-admin-template-resync/tasks.md new file mode 100644 index 000000000..106558ff3 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-admin-template-resync/tasks.md @@ -0,0 +1,34 @@ +# Tasks: Admin template re-sync + +## Backend +- [x] `lib/Service/TemplateResyncService.php` — diff source template placements vs each provisioned copy (partition template-origin vs user-added, via the new `WidgetPlacement.templatePlacementId` origin key); compute a per-copy plan under `overwrite` | `merge`; apply transactionally per copy; produce a dry-run report without mutating; write an audit record; guarantee idempotency (no-change template = no-op). +- [x] Reconcile compulsory widgets under BOTH strategies — restore a missing compulsory widget and align its position/flags to the template. (No separate code path needed: compulsory widgets are ordinary template-origin placements, so the general reconciliation covers them under both strategies — see the class docblock.) +- [x] Preserve user-added placements under `merge` (only template-origin placements are reconciled); replace layout under `overwrite`. +- [x] `lib/Controller/AdminController.php` (`admin#resyncTemplate` — this app's existing convention is one `AdminController`, not a dedicated `AdminTemplateController`) — added `POST /api/admin/templates/{id}/resync` with body `{strategy, dryRun}`, admin-guarded (`AuthorizedAdminSetting` + explicit `assertAdmin()`); dry-run returns the report inline; real run applies inline for small groups or enqueues `TemplateResyncJob` for large groups. +- [x] `appinfo/routes.php` — registered the resync route (`admin#resyncTemplate`, POST `/api/admin/templates/{id}/resync`) ahead of the `{id}` wildcard routes; auth is enforced via the controller method's `AuthorizedAdminSetting` attribute (this app's convention — routes.php itself carries no auth attribute). +- [x] `lib/BackgroundJob/TemplateResyncJob.php` — apply the computed plan asynchronously (per-copy transactional, recomputes the plan fresh at run time), notify each affected user on completion. +- [ ] Dispatch affected-user notifications via the canonical `x-openregister-notifications` dialect when OpenRegister is present, else Nextcloud `INotification`. PARTIAL: implemented the Nextcloud `INotification` branch only (this app's existing, sole notification pattern — see `DashboardShareService`; no `x-openregister-notifications` dialect exists anywhere in this codebase yet). The OR-dialect branch is not wired in — left as a follow-up. +- [x] Validate `strategy` (only `overwrite`/`merge`) and reject re-sync of a non-`admin_template` dashboard (400). +- [x] (Bonus, encountered while wiring the origin key) Fixed a pre-existing gap in `TemplateService::clonePlacement()` — first-access template distribution was silently dropping `content`, `customIcon`, and all `tile*` fields, unlike `WidgetPlacementMapper::cloneToDashboard()` (the fork/save-as-template path). Both paths now copy the same field set. + +## Frontend +- [x] `src/modals/TemplateResyncModal.vue` (placed under `src/modals/` per this app's modal-isolation convention, not `src/components/admin/`) — strategy selector (overwrite / merge) with `NcSelect` + `input-label`, Dry-run button showing affected-copy count + planned per-copy changes, Apply button disabled until a dry-run has been reviewed for the current strategy; EN strings via `t()` (translatable; NL `.po` catalogue not populated in this pass). +- [x] Added a "Re-sync to existing copies" button to `TemplatesPage.vue` (the template management view) that opens the dialog. + +## Testing +- [x] PHPUnit: dry-run reports affected copies + planned changes and mutates nothing. +- [x] PHPUnit: `merge` keeps user-added widgets while applying template changes; `overwrite` replaces the layout. +- [x] PHPUnit: compulsory widgets reconciled under both strategies (restored when missing, position/flags aligned). +- [x] PHPUnit: idempotency — re-applying a plan yields the same state; unchanged template = no-op. +- [x] PHPUnit: audit record written on a real run (who/template/strategy/affected count/timestamp); notification dispatched per affected user. +- [x] PHPUnit: non-admin → 403; non-template dashboard → 400. +- [x] PHPUnit (bonus): `TemplateResyncJob` argument validation + delegation + exception-swallowing. +- [x] Vitest: dialog gates Apply behind a completed dry-run; strategy binding. + +## Docs +- [x] Document the re-sync action, overwrite-vs-merge semantics, dry-run workflow, and compulsory-widget guarantee in the admin template docs. (`docs/features/admin-template-resync.md`) + +## Out of scope (follow-ups) +- Automatic/scheduled re-sync on template edit (this change is explicit-only). +- Per-user re-sync opt-out. +- Re-syncing permission level / target groups (already resolve dynamically). diff --git a/openspec/changes/archive/2026-07-23-clock-weather-widgets/proposal.md b/openspec/changes/archive/2026-07-23-clock-weather-widgets/proposal.md new file mode 100644 index 000000000..95964c7d2 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-clock-weather-widgets/proposal.md @@ -0,0 +1,36 @@ +# Clock & weather widgets — ambient dashboard tiles + +Ambient tiles — a clock and a weather panel — are table-stakes on every consumer and workspace dashboard (Google, Workspace 365, Homarr, gethomepage), yet LaunchPad has neither. Market research (Spectr `lp-clock-weather-widget`, demand 8, competitorCoverage 8) flags both as high-demand, well-covered gaps that make a dashboard feel "alive" and personal. + +This change adds two widgets: + +- **`launchpad_clock`** — a fully client-side clock/date tile (analog or digital style, 12/24-hour, configurable timezone, locale-aware date formatting). It mirrors the divider widget exactly: **no backend at all**, config stored in the placement `widgetContent` JSON, rendered entirely in-browser off the device clock. +- **`launchpad_weather`** — a locale- and units-aware weather tile for a configured location. Because a weather fetch needs an outbound call and (usually) an API key, the fetch is performed **server-side** via `OCP\Http\Client` and cached in `ICache`; the browser only ever calls a LaunchPad endpoint. Where the Nextcloud `weather_status` provider is available it is reused; otherwise a configurable provider URL with an admin-held API key is used, and the key never reaches the browser. Units and language MUST follow the user locale — Nextcloud has a history of weather-localisation bugs (hardcoded units / English strings), so this is called out explicitly. + +## Affected code units + +- `lib/Widget/ClockWidgetProvider.php` — new v2 widget provider registering `launchpad_clock`; zero backend data. +- `lib/Widget/WeatherWidgetProvider.php` — new v2 widget provider registering `launchpad_weather`. +- `lib/Service/WeatherService.php` — resolves a placement's location + user locale to a weather reading: reuse the `weather_status` provider when present, else a configurable provider URL with a server-held API key; fetch via `OCP\Http\Client`, cache in `ICache` with a TTL; unit/language selection from the user locale. +- `lib/Controller/WeatherController.php` — new `#[NoAdminRequired]` endpoint `GET /api/weather/{placementId}` returning the cached reading for one placement; validates the caller may view the placement. +- `src/components/widgets/ClockWidget.vue` + `src/components/widgets/ClockWidgetConfig.vue` — render the clock and its author UI; entirely client-side. +- `src/components/widgets/WeatherWidget.vue` + `src/components/widgets/WeatherWidgetConfig.vue` — render the weather reading (states: loading / stale / error) and its author UI (location, units-follow-locale toggle, provider choice when applicable). +- `lib/Db/WidgetPlacement.php` — no schema change; both widgets store config in the existing `widgetContent` JSON blob. + +## Why a new change + +The two widgets ship together because they are the "ambient tiles" pairing users expect, but they sit at opposite ends of the backend spectrum: the clock is pure client-side (divider-class, no endpoints), while weather needs a governed server-side fetch (credentials, caching, locale-correct units). Keeping them in one change lets the review contrast the two patterns; keeping the clock backend-free avoids inventing needless plumbing. + +## Approach + +- **Clock: zero backend.** Rendered in-browser from the device clock; timezone conversion and locale-aware date/time formatting done client-side (Intl). No endpoint, no data fetch on discovery, no migration — mirrors the divider widget. +- **Weather: server-side, cached.** The browser calls `GET /api/weather/{placementId}`; the server resolves the location, fetches via `OCP\Http\Client`, and caches in `ICache` keyed on location + units + language + config hash, with a TTL (default 900s). On upstream failure a previously cached reading is returned marked `stale`; with no cache the widget renders an error state, never crashes. +- **Provider.** Prefer the existing `weather_status` provider pattern when it is available on the instance. Otherwise use a configurable provider URL with an admin-held API key kept server-side; the key MUST NOT appear in any response or in the widget config. +- **Locale correctness (explicit).** Units (°C/°F, km/h vs mph) and forecast text language MUST be derived from the requesting user's Nextcloud locale, with an author override for units. The response MUST state which units and language it used so the frontend never re-guesses. +- **WCAG AA.** Clock and weather values carry accessible labels; weather condition is conveyed by icon **and** text (not colour/icon alone); the analog clock exposes a textual time for screen readers. + +## Notes + +- Out of scope: multi-day forecast strip (v1 shows current conditions only) — follow-up `weather-forecast-strip`. +- Out of scope: automatic geolocation of the viewer — location is author-configured in v1. +- Out of scope: world-clock multi-timezone grid — one timezone per clock tile in v1. diff --git a/openspec/changes/archive/2026-07-23-clock-weather-widgets/specs/clock-weather-widgets/spec.md b/openspec/changes/archive/2026-07-23-clock-weather-widgets/specs/clock-weather-widgets/spec.md new file mode 100644 index 000000000..259d11704 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-clock-weather-widgets/specs/clock-weather-widgets/spec.md @@ -0,0 +1,141 @@ +## ADDED Requirements + +### Requirement: REQ-CLOCK-001 Register client-side clock widget + +The system MUST register a `launchpad_clock` widget with the Nextcloud Dashboard Widget API (v2) that is rendered entirely client-side, with no backend endpoint and no data fetch on discovery. + +#### Scenario: Widget appears in discovery +- GIVEN the LaunchPad app is installed and enabled +- WHEN the user opens the "Add Widget" modal on a dashboard +- THEN the clock widget MUST appear in the widget list with id `launchpad_clock` +- AND the widget MUST have a title and an icon +- AND the widget MUST NOT fetch any data on discovery — it is fully client-side + +#### Scenario: Registration via IManager +- GIVEN `OCP\Dashboard\IManager` is available +- WHEN the LaunchPad app boots +- THEN the app MUST register `ClockWidgetProvider` by calling `$manager->registerWidget(...)` + +#### Scenario: No backend endpoint or migration +- GIVEN the clock widget is implemented +- WHEN the LaunchPad app is upgraded +- THEN NO custom API endpoint (e.g. `/api/clock/...`) MUST exist for the clock +- AND NO database migration MUST be created — the clock reads only the device clock and its `widgetContent` config + +### Requirement: REQ-CLOCK-002 Configure clock style, format, and timezone + +The system MUST store clock configuration in the placement `widgetContent` JSON so a dashboard author can choose analog or digital style, 12- or 24-hour format, a timezone, and a locale-aware date. + +#### Scenario: Digital style configuration +- GIVEN a clock widget is placed on a dashboard +- WHEN the author selects style = `digital`, hourFormat = `24h`, timezone = `Europe/Amsterdam`, showDate = true +- THEN the config MUST persist as `{ "style": "digital", "hourFormat": "24h", "timezone": "Europe/Amsterdam", "showDate": true }` + +#### Scenario: Analog style configuration +- GIVEN a clock widget is placed on a dashboard +- WHEN the author selects style = `analog` and timezone = `America/New_York` +- THEN the config MUST persist as `{ "style": "analog", "timezone": "America/New_York" }` +- AND the config UI MUST offer a timezone picker listing IANA timezone identifiers + +#### Scenario: Defaults when unset +- GIVEN a newly placed clock widget with no explicit config +- WHEN it first renders +- THEN it MUST default to style = `digital`, hourFormat following the user locale, timezone = the user's Nextcloud timezone, showDate = true + +### Requirement: REQ-CLOCK-003 Render locale-aware clock, WCAG AA + +The system MUST render the clock in the browser using the configured timezone and format, with a locale-aware date, accessible to screen readers. + +#### Scenario: Digital time honours timezone and format +- GIVEN a clock with style = `digital`, hourFormat = `24h`, timezone = `Europe/Amsterdam` +- WHEN the widget renders +- THEN it MUST display the current time in that timezone in 24-hour form, updating at least once per second (or per minute if seconds are hidden) +- AND a `12h` configuration MUST render an AM/PM suffix instead + +#### Scenario: Locale-aware date +- GIVEN a clock with showDate = true and the user's Nextcloud locale is Dutch +- WHEN the widget renders the date +- THEN the date MUST be formatted per the Dutch locale via `Intl` (e.g. weekday and month names in Dutch), not hardcoded English + +#### Scenario: Analog clock is accessible +- GIVEN a clock with style = `analog` +- WHEN a screen reader accesses the widget +- THEN the widget MUST expose the current time as text (e.g. via `aria-label` or a visually-hidden element), so the time is not conveyed by the analog face alone + +### Requirement: REQ-WEATHER-001 Register weather widget with server-side fetch + +The system MUST register a `launchpad_weather` widget (v2) whose reading is fetched server-side, so any provider API key stays on the server and the browser calls only a LaunchPad endpoint. + +#### Scenario: Widget appears in discovery +- GIVEN the LaunchPad app is installed and enabled +- WHEN the user opens the "Add Widget" modal on a dashboard +- THEN the weather widget MUST appear in the widget list with id `launchpad_weather` +- AND the widget MUST have a title and an icon + +#### Scenario: Registration via IManager +- GIVEN `OCP\Dashboard\IManager` is available +- WHEN the LaunchPad app boots +- THEN the app MUST register `WeatherWidgetProvider` by calling `$manager->registerWidget(...)` + +#### Scenario: Browser fetches via the placement endpoint, key never exposed +- GIVEN a weather placement the current user may view +- WHEN the widget calls `GET /api/weather/{placementId}` +- THEN the response MUST be `{ location, tempValue, units, condition, conditionText, language, fetchedAt, stale }` +- AND the response MUST NOT contain the provider API key or the raw provider URL + +#### Scenario: Caller authorization +- GIVEN a weather placement on a dashboard the current user may NOT view +- WHEN the user calls `GET /api/weather/{placementId}` +- THEN the system MUST return 403 and MUST NOT perform the fetch + +### Requirement: REQ-WEATHER-002 Resolve provider, fetch, and cache + +The system MUST resolve the weather reading via the Nextcloud `weather_status` provider when available, otherwise via a configurable provider URL with a server-held API key, fetching via `OCP\Http\Client` and caching in `ICache`. + +#### Scenario: Reuse weather_status when present +- GIVEN the Nextcloud `weather_status` provider is available on the instance +- WHEN `WeatherService` resolves a placement's location +- THEN it MUST obtain the reading through the `weather_status` provider pattern rather than a bespoke external call + +#### Scenario: Fallback to configurable provider URL +- GIVEN `weather_status` is NOT available +- WHEN `WeatherService` resolves a placement's location +- THEN it MUST fetch from the admin-configured provider URL using `OCP\Http\Client`, sending the admin-held API key server-side only + +#### Scenario: Cached within TTL +- GIVEN a weather reading fetched 200 seconds ago with a 900-second TTL +- WHEN the endpoint is called again for the same location + units + language +- THEN the system MUST return the cached reading with `stale = false` and MUST NOT perform a new upstream fetch + +#### Scenario: Upstream failure degrades gracefully +- GIVEN the weather provider is unreachable or returns a non-2xx status +- WHEN resolution fails and a previously cached reading exists +- THEN the endpoint MUST return the last-known reading with `stale = true` +- AND WHEN no cached reading exists THEN it MUST return an error shape and the widget MUST render an error state, never crash + +### Requirement: REQ-WEATHER-003 Locale-aware units and language, WCAG AA + +The system MUST derive units and forecast language from the requesting user's Nextcloud locale (with an author override for units), and MUST render the condition accessibly. This guards against the historical Nextcloud weather bug of hardcoded units and English-only strings. + +#### Scenario: Units follow the user locale +- GIVEN a user whose Nextcloud locale implies metric units +- WHEN the weather reading is resolved with units-follow-locale enabled +- THEN `units` MUST be metric (°C, km/h) and the response MUST state `units` +- AND a user whose locale implies imperial units MUST receive °F / mph without any code change + +#### Scenario: Author override of units +- GIVEN an author has overridden units to `imperial` for a specific weather tile +- WHEN the reading is resolved +- THEN the response `units` MUST be `imperial` regardless of the viewer's locale + +#### Scenario: Forecast language follows the locale +- GIVEN the user's Nextcloud language is Dutch +- WHEN the weather reading is resolved +- THEN `conditionText` MUST be requested/rendered in Dutch where the provider supports it, and `language` MUST report `nl` +- AND English MUST be the fallback when the provider has no localisation, never a silent wrong-language string + +#### Scenario: Condition is not conveyed by icon or colour alone +- GIVEN a weather tile rendering a condition (e.g. "Light rain") +- WHEN a screen-reader or colour-blind user views it +- THEN the condition MUST be conveyed by an icon AND a text label +- AND the temperature MUST carry an accessible label including its units diff --git a/openspec/changes/archive/2026-07-23-clock-weather-widgets/tasks.md b/openspec/changes/archive/2026-07-23-clock-weather-widgets/tasks.md new file mode 100644 index 000000000..440a7ac6d --- /dev/null +++ b/openspec/changes/archive/2026-07-23-clock-weather-widgets/tasks.md @@ -0,0 +1,38 @@ +# Tasks: Clock & weather widgets + +> **Implementation note (2026-07-23).** This repo has no `lib/Widget/` +> directory — dashboard widget types are registered from the **frontend** +> `src/constants/widgetRegistry.js`, not via PHP `IManager` providers. The two +> `*WidgetProvider.php` tasks below are therefore superseded by the registry +> entries (marked n/a). Component paths also live under +> `src/components/Widgets/Renderers/` rather than `src/components/widgets/`. + +## Backend +- [n/a] `lib/Widget/ClockWidgetProvider.php` — superseded: registered in `src/constants/widgetRegistry.js` (`clock`); no PHP provider layer exists in this app. +- [n/a] `lib/Widget/WeatherWidgetProvider.php` — superseded: registered in `src/constants/widgetRegistry.js` (`weather`). +- [x] `lib/Service/WeatherService.php` — resolve location + user locale → reading; reuse `weather_status` provider when present, else configurable provider URL + server-held API key; fetch via `OCP\Http\Client`; cache in `ICache` (TTL default 900s) keyed on location+units+language+config hash; stale fallback on upstream failure. +- [x] `lib/Controller/WeatherController.php` — `#[NoAdminRequired]` `GET /api/weather/{placementId}`; placement view-authorization guard; response `{ location, tempValue, units, condition, conditionText, language, fetchedAt, stale }` with NO API key. +- [x] `appinfo/routes.php` — register the weather route with its auth attribute. +- [x] Admin config for the weather provider URL + API key (server-side only) when `weather_status` is not used (`weather_provider_url`, `weather_provider_api_key`). + +## Frontend +- [x] `ClockWidget.vue` — analog/digital render off the device clock; timezone conversion + locale-aware date/time via Intl; textual time for screen readers. +- [x] `ClockWidgetForm.vue` — style (analog/digital), 12/24h, timezone picker, date format/locale; config persisted to `widgetContent`. +- [x] `WeatherWidget.vue` — render current conditions with loading/stale/error states; condition shown by icon AND text (WCAG AA). +- [x] `WeatherWidgetForm.vue` — location, units-follow-locale toggle (+ manual override), provider choice where applicable. +- [x] Register `clock` and `weather` in the widget catalogue/constants (+ completeness spec). + +## Testing +- [x] Vitest: clock renders correct time for a configured timezone and 12/24h mode; locale-aware date string; no network call. (44 assertions pass across 6 files.) +- [x] PHPUnit: `WeatherService` picks `weather_status` when present, falls back to provider URL otherwise; ICache hit within TTL; stale fallback on upstream failure; response contains no API key. (`tests/Unit/Service/WeatherServiceTest.php`) +- [x] PHPUnit: `WeatherService` derives units + language from user locale (regression guard against hardcoded units / English-only strings). +- [x] PHPUnit: `WeatherController` 403 on unauthorized placement; response shape excludes credentials. (`tests/Unit/Controller/WeatherControllerTest.php`) +- [ ] Playwright: drop clock tile, set timezone, confirm rendered time; drop weather tile against a stubbed provider, confirm conditions + units render and a stale badge appears on upstream failure. — deferred, tracked as a follow-up; unit coverage stands in for now. + +## Docs +- [x] Add "Clock" and "Weather" sections to dashboard-authoring docs; document locale-driven units/language and the server-side provider/API-key setup. (`docs/features/clock-weather-widgets.md` + features README row) + +## Out of scope (follow-ups) +- Multi-day forecast strip — `weather-forecast-strip`. +- Viewer geolocation — author-configured location in v1. +- World-clock multi-timezone grid — one timezone per tile in v1. diff --git a/openspec/changes/archive/2026-07-23-conditional-visibility-editor/proposal.md b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/proposal.md new file mode 100644 index 000000000..a23d426ab --- /dev/null +++ b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/proposal.md @@ -0,0 +1,36 @@ +# Conditional visibility editor — an author UI over the existing rules engine + +LaunchPad already ships a fully-implemented conditional-visibility rules engine (`conditional-visibility` spec, status: done): group / time-of-day / date-range / attribute rules, evaluated at render time, with include=OR and exclude=AND semantics. But that spec is explicit that in v1.0.5 there is **no UI surface for rule editing** — rules can only be created, listed, updated and deleted via the JSON API (`POST/GET /api/widgets/{placementId}/rules`, `PUT/DELETE /api/rules/{ruleId}`). Conditional visibility is LaunchPad's own headline feature, yet an author cannot use it without hand-crafting API calls. + +This change adds the admin/author UI to create, edit, preview and delete visibility rules on a widget placement directly from the widget settings panel, plus a **"preview as audience / date"** affordance so authors can see the effective visibility for a chosen group on a chosen date/time **before** publishing. Preview de-risks the headline feature (a mis-scoped exclude rule can silently hide a widget from everyone) and closes a competitive gap: Microsoft Viva offers preview-as-audience for its adaptive-card targeting. + +This change is a **UI over the existing engine**. It MUST NOT change rule evaluation semantics, rule storage, or the rule shape. The one new backend endpoint is read-only and non-persisting: it exists solely so the preview reuses the *same* evaluation code path as render-time, guaranteeing the preview can never diverge from what the dashboard will actually show. + +## Affected code units + +- `src/components/ConditionalVisibilityEditor.vue` — new. The rule builder embedded in the placement/widget settings panel: lists a placement's rules, adds/removes rows, groups them visually into "Show when…" (include / OR) and "Hide when…" (exclude / AND) sections so the engine's semantics are legible, and hosts the preview affordance. Reads and writes rules via the existing `/api/widgets/{placementId}/rules` and `/api/rules/{ruleId}` endpoints — no new persistence path. +- `src/components/VisibilityRuleRow.vue` — new. One rule: a `ruleType` selector (`group` / `time` / `date` / `attribute`) plus the type-specific operand fields (group multi-select; startTime/endTime + day-of-week for time; startDate/endDate for date; attribute + operator + value for attribute), and an include/exclude toggle. Emits the canonical `ruleConfig` shape defined by the existing spec (camelCase keys). +- `src/composables/useVisibilityPreview.js` — new. Given the current in-editor rule set and a chosen `(groups, datetime)` context, calls the preview endpoint and returns the effective visibility plus which rules matched. Uses the same rule shape the editor emits so no translation layer can drift. +- Integration into the existing placement/widget settings modal — mount `ConditionalVisibilityEditor` in a "Visibility" section of the settings panel; no change to the modal's other fields. +- `lib/Controller/VisibilityPreviewController.php` — new. `#[NoAdminRequired]` `POST /api/visibility/preview` that evaluates a supplied rule set against a supplied `(groups, datetime)` context and returns the effective visibility **without persisting anything**. It reuses the existing evaluation service (`VisibilityChecker` / `RuleEvaluatorService` via `ConditionalService`) so preview and runtime agree by construction. + +No DB schema change: rules are already persisted by the `conditional-visibility` capability (`oc_launchpad_conditional_rules`). This change adds no table, column, or migration. + +## Why a new change + +The rules engine and its CRUD API are `status: done`; retrofitting a UI into that closed spec would muddy a completed capability. The UI is also a distinct surface with its own concerns — client-side validation, legibility of include/exclude semantics, and a preview affordance — that warrant their own requirements and e2e coverage. The one backend addition (a stateless preview endpoint) is deliberately minimal and read-only; it is bundled here rather than in the engine spec because it exists only to serve the UI's preview, and its whole contract is "reuse the render-time evaluation code path, persist nothing". + +## Approach + +- **No semantic change.** The editor reads/writes rules through the existing endpoints and emits the exact `ruleConfig` shapes the `conditional-visibility` spec defines (`group`: `{groups:[…]}`; `time`: `{startTime,endTime,days?}`; `date`: `{startDate?,endDate?}`; `attribute`: `{attribute,operator,value}`). Evaluation semantics (include=OR, exclude=AND, isVisible gate) are untouched. +- **Legible semantics.** The editor renders include rules under a "Show when any of these match" heading and exclude rules under "Hide when any of these match", making the OR/AND behaviour explicit rather than implied by a boolean flag. +- **Preview reuses the engine.** `POST /api/visibility/preview` accepts `{rules:[…], context:{groups:[…], datetime:"…"}}` and evaluates it through the *same* `VisibilityChecker`/`RuleEvaluatorService` used at render time, injected with the supplied context instead of the live user/clock. It returns `{visible, matchedIncludeRuleIds, matchedExcludeRuleIds}` and writes nothing to the database. This is the single guarantee that preview cannot diverge from actual visibility. +- **Validation both sides.** The row component validates operand shape client-side (e.g. time `HH:MM`, non-empty groups) before enabling save; the preview endpoint and the existing CRUD endpoints validate server-side, rejecting unknown `ruleType` values and malformed `ruleConfig` with HTTP 400. +- **WCAG AA.** Include/exclude grouping is conveyed by heading text and layout, not colour alone; the preview result states "Visible" / "Hidden" in text with an icon, not colour only. + +## Notes + +- Server-time semantics carry over: time rules evaluate in the server timezone. The preview accepts a `datetime` and evaluates it as server-local, matching render-time behaviour (including the known midnight-spanning limitation — preview reflects the real engine, warts and all, rather than a "corrected" model). +- Out of scope: fixing the engine's known limitations (midnight-spanning time windows, missing timezone field, missing ruleType validation in the engine, missing ownership checks on update/delete). Those belong to the `conditional-visibility` engine spec, not its UI. Where the missing update/delete ownership check is user-visible, this change surfaces it as a follow-up but does not add the guard here. +- Out of scope: bulk rule templates / saved audiences (follow-up `visibility-saved-audiences`). +- Out of scope: preview across a full dashboard (this change previews one placement's rule set at a time). diff --git a/openspec/changes/archive/2026-07-23-conditional-visibility-editor/specs/conditional-visibility-editor/spec.md b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/specs/conditional-visibility-editor/spec.md new file mode 100644 index 000000000..1be1e96dc --- /dev/null +++ b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/specs/conditional-visibility-editor/spec.md @@ -0,0 +1,156 @@ +## ADDED Requirements + +### Requirement: REQ-CVUI-001 Rule Builder in Placement Settings + +Authors MUST be able to create, edit and delete conditional visibility rules on a widget placement they own from the widget settings panel, without using the raw API. The builder MUST operate over the existing conditional-visibility CRUD endpoints and MUST emit the canonical rule shape defined by the `conditional-visibility` spec. It MUST NOT introduce a new persistence path or alter the stored rule shape. + +#### Scenario: Visibility section appears in the settings panel +- GIVEN author "alice" owns a dashboard with widget placement id 10 +- WHEN she opens the placement/widget settings modal +- THEN a "Visibility" section MUST render the `ConditionalVisibilityEditor` component +- AND it MUST load the placement's existing rules via `GET /api/widgets/10/rules` +- AND each loaded rule MUST render as a `VisibilityRuleRow` with its `ruleType`, operands and include/exclude state populated + +#### Scenario: Add a group inclusion rule through the UI +- GIVEN the Visibility section is open for placement id 10 with no rules +- WHEN alice adds a rule, selects type `group`, picks groups ["marketing", "sales"], sets it to include, and saves +- THEN the editor MUST send `POST /api/widgets/10/rules` with body `{"ruleType":"group","ruleConfig":{"groups":["marketing","sales"]},"isInclude":true}` +- AND the persisted `ruleConfig` MUST match the canonical shape from the `conditional-visibility` spec (camelCase, `groups` array) +- AND on HTTP 201 the new rule MUST appear as a row in the include section + +#### Scenario: Edit an existing rule through the UI +- GIVEN placement id 10 has rule id 5 of type `group` with `ruleConfig {"groups":["marketing"]}` +- WHEN alice adds "management" to the group operand and saves the row +- THEN the editor MUST send `PUT /api/rules/5` with the updated `ruleConfig` +- AND the row MUST reflect the updated groups on HTTP 200 + +#### Scenario: Delete a rule through the UI +- GIVEN placement id 10 has rule id 5 +- WHEN alice removes that row and confirms +- THEN the editor MUST send `DELETE /api/rules/5` +- AND the row MUST disappear from the editor on HTTP 200 + +#### Scenario: Editor does not change evaluation semantics +- GIVEN any rule created, edited or deleted through the editor +- WHEN the dashboard is subsequently rendered +- THEN visibility MUST be evaluated by the unchanged `ConditionalService` / `VisibilityChecker` pipeline +- AND the editor MUST NOT introduce any alternative evaluation, storage, or rule shape + +### Requirement: REQ-CVUI-002 Per-Rule Row Editor for All Four Rule Types + +The `VisibilityRuleRow` component MUST let an author configure any of the four supported rule types (`group`, `time`, `date`, `attribute`) with type-appropriate operand fields and an include/exclude toggle, emitting the canonical `ruleConfig` shape for that type. + +#### Scenario: Group row operands +- GIVEN a rule row with type `group` +- WHEN the author selects groups ["marketing", "sales"] +- THEN the row MUST emit `ruleConfig {"groups":["marketing","sales"]}` + +#### Scenario: Time row operands with day-of-week +- GIVEN a rule row with type `time` +- WHEN the author sets startTime "09:00", endTime "17:00" and days ["mon","tue","wed","thu","fri"] +- THEN the row MUST emit `ruleConfig {"startTime":"09:00","endTime":"17:00","days":["mon","tue","wed","thu","fri"]}` using camelCase keys + +#### Scenario: Date row operands with open-ended range +- GIVEN a rule row with type `date` +- WHEN the author sets startDate "2026-12-01" and leaves endDate empty +- THEN the row MUST emit `ruleConfig {"startDate":"2026-12-01"}` and MUST NOT emit an empty `endDate` key + +#### Scenario: Attribute row operands +- GIVEN a rule row with type `attribute` +- WHEN the author selects attribute "language", operator "equals", value "nl" +- THEN the row MUST emit `ruleConfig {"attribute":"language","operator":"equals","value":"nl"}` + +#### Scenario: Include/exclude toggle +- GIVEN a rule row of any type +- WHEN the author toggles it to exclude +- THEN the row MUST emit `isInclude: false` +- AND the row MUST move to the editor's "Hide when…" section + +### Requirement: REQ-CVUI-003 Legible Include/Exclude Semantics + +The editor MUST surface the existing engine's include=OR and exclude=AND semantics clearly so an author can predict a placement's visibility from the layout, not from hidden boolean flags. + +#### Scenario: Include rules grouped under an OR heading +- GIVEN placement id 10 has two include rules and one exclude rule +- WHEN the Visibility section renders +- THEN the two include rules MUST appear under a heading conveying "Show when ANY of these match" (OR) +- AND the exclude rule MUST appear under a heading conveying "Hide when ANY of these match" (AND-overrides) + +#### Scenario: Semantics conveyed without relying on colour +- GIVEN the include and exclude sections are rendered +- WHEN a colour-blind author views the panel +- THEN the include/exclude distinction MUST be conveyed by heading text and layout, not colour alone (WCAG 2.1 AA 1.4.1) + +#### Scenario: Empty state explains default visibility +- GIVEN placement id 10 has `isVisible: 1` and no rules +- WHEN the Visibility section renders +- THEN it MUST state that with no rules the widget is always shown (matching the engine's no-rules-means-visible behaviour) + +### Requirement: REQ-CVUI-004 Preview As Audience and Date + +The editor MUST provide a "preview as audience / date" affordance that shows the effective visibility of the current rule set for an author-chosen `(groups, datetime)` context before the rules are published, so an author can catch a mis-scoped rule (e.g. an exclude rule that hides the widget from everyone) prior to saving. + +#### Scenario: Preview shows visible for a matching audience +- GIVEN the editor holds one include group rule with groups ["marketing"] +- WHEN the author previews as groups ["marketing"] at datetime "2026-07-23T14:30" +- THEN `useVisibilityPreview` MUST call `POST /api/visibility/preview` with the current rule set and that context +- AND the result MUST display "Visible" with an icon and text (not colour alone) +- AND the matched include rule MUST be indicated + +#### Scenario: Preview shows hidden for a non-matching audience +- GIVEN the editor holds one include group rule with groups ["marketing"] +- WHEN the author previews as groups ["engineering"] at any datetime +- THEN the result MUST display "Hidden" +- AND no include rule MUST be indicated as matched + +#### Scenario: Preview reflects an exclude override +- GIVEN the editor holds an include group rule (groups ["marketing"], matches) and an exclude date rule (2026-07-01..2026-07-31) +- WHEN the author previews as groups ["marketing"] at datetime "2026-07-15T10:00" +- THEN the result MUST display "Hidden" +- AND the matched exclude rule MUST be indicated as the reason + +#### Scenario: Preview evaluates unsaved edits +- GIVEN the author has added a rule row but has not yet saved it +- WHEN the author runs preview +- THEN the preview MUST evaluate the in-editor (unsaved) rule set +- AND the preview MUST NOT persist any rule + +#### Scenario: Preview uses the same rule shape as the editor emits +- GIVEN the editor's rule rows and the preview request +- WHEN `useVisibilityPreview` builds the request body +- THEN it MUST send the exact `ruleConfig` shape the rows emit, with no translation layer that could drift from the saved shape + +### Requirement: REQ-CVUI-005 Preview Endpoint Reuses the Render-Time Evaluation Path and Never Persists + +The `POST /api/visibility/preview` endpoint MUST be read-only and MUST evaluate the supplied rule set against the supplied `(groups, datetime)` context through the SAME evaluation code path used at render time, so a preview verdict can never diverge from the visibility the dashboard will actually produce for that context. It MUST NOT write to the database and MUST validate its input server-side. + +#### Scenario: Endpoint delegates to the shared evaluation service +- GIVEN a preview request `{"rules":[…], "context":{"groups":["marketing"], "datetime":"2026-07-15T10:00"}}` +- WHEN `VisibilityPreviewController` handles it +- THEN it MUST delegate to the same `VisibilityChecker` / `RuleEvaluatorService` (via `ConditionalService`) used by render-time `isWidgetVisible()` +- AND it MUST NOT re-implement or fork the include/exclude combination logic + +#### Scenario: Preview verdict matches render-time verdict for identical inputs +- GIVEN a rule set R and a context C = (groups G, datetime D) +- AND a placement whose stored rules equal R rendered for a user in groups G at server time D +- WHEN both the preview endpoint and the render-time pipeline evaluate their inputs +- THEN the preview `visible` verdict MUST equal the render-time visibility verdict +- AND this equality MUST hold because both paths execute the same evaluation code, not because the preview reproduces the expected result independently + +#### Scenario: Preview persists nothing +- GIVEN a preview request for placement-independent rule set R +- WHEN the endpoint responds +- THEN no row MUST be inserted, updated or deleted in `oc_launchpad_conditional_rules` +- AND the response MUST return `{visible, matchedIncludeRuleIds, matchedExcludeRuleIds}` only + +#### Scenario: Preview rejects an invalid rule set +- GIVEN a preview request containing a rule with `ruleType` "weather" +- WHEN the endpoint validates the input +- THEN it MUST return HTTP 400 indicating the ruleType is invalid +- AND only `group`, `time`, `date`, and `attribute` MUST be accepted + +#### Scenario: Preview requires an authenticated user +- GIVEN the endpoint is declared `#[NoAdminRequired]` +- WHEN an unauthenticated request is made +- THEN Nextcloud MUST reject it before the controller runs +- AND the endpoint MUST NOT be reachable as a public page diff --git a/openspec/changes/archive/2026-07-23-conditional-visibility-editor/tasks.md b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/tasks.md new file mode 100644 index 000000000..db016dc3e --- /dev/null +++ b/openspec/changes/archive/2026-07-23-conditional-visibility-editor/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Conditional visibility editor + +## Backend +- [x] `lib/Controller/VisibilityPreviewController.php` — `#[NoAdminRequired]` `POST /api/visibility/preview`; accepts `{rules:[…], context:{groups:[…], datetime}}`; returns `{visible, matchedIncludeRuleIds, matchedExcludeRuleIds}`; persists nothing. +- [x] Route the preview endpoint in `appinfo/routes.php` with an auth attribute. +- [x] Wire the preview controller to the EXISTING evaluation code path (`ConditionalService` / `VisibilityChecker` / `RuleEvaluatorService`) with the supplied `(groups, datetime)` injected in place of the live user/clock — no fork, no re-implementation. +- [x] Server-side validation: reject unknown `ruleType` and malformed `ruleConfig` with HTTP 400 (shared with, not duplicated from, the CRUD path). + +## Frontend +- [x] `src/components/VisibilityRuleRow.vue` — ruleType selector (group/time/date/attribute) + type-specific operands + include/exclude toggle; emits the canonical camelCase `ruleConfig` shape; client-side operand validation. (Placed under `src/components/Widgets/` alongside the modal it's used from, matching this repo's existing convention — see `VisibilityRulesModal.vue`.) +- [x] `src/components/ConditionalVisibilityEditor.vue` — list/add/remove rows; group them under "Show when…" (include/OR) and "Hide when…" (exclude/AND); read/write via existing `/api/widgets/{placementId}/rules` and `/api/rules/{ruleId}`; host the preview affordance (group picker + datetime picker + result). (Also under `src/components/Widgets/`.) +- [x] `src/composables/useVisibilityPreview.js` — take the in-editor rule set + `(groups, datetime)`, call `POST /api/visibility/preview`, return effective visibility + matched rule ids; use the same rule shape the editor emits. +- [x] Mount `ConditionalVisibilityEditor` in a "Visibility" section of the existing placement/widget settings modal; leave the modal's other fields untouched. (`VisibilityRulesModal.vue` — the pre-existing per-placement visibility settings surface, opened from the widget context menu — was refactored to a thin `NcModal` host for `ConditionalVisibilityEditor`; its `placementId`/`open`/`availableGroups` props and `close`/`rule-added`/`rule-removed` events are unchanged, plus a new `rule-updated` event, so `Views.vue`'s wiring needed only one additive line.) + +## Testing +- [x] PHPUnit: `VisibilityPreviewController` returns the same verdict as render-time evaluation for identical `(rules, groups, datetime)` — assert it delegates to the shared evaluation service and persists nothing (no DB write). +- [x] PHPUnit: preview endpoint rejects unknown `ruleType` / malformed `ruleConfig` with HTTP 400. +- [x] Vitest: `VisibilityRuleRow` emits correct `ruleConfig` per type; client-side validation blocks malformed operands (bad time, empty groups). +- [x] Vitest: `useVisibilityPreview` posts the editor's rule shape unchanged and maps the response. +- [ ] Playwright: open a placement's Visibility section, add an include group rule + an exclude date rule, run "preview as audience/date" for a chosen group+datetime, confirm the previewed verdict matches what the dashboard renders for that context; save and confirm rules persist via the existing API. — NOT DONE: this build was explicitly scoped to local unit tests only (no Playwright/e2e against the shared instance). Left for a follow-up e2e pass. + +## Docs +- [x] Add a "Visibility rules & preview" section to dashboard-authoring docs; explain include=OR / exclude=AND and the preview-as-audience affordance; cross-reference the `conditional-visibility` engine spec. (Added to `docs/features/conditional-visibility.md`, the existing feature doc for this capability — no separate "dashboard-authoring" doc file exists in this repo.) + +## Out of scope (follow-ups) +- Engine limitations (midnight-spanning time windows, timezone field, engine-side ruleType validation, update/delete ownership guards) — belong to the `conditional-visibility` engine spec. +- Saved audiences / rule templates — `visibility-saved-audiences`. +- Whole-dashboard preview — this change previews one placement's rule set at a time. diff --git a/openspec/changes/archive/2026-07-23-iframe-embed-widget/proposal.md b/openspec/changes/archive/2026-07-23-iframe-embed-widget/proposal.md new file mode 100644 index 000000000..d96b48978 --- /dev/null +++ b/openspec/changes/archive/2026-07-23-iframe-embed-widget/proposal.md @@ -0,0 +1,32 @@ +# iframe-embed widget — embed an external URL in a sandboxed frame + +LaunchPad dashboards can today link *out* to an external page but cannot render one *in place*. Embedding a live external portal, a Grafana panel, a status page or an internal tool directly on a dashboard is one of the most-requested Nextcloud dashboard capabilities — filed as **dashboard#53 in 2019** and still open — and is filled today only by third-party micro-apps (iFrame Widget, External Portal, DashLink) that ship no CSP handling and no fallback when the target refuses framing. + +This change adds a first-class `launchpad_iframe` widget that embeds an admin-allow-listed external URL in a **sandboxed** `'; - - $clean = $this->service->sanitiseSummaryHtml(html: $html); - - $this->assertStringNotContainsString('assertStringNotContainsString('service->sanitiseSummaryHtml(html: $html); - - $this->assertStringContainsString('rel="noopener noreferrer"', $clean); - }//end testSanitiseSummaryForcesRelOnLinks() - - public function testSanitiseSummaryNeutralisesJavascriptHref(): void - { - $html = '
danger'; - - $clean = $this->service->sanitiseSummaryHtml(html: $html); - - $this->assertStringNotContainsString('javascript:', $clean); - $this->assertStringContainsString('href="#"', $clean); - }//end testSanitiseSummaryNeutralisesJavascriptHref() - - public function testCheckAllowListAcceptsAllWhenEmpty(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(''); - - $this->assertTrue($this->service->checkAllowList(url: 'https://anything.example.com/feed')); - }//end testCheckAllowListAcceptsAllWhenEmpty() - - public function testCheckAllowListMatchesCaseInsensitively(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['Example.Org'])); - - $this->assertTrue($this->service->checkAllowList(url: 'https://example.org/feed')); - }//end testCheckAllowListMatchesCaseInsensitively() - - public function testCheckAllowListRejectsHostnamesNotInList(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['allowed.example.com'])); - - $this->assertFalse($this->service->checkAllowList(url: 'https://blocked.example.org/feed')); - }//end testCheckAllowListRejectsHostnamesNotInList() - - public function testCheckAllowListSubdomainNotImplied(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['example.org'])); - - // Per spec REQ-NEWS-006: exact hostname required, no wildcard - // subdomain expansion. - $this->assertFalse($this->service->checkAllowList(url: 'https://news.example.org/feed')); - }//end testCheckAllowListSubdomainNotImplied() - - public function testCheckMetadataFilterRejectsWhenSpecNotImplemented(): void - { - // dashboard-metadata-fields capability not on this branch — the - // filter must conservatively return false (treat missing field - // as null which never matches a configured equality). - $this->assertFalse( - $this->service->checkMetadataFilter( - dashboardId: 1, - metadataFilter: ['fieldKey' => 'department', 'value' => 'marketing'] - ) - ); - }//end testCheckMetadataFilterRejectsWhenSpecNotImplemented() - - public function testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse(): void - { - $response = $this->service->fetchAndMergeFeeds(feedUrls: [], limit: 10); - - $this->assertSame([], $response['items']); - $this->assertSame(0, $response['feedsFailed']); - $this->assertSame([], $response['failedUrls']); - }//end testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse() - - /** - * C1 SSRF: http:// URLs MUST be rejected before any allow-list check. - * The UrlSafetyValidator rejects non-HTTPS, so fetchAndMergeFeeds must - * count the URL as failed without attempting a network request. - */ - public function testFetchAndMergeFeedsRejectsHttpUrls(): void - { - // No HTTP client interaction expected — SSRF guard fires first. - $this->clientService->expects($this->never())->method('newClient'); - - $response = $this->service->fetchAndMergeFeeds( - feedUrls: ['http://attacker.internal/feed.rss'], - limit: 10 - ); - - $this->assertSame([], $response['items']); - $this->assertSame(1, $response['feedsFailed']); - }//end testFetchAndMergeFeedsRejectsHttpUrls() - - /** - * C1 SSRF: extractFeedUrls MUST drop http:// entries (HTTPS-only). - */ - public function testExtractNewsConfigDropsHttpFeedUrls(): void - { - $placement = new WidgetPlacement(); - $placement->setStyleConfig(json_encode([ - 'feedUrls' => [ - 'https://valid.example.com/feed', - 'http://insecure.example.com/feed', - 'ftp://nope.example.com/feed', - ], - ])); - - $config = $this->service->extractNewsConfig(placement: $placement); - - $this->assertSame(['https://valid.example.com/feed'], $config['feedUrls']); - }//end testExtractNewsConfigDropsHttpFeedUrls() + $items = $this->service->parseRssFeed( + feedContent: $atom, + sourceUrl: 'https://example.com/atom', + sourceTitle: 'fallback' + ); + + $this->assertCount(1, $items); + $this->assertSame('urn:1', $items[0]['guid']); + $this->assertSame('Atom one', $items[0]['title']); + $this->assertSame('https://example.com/atom-one', $items[0]['link']); + $this->assertSame('Atom Source', $items[0]['sourceTitle']); + }//end testParseRssFeedAcceptsAtom() + + public function testParseRssFeedReturnsEmptyOnGarbage(): void { + $items = $this->service->parseRssFeed( + feedContent: '<>', + sourceUrl: 'https://x.example.com/bad', + sourceTitle: 'bad' + ); + + $this->assertSame([], $items); + }//end testParseRssFeedReturnsEmptyOnGarbage() + + public function testDeduplicateItemsKeepsFirstOccurrence(): void { + $items = [ + ['guid' => 'a', 'title' => 'first'], + ['guid' => 'b', 'title' => 'second'], + ['guid' => 'a', 'title' => 'duplicate'], + ]; + + $out = $this->service->deduplicateItems(items: $items); + + $this->assertCount(2, $out); + $this->assertSame('first', $out[0]['title']); + $this->assertSame('second', $out[1]['title']); + }//end testDeduplicateItemsKeepsFirstOccurrence() + + public function testSortItemsByDateDescending(): void { + $items = [ + ['guid' => '1', 'pubDate' => '2026-04-30T10:00:00Z'], + ['guid' => '2', 'pubDate' => '2026-05-01T16:00:00Z'], + ['guid' => '3', 'pubDate' => '2026-05-01T14:00:00Z'], + ]; + + $sorted = $this->service->sortItemsByDate(items: $items); + + $this->assertSame('2', $sorted[0]['guid']); + $this->assertSame('3', $sorted[1]['guid']); + $this->assertSame('1', $sorted[2]['guid']); + }//end testSortItemsByDateDescending() + + public function testSanitiseSummaryHtmlAllowsWhitelistedTags(): void { + $html = '

Read our latest post

'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringContainsString('

', $clean); + $this->assertStringContainsString('', $clean); + }//end testSanitiseSummaryHtmlAllowsWhitelistedTags() + + public function testSanitiseSummaryStripsScriptTags(): void { + $html = '

Hi

'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringNotContainsString('assertStringNotContainsString('service->sanitiseSummaryHtml(html: $html); + + $this->assertStringContainsString('rel="noopener noreferrer"', $clean); + }//end testSanitiseSummaryForcesRelOnLinks() + + public function testSanitiseSummaryNeutralisesJavascriptHref(): void { + $html = 'danger'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringNotContainsString('javascript:', $clean); + $this->assertStringContainsString('href="#"', $clean); + }//end testSanitiseSummaryNeutralisesJavascriptHref() + + public function testCheckAllowListAcceptsAllWhenEmpty(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(''); + + $this->assertTrue($this->service->checkAllowList(url: 'https://anything.example.com/feed')); + }//end testCheckAllowListAcceptsAllWhenEmpty() + + public function testCheckAllowListMatchesCaseInsensitively(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['Example.Org'])); + + $this->assertTrue($this->service->checkAllowList(url: 'https://example.org/feed')); + }//end testCheckAllowListMatchesCaseInsensitively() + + public function testCheckAllowListRejectsHostnamesNotInList(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['allowed.example.com'])); + + $this->assertFalse($this->service->checkAllowList(url: 'https://blocked.example.org/feed')); + }//end testCheckAllowListRejectsHostnamesNotInList() + + public function testCheckAllowListSubdomainNotImplied(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['example.org'])); + + // Per spec REQ-NEWS-006: exact hostname required, no wildcard + // subdomain expansion. + $this->assertFalse($this->service->checkAllowList(url: 'https://news.example.org/feed')); + }//end testCheckAllowListSubdomainNotImplied() + + public function testCheckMetadataFilterRejectsWhenSpecNotImplemented(): void { + // dashboard-metadata-fields capability not on this branch — the + // filter must conservatively return false (treat missing field + // as null which never matches a configured equality). + $this->assertFalse( + $this->service->checkMetadataFilter( + dashboardId: 1, + metadataFilter: ['fieldKey' => 'department', 'value' => 'marketing'] + ) + ); + }//end testCheckMetadataFilterRejectsWhenSpecNotImplemented() + + public function testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse(): void { + $response = $this->service->fetchAndMergeFeeds(feedUrls: [], limit: 10); + + $this->assertSame([], $response['items']); + $this->assertSame(0, $response['feedsFailed']); + $this->assertSame([], $response['failedUrls']); + }//end testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse() + + /** + * C1 SSRF: http:// URLs MUST be rejected before any allow-list check. + * The UrlSafetyValidator rejects non-HTTPS, so fetchAndMergeFeeds must + * count the URL as failed without attempting a network request. + */ + public function testFetchAndMergeFeedsRejectsHttpUrls(): void { + // No HTTP client interaction expected — SSRF guard fires first. + $this->clientService->expects($this->never())->method('newClient'); + + $response = $this->service->fetchAndMergeFeeds( + feedUrls: ['http://attacker.internal/feed.rss'], + limit: 10 + ); + + $this->assertSame([], $response['items']); + $this->assertSame(1, $response['feedsFailed']); + }//end testFetchAndMergeFeedsRejectsHttpUrls() + + /** + * C1 SSRF: extractFeedUrls MUST drop http:// entries (HTTPS-only). + */ + public function testExtractNewsConfigDropsHttpFeedUrls(): void { + $placement = new WidgetPlacement(); + $placement->setStyleConfig(json_encode([ + 'feedUrls' => [ + 'https://valid.example.com/feed', + 'http://insecure.example.com/feed', + 'ftp://nope.example.com/feed', + ], + ])); + + $config = $this->service->extractNewsConfig(placement: $placement); + + $this->assertSame(['https://valid.example.com/feed'], $config['feedUrls']); + }//end testExtractNewsConfigDropsHttpFeedUrls() }//end class diff --git a/tests/Unit/Service/OrgNavigationServiceTest.php b/tests/Unit/Service/OrgNavigationServiceTest.php index 783f3a0b0..7b9634371 100644 --- a/tests/Unit/Service/OrgNavigationServiceTest.php +++ b/tests/Unit/Service/OrgNavigationServiceTest.php @@ -21,7 +21,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -41,444 +41,400 @@ /** * Tests for the org-wide navigation editor service. */ -class OrgNavigationServiceTest extends TestCase -{ - - /** @var IAppData&MockObject */ - private $appData; - - /** @var AdminTemplateService&MockObject */ - private $templateService; - - private OrgNavigationService $service; - - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->templateService = $this->createMock(AdminTemplateService::class); - - $this->service = new OrgNavigationService( - appData: $this->appData, - templateService: $this->templateService, - ); - - }//end setUp() - - - /** - * Build a deterministic UUID v4 derived from the given seed so - * fixtures stay readable. - * - * @param string $seed The seed. - * - * @return string A canonical UUID string. - */ - private function uuid(string $seed): string - { - $hash = md5($seed); - return sprintf( - '%s-%s-4%s-8%s-%s', - substr($hash, 0, 8), - substr($hash, 8, 4), - substr($hash, 12, 3), - substr($hash, 15, 3), - substr($hash, 18, 12) - ); - - }//end uuid() - - - public function testValidateAcceptsWellFormedTree(): void - { - $tree = [ - [ - 'id' => $this->uuid('a'), - 'label' => 'Section A', - 'icon' => 'folder', - 'url' => null, - 'openInNewTab' => false, - 'groupVisibility' => null, - 'children' => [ - [ - 'id' => $this->uuid('a.1'), - 'label' => 'Child', - 'url' => '/apps/launchpad/dashboards', - 'children' => [], - ], - ], - ], - ]; - - $this->service->validateTree(tree: $tree); - $this->assertTrue(true); - - }//end testValidateAcceptsWellFormedTree() - - - public function testValidateRejectsTreeExceedingDepth(): void - { - $tree = [ - [ - 'id' => $this->uuid('l1'), - 'label' => 'L1', - 'children' => [ - [ - 'id' => $this->uuid('l2'), - 'label' => 'L2', - 'children' => [ - [ - 'id' => $this->uuid('l3'), - 'label' => 'L3', - 'children' => [ - [ - 'id' => $this->uuid('l4'), - 'label' => 'L4 too deep', - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tree depth cannot exceed 3 levels'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsTreeExceedingDepth() - - - public function testValidateRejectsDuplicateIds(): void - { - $shared = $this->uuid('shared'); - $tree = [ - ['id' => $shared, 'label' => 'A'], - ['id' => $shared, 'label' => 'B'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/duplicate.*id/i'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsDuplicateIds() - - - public function testValidateRejectsJavascriptUrl(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'JavaScript:alert(1)'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('URL scheme is not allowed'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsJavascriptUrl() - - - public function testValidateRejectsDataUrl(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'data:text/html,'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsDataUrl() - - - public function testValidateRejectsEmptyLabel(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => ' '], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('label is required'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsEmptyLabel() - - - public function testValidateRejectsNonUuidId(): void - { - $tree = [ - ['id' => 'not-a-uuid', 'label' => 'X'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Node id must be a valid UUID'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsNonUuidId() - - - public function testFilterReturnsFullTreeWhenAllNodesAreUnrestricted(): void - { - $tree = [ - [ - 'id' => $this->uuid('a'), - 'label' => 'A', - 'groupVisibility' => null, - 'children' => [ - [ - 'id' => $this->uuid('a.1'), - 'label' => 'A.1', - 'groupVisibility' => null, - 'children' => [], - ], - ], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['anyone']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'alice' - ); - - $this->assertCount(1, $result); - $this->assertCount(1, $result[0]['children']); - - }//end testFilterReturnsFullTreeWhenAllNodesAreUnrestricted() - +class OrgNavigationServiceTest extends TestCase { + + /** @var IAppData&MockObject */ + private $appData; + + /** @var AdminTemplateService&MockObject */ + private $templateService; + + private OrgNavigationService $service; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->templateService = $this->createMock(AdminTemplateService::class); + + $this->service = new OrgNavigationService( + appData: $this->appData, + templateService: $this->templateService, + ); + + }//end setUp() + + /** + * Build a deterministic UUID v4 derived from the given seed so + * fixtures stay readable. + * + * @param string $seed The seed. + * + * @return string A canonical UUID string. + */ + private function uuid(string $seed): string { + $hash = md5($seed); + return sprintf( + '%s-%s-4%s-8%s-%s', + substr($hash, 0, 8), + substr($hash, 8, 4), + substr($hash, 12, 3), + substr($hash, 15, 3), + substr($hash, 18, 12) + ); + + }//end uuid() + + public function testValidateAcceptsWellFormedTree(): void { + $tree = [ + [ + 'id' => $this->uuid('a'), + 'label' => 'Section A', + 'icon' => 'folder', + 'url' => null, + 'openInNewTab' => false, + 'groupVisibility' => null, + 'children' => [ + [ + 'id' => $this->uuid('a.1'), + 'label' => 'Child', + 'url' => '/apps/launchpad/dashboards', + 'children' => [], + ], + ], + ], + ]; + + $this->service->validateTree(tree: $tree); + $this->assertTrue(true); + + }//end testValidateAcceptsWellFormedTree() + + public function testValidateRejectsTreeExceedingDepth(): void { + $tree = [ + [ + 'id' => $this->uuid('l1'), + 'label' => 'L1', + 'children' => [ + [ + 'id' => $this->uuid('l2'), + 'label' => 'L2', + 'children' => [ + [ + 'id' => $this->uuid('l3'), + 'label' => 'L3', + 'children' => [ + [ + 'id' => $this->uuid('l4'), + 'label' => 'L4 too deep', + 'children' => [], + ], + ], + ], + ], + ], + ], + ], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tree depth cannot exceed 3 levels'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsTreeExceedingDepth() + + public function testValidateRejectsDuplicateIds(): void { + $shared = $this->uuid('shared'); + $tree = [ + ['id' => $shared, 'label' => 'A'], + ['id' => $shared, 'label' => 'B'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/duplicate.*id/i'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsDuplicateIds() + + public function testValidateRejectsJavascriptUrl(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'JavaScript:alert(1)'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URL scheme is not allowed'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsJavascriptUrl() + + public function testValidateRejectsDataUrl(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'data:text/html,'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsDataUrl() + + public function testValidateRejectsEmptyLabel(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => ' '], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('label is required'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsEmptyLabel() + + public function testValidateRejectsNonUuidId(): void { + $tree = [ + ['id' => 'not-a-uuid', 'label' => 'X'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Node id must be a valid UUID'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsNonUuidId() + + public function testFilterReturnsFullTreeWhenAllNodesAreUnrestricted(): void { + $tree = [ + [ + 'id' => $this->uuid('a'), + 'label' => 'A', + 'groupVisibility' => null, + 'children' => [ + [ + 'id' => $this->uuid('a.1'), + 'label' => 'A.1', + 'groupVisibility' => null, + 'children' => [], + ], + ], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['anyone']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'alice' + ); + + $this->assertCount(1, $result); + $this->assertCount(1, $result[0]['children']); + + }//end testFilterReturnsFullTreeWhenAllNodesAreUnrestricted() + + public function testFilterHidesNodeWhenUserNotInGroup(): void { + $tree = [ + [ + 'id' => $this->uuid('admin'), + 'label' => 'Admin only', + 'groupVisibility' => ['admin'], + 'children' => [], + ], + [ + 'id' => $this->uuid('public'), + 'label' => 'Public', + 'groupVisibility' => null, + 'children' => [], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['users']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'bob' + ); + + $this->assertCount(1, $result); + $this->assertSame('Public', $result[0]['label']); + + }//end testFilterHidesNodeWhenUserNotInGroup() + + public function testFilterShowsNodeWhenUserMatchesAnyListedGroup(): void { + $tree = [ + [ + 'id' => $this->uuid('mkt'), + 'label' => 'Sales/Marketing', + 'groupVisibility' => ['marketing', 'sales'], + 'children' => [], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['sales']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'sam' + ); + + $this->assertCount(1, $result); + + }//end testFilterShowsNodeWhenUserMatchesAnyListedGroup() + + public function testFilterCascadesHiddenParentToChildren(): void { + $tree = [ + [ + 'id' => $this->uuid('p'), + 'label' => 'Parent', + 'groupVisibility' => ['secret'], + 'children' => [ + [ + 'id' => $this->uuid('c'), + 'label' => 'Child', + 'groupVisibility' => null, + 'children' => [], + ], + ], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['users']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'eve' + ); + + $this->assertSame([], $result); + + }//end testFilterCascadesHiddenParentToChildren() + + public function testGetTreeReturnsEmptyWhenFolderMissing(): void { + $this->appData + ->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertSame([], $this->service->getTree()); + + }//end testGetTreeReturnsEmptyWhenFolderMissing() + + public function testGetTreeReturnsEmptyWhenFileMissing(): void { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $this->assertSame([], $this->service->getTree()); + + }//end testGetTreeReturnsEmptyWhenFileMissing() + + public function testGetTreeDecodesPersistedJson(): void { + $payload = json_encode([ + ['id' => $this->uuid('only'), 'label' => 'Only', 'children' => []], + ]); + + $file = $this->createMock(ISimpleFile::class); + $file->method('getSize')->willReturn(strlen((string)$payload)); + $file->method('getContent')->willReturn($payload); + + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile')->willReturn($file); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $tree = $this->service->getTree(language: 'nl'); + + $this->assertCount(1, $tree); + $this->assertSame('Only', $tree[0]['label']); + + }//end testGetTreeDecodesPersistedJson() + + public function testSetTreeWritesNewFileWhenAbsent(): void { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $folder->expects($this->once()) + ->method('newFile') + ->with( + $this->equalTo('nl.json'), + $this->callback(static function (string $content): bool { + $decoded = json_decode($content, true); + return is_array($decoded) === true + && count($decoded) === 1 + && $decoded[0]['label'] === 'Item'; + }) + ); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $this->service->setTree( + tree: [ + ['id' => $this->uuid('one'), 'label' => 'Item', 'children' => []], + ], + language: 'nl' + ); + + }//end testSetTreeWritesNewFileWhenAbsent() + + public function testSetTreeOverwritesExistingFile(): void { + $file = $this->createMock(ISimpleFile::class); + $file->expects($this->once())->method('putContent'); + + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile')->willReturn($file); + + $this->appData + ->method('getFolder') + ->willReturn($folder); - public function testFilterHidesNodeWhenUserNotInGroup(): void - { - $tree = [ - [ - 'id' => $this->uuid('admin'), - 'label' => 'Admin only', - 'groupVisibility' => ['admin'], - 'children' => [], - ], - [ - 'id' => $this->uuid('public'), - 'label' => 'Public', - 'groupVisibility' => null, - 'children' => [], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['users']); + $this->service->setTree( + tree: [ + ['id' => $this->uuid('over'), 'label' => 'Over', 'children' => []], + ], + language: 'en' + ); - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'bob' - ); + }//end testSetTreeOverwritesExistingFile() - $this->assertCount(1, $result); - $this->assertSame('Public', $result[0]['label']); - - }//end testFilterHidesNodeWhenUserNotInGroup() - - - public function testFilterShowsNodeWhenUserMatchesAnyListedGroup(): void - { - $tree = [ - [ - 'id' => $this->uuid('mkt'), - 'label' => 'Sales/Marketing', - 'groupVisibility' => ['marketing', 'sales'], - 'children' => [], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['sales']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'sam' - ); - - $this->assertCount(1, $result); - - }//end testFilterShowsNodeWhenUserMatchesAnyListedGroup() - - - public function testFilterCascadesHiddenParentToChildren(): void - { - $tree = [ - [ - 'id' => $this->uuid('p'), - 'label' => 'Parent', - 'groupVisibility' => ['secret'], - 'children' => [ - [ - 'id' => $this->uuid('c'), - 'label' => 'Child', - 'groupVisibility' => null, - 'children' => [], - ], - ], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['users']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'eve' - ); - - $this->assertSame([], $result); - - }//end testFilterCascadesHiddenParentToChildren() - - - public function testGetTreeReturnsEmptyWhenFolderMissing(): void - { - $this->appData - ->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertSame([], $this->service->getTree()); - - }//end testGetTreeReturnsEmptyWhenFolderMissing() - - - public function testGetTreeReturnsEmptyWhenFileMissing(): void - { - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->assertSame([], $this->service->getTree()); - - }//end testGetTreeReturnsEmptyWhenFileMissing() - - - public function testGetTreeDecodesPersistedJson(): void - { - $payload = json_encode([ - ['id' => $this->uuid('only'), 'label' => 'Only', 'children' => []], - ]); - - $file = $this->createMock(ISimpleFile::class); - $file->method('getSize')->willReturn(strlen((string) $payload)); - $file->method('getContent')->willReturn($payload); - - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile')->willReturn($file); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $tree = $this->service->getTree(language: 'nl'); - - $this->assertCount(1, $tree); - $this->assertSame('Only', $tree[0]['label']); - - }//end testGetTreeDecodesPersistedJson() - - - public function testSetTreeWritesNewFileWhenAbsent(): void - { - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $folder->expects($this->once()) - ->method('newFile') - ->with( - $this->equalTo('nl.json'), - $this->callback(static function (string $content): bool { - $decoded = json_decode($content, true); - return is_array($decoded) === true - && count($decoded) === 1 - && $decoded[0]['label'] === 'Item'; - }) - ); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->service->setTree( - tree: [ - ['id' => $this->uuid('one'), 'label' => 'Item', 'children' => []], - ], - language: 'nl' - ); - - }//end testSetTreeWritesNewFileWhenAbsent() - - - public function testSetTreeOverwritesExistingFile(): void - { - $file = $this->createMock(ISimpleFile::class); - $file->expects($this->once())->method('putContent'); - - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile')->willReturn($file); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->service->setTree( - tree: [ - ['id' => $this->uuid('over'), 'label' => 'Over', 'children' => []], - ], - language: 'en' - ); - - }//end testSetTreeOverwritesExistingFile() - - - public function testSetTreeRejectsInvalidPayload(): void - { - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(InvalidArgumentException::class); - $this->service->setTree( - tree: [ - ['id' => 'not-uuid', 'label' => 'X'], - ], - language: 'nl' - ); - - }//end testSetTreeRejectsInvalidPayload() - - - public function testSanitiseUrlAcceptsHttpsAndRelativePaths(): void - { - $this->assertSame( - 'https://example.com/x', - $this->service->sanitiseUrl(url: 'https://example.com/x') - ); - $this->assertSame( - '/apps/launchpad/dashboards', - $this->service->sanitiseUrl(url: '/apps/launchpad/dashboards') - ); - - }//end testSanitiseUrlAcceptsHttpsAndRelativePaths() - - - public function testSanitiseUrlRejectsVbscript(): void - { - $this->expectException(InvalidArgumentException::class); - $this->service->sanitiseUrl(url: 'VBScript:msgbox'); - - }//end testSanitiseUrlRejectsVbscript() + public function testSetTreeRejectsInvalidPayload(): void { + $this->appData->expects($this->never())->method('getFolder'); + $this->expectException(InvalidArgumentException::class); + $this->service->setTree( + tree: [ + ['id' => 'not-uuid', 'label' => 'X'], + ], + language: 'nl' + ); + + }//end testSetTreeRejectsInvalidPayload() + + public function testSanitiseUrlAcceptsHttpsAndRelativePaths(): void { + $this->assertSame( + 'https://example.com/x', + $this->service->sanitiseUrl(url: 'https://example.com/x') + ); + $this->assertSame( + '/apps/launchpad/dashboards', + $this->service->sanitiseUrl(url: '/apps/launchpad/dashboards') + ); + + }//end testSanitiseUrlAcceptsHttpsAndRelativePaths() + + public function testSanitiseUrlRejectsVbscript(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->sanitiseUrl(url: 'VBScript:msgbox'); + + }//end testSanitiseUrlRejectsVbscript() }//end class diff --git a/tests/Unit/Service/OrphanedDataCleanupServiceTest.php b/tests/Unit/Service/OrphanedDataCleanupServiceTest.php index 4885e5111..b7271f85f 100644 --- a/tests/Unit/Service/OrphanedDataCleanupServiceTest.php +++ b/tests/Unit/Service/OrphanedDataCleanupServiceTest.php @@ -15,7 +15,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -37,285 +37,276 @@ /** * Unit tests for OrphanedDataCleanupService. */ -class OrphanedDataCleanupServiceTest extends TestCase -{ - /** - * Registry mock. - * - * @var CategoryRegistryService&MockObject - */ - private $registry; - - /** - * Cache factory mock. - * - * @var ICacheFactory&MockObject - */ - private $cacheFactory; - - /** - * Cache mock returned by the factory. - * - * @var ICache&MockObject - */ - private $cache; - - /** - * DB connection mock (transaction tracking). - * - * @var IDBConnection&MockObject - */ - private $db; - - /** - * Activity manager mock. - * - * @var IActivityManager&MockObject - */ - private $activity; - - /** - * Logger mock. - * - * @var LoggerInterface&MockObject - */ - private $logger; - - /** - * Service under test. - * - * @var OrphanedDataCleanupService - */ - private OrphanedDataCleanupService $service; - - /** - * Build all mocks. - * - * @return void - */ - protected function setUp(): void - { - $this->registry = $this->createMock(originalClassName: CategoryRegistryService::class); - $this->cacheFactory = $this->createMock(originalClassName: ICacheFactory::class); - $this->cache = $this->createMock(originalClassName: ICache::class); - $this->db = $this->createMock(originalClassName: IDBConnection::class); - $this->activity = $this->createMock(originalClassName: IActivityManager::class); - $this->logger = $this->createMock(originalClassName: LoggerInterface::class); - - $this->cacheFactory->method('createDistributed')->willReturn($this->cache); - - $this->service = new OrphanedDataCleanupService( - registry: $this->registry, - cacheFactory: $this->cacheFactory, - db: $this->db, - activityManager: $this->activity, - logger: $this->logger, - ); - } - - /** - * Build a category mock returning the supplied count from `scan` - * and `purge`. `isAvailable()` is `true` by default. - * - * @param string $name Category identifier. - * @param int $count Count to return from scan/purge. - * @param bool $available Whether the category is available. - * - * @return CleanupCategoryInterface&MockObject The category. - */ - private function makeCategory( - string $name, - int $count, - bool $available=true - ): CleanupCategoryInterface { - $category = $this->createMock(originalClassName: CleanupCategoryInterface::class); - $category->method('getName')->willReturn($name); - $category->method('isAvailable')->willReturn($available); - $category->method('scan')->willReturn($count); - $category->method('purge')->willReturn($count); - - return $category; - } - - /** - * Scan with no filter MUST traverse every registered category in - * registration order and aggregate counts. - * - * @return void - */ - public function testScanAggregatesCountsAcrossRegistry(): void - { - $a = $this->makeCategory(name: 'a', count: 3); - $b = $this->makeCategory(name: 'b', count: 0); - - $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); - $this->registry->method('getCategoryByName')->willReturnMap( - [ - ['a', $a], - ['b', $b], - ] - ); - - // Cache empty so the orchestrator runs a fresh scan. - $this->cache->method('get')->willReturn(null); - - $result = $this->service->scan(); - - $this->assertSame(expected: 3, actual: $result->getTotalRows()); - $this->assertSame( - expected: ['a' => 3, 'b' => 0], - actual: $result->getByCategory() - ); - } - - /** - * Categories whose `isAvailable()` is `false` MUST end up under - * `skipped` and contribute no count. - * - * @return void - */ - public function testScanSkipsUnavailableCategories(): void - { - $a = $this->makeCategory(name: 'a', count: 3); - $b = $this->makeCategory(name: 'b', count: 99, available: false); - - $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); - $this->registry->method('getCategoryByName')->willReturnMap( - [ - ['a', $a], - ['b', $b], - ] - ); - $this->cache->method('get')->willReturn(null); - - $result = $this->service->scan(); - - $this->assertSame(expected: ['a' => 3], actual: $result->getByCategory()); - $this->assertSame(expected: ['b'], actual: $result->getSkipped()); - $this->assertSame(expected: 3, actual: $result->getTotalRows()); - } - - /** - * The cache hit path MUST short-circuit the registry traversal - * and return a hydrated DTO. - * - * @return void - */ - public function testScanReturnsCachedResultWhenAvailable(): void - { - // Registry MUST NOT be touched on a cache hit. - $this->registry->expects($this->never())->method('getCategoryNames'); - - $this->cache->method('get')->willReturn( - [ - 'byCategory' => ['x' => 7], - 'totalRows' => 7, - 'durationMs' => 1, - 'dryRun' => false, - 'scannedAt' => '2026-05-03T10:00:00Z', - 'skipped' => [], - ] - ); - - $result = $this->service->scan(); - - $this->assertSame(expected: 7, actual: $result->getTotalRows()); - $this->assertSame( - expected: '2026-05-03T10:00:00Z', - actual: $result->getScannedAt() - ); - } - - /** - * A successful real purge MUST invalidate the cache. - * - * @return void - */ - public function testRealPurgeInvalidatesCache(): void - { - $a = $this->makeCategory(name: 'a', count: 2); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->cache->expects($this->once()) - ->method('remove') - ->with(self::equalTo('launchpad.cleanup.scan')); - - $this->service->purge(); - } - - /** - * Dry-run purge MUST wrap the work in a transaction rollback and - * MUST NOT emit an Activity event or invalidate the cache. - * - * @return void - */ - public function testDryRunRollsBackAndDoesNotEmitEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 5); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->db->expects($this->once())->method('beginTransaction'); - $this->db->expects($this->once())->method('rollBack'); - $this->cache->expects($this->never())->method('remove'); - $this->activity->expects($this->never())->method('publish'); - - $result = $this->service->purge(categoryNames: [], dryRun: true); - - $this->assertTrue(condition: $result->isDryRun()); - $this->assertSame(expected: 5, actual: $result->getTotalRows()); - } - - /** - * Real purge with a non-zero total MUST publish exactly one - * activity event tagged with the source. - * - * @return void - */ - public function testRealPurgeEmitsOneActivityEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 4); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $event = $this->createMock(originalClassName: IEvent::class); - $event->method('setApp')->willReturnSelf(); - $event->method('setType')->willReturnSelf(); - $event->method('setAffectedUser')->willReturnSelf(); - $event->method('setAuthor')->willReturnSelf(); - $event->method('setSubject')->willReturnSelf(); - $event->method('setObject')->willReturnSelf(); - - $this->activity->method('generateEvent')->willReturn($event); - $this->activity->expects($this->once())->method('publish'); - - $this->service->purge( - categoryNames: [], - dryRun: false, - userId: 'admin', - source: 'cli' - ); - } - - /** - * A real purge that finds zero rows MUST NOT emit an activity - * event (avoids audit-log spam from idle daily runs). - * - * @return void - */ - public function testRealPurgeWithZeroRowsDoesNotEmitEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 0); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->activity->expects($this->never())->method('publish'); - - $this->service->purge(); - } +class OrphanedDataCleanupServiceTest extends TestCase { + /** + * Registry mock. + * + * @var CategoryRegistryService&MockObject + */ + private $registry; + + /** + * Cache factory mock. + * + * @var ICacheFactory&MockObject + */ + private $cacheFactory; + + /** + * Cache mock returned by the factory. + * + * @var ICache&MockObject + */ + private $cache; + + /** + * DB connection mock (transaction tracking). + * + * @var IDBConnection&MockObject + */ + private $db; + + /** + * Activity manager mock. + * + * @var IActivityManager&MockObject + */ + private $activity; + + /** + * Logger mock. + * + * @var LoggerInterface&MockObject + */ + private $logger; + + /** + * Service under test. + * + * @var OrphanedDataCleanupService + */ + private OrphanedDataCleanupService $service; + + /** + * Build all mocks. + * + * @return void + */ + protected function setUp(): void { + $this->registry = $this->createMock(originalClassName: CategoryRegistryService::class); + $this->cacheFactory = $this->createMock(originalClassName: ICacheFactory::class); + $this->cache = $this->createMock(originalClassName: ICache::class); + $this->db = $this->createMock(originalClassName: IDBConnection::class); + $this->activity = $this->createMock(originalClassName: IActivityManager::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->cacheFactory->method('createDistributed')->willReturn($this->cache); + + $this->service = new OrphanedDataCleanupService( + registry: $this->registry, + cacheFactory: $this->cacheFactory, + db: $this->db, + activityManager: $this->activity, + logger: $this->logger, + ); + } + + /** + * Build a category mock returning the supplied count from `scan` + * and `purge`. `isAvailable()` is `true` by default. + * + * @param string $name Category identifier. + * @param int $count Count to return from scan/purge. + * @param bool $available Whether the category is available. + * + * @return CleanupCategoryInterface&MockObject The category. + */ + private function makeCategory( + string $name, + int $count, + bool $available = true, + ): CleanupCategoryInterface { + $category = $this->createMock(originalClassName: CleanupCategoryInterface::class); + $category->method('getName')->willReturn($name); + $category->method('isAvailable')->willReturn($available); + $category->method('scan')->willReturn($count); + $category->method('purge')->willReturn($count); + + return $category; + } + + /** + * Scan with no filter MUST traverse every registered category in + * registration order and aggregate counts. + * + * @return void + */ + public function testScanAggregatesCountsAcrossRegistry(): void { + $a = $this->makeCategory(name: 'a', count: 3); + $b = $this->makeCategory(name: 'b', count: 0); + + $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); + $this->registry->method('getCategoryByName')->willReturnMap( + [ + ['a', $a], + ['b', $b], + ] + ); + + // Cache empty so the orchestrator runs a fresh scan. + $this->cache->method('get')->willReturn(null); + + $result = $this->service->scan(); + + $this->assertSame(expected: 3, actual: $result->getTotalRows()); + $this->assertSame( + expected: ['a' => 3, 'b' => 0], + actual: $result->getByCategory() + ); + } + + /** + * Categories whose `isAvailable()` is `false` MUST end up under + * `skipped` and contribute no count. + * + * @return void + */ + public function testScanSkipsUnavailableCategories(): void { + $a = $this->makeCategory(name: 'a', count: 3); + $b = $this->makeCategory(name: 'b', count: 99, available: false); + + $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); + $this->registry->method('getCategoryByName')->willReturnMap( + [ + ['a', $a], + ['b', $b], + ] + ); + $this->cache->method('get')->willReturn(null); + + $result = $this->service->scan(); + + $this->assertSame(expected: ['a' => 3], actual: $result->getByCategory()); + $this->assertSame(expected: ['b'], actual: $result->getSkipped()); + $this->assertSame(expected: 3, actual: $result->getTotalRows()); + } + + /** + * The cache hit path MUST short-circuit the registry traversal + * and return a hydrated DTO. + * + * @return void + */ + public function testScanReturnsCachedResultWhenAvailable(): void { + // Registry MUST NOT be touched on a cache hit. + $this->registry->expects($this->never())->method('getCategoryNames'); + + $this->cache->method('get')->willReturn( + [ + 'byCategory' => ['x' => 7], + 'totalRows' => 7, + 'durationMs' => 1, + 'dryRun' => false, + 'scannedAt' => '2026-05-03T10:00:00Z', + 'skipped' => [], + ] + ); + + $result = $this->service->scan(); + + $this->assertSame(expected: 7, actual: $result->getTotalRows()); + $this->assertSame( + expected: '2026-05-03T10:00:00Z', + actual: $result->getScannedAt() + ); + } + + /** + * A successful real purge MUST invalidate the cache. + * + * @return void + */ + public function testRealPurgeInvalidatesCache(): void { + $a = $this->makeCategory(name: 'a', count: 2); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->cache->expects($this->once()) + ->method('remove') + ->with(self::equalTo('launchpad.cleanup.scan')); + + $this->service->purge(); + } + + /** + * Dry-run purge MUST wrap the work in a transaction rollback and + * MUST NOT emit an Activity event or invalidate the cache. + * + * @return void + */ + public function testDryRunRollsBackAndDoesNotEmitEvent(): void { + $a = $this->makeCategory(name: 'a', count: 5); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->db->expects($this->once())->method('beginTransaction'); + $this->db->expects($this->once())->method('rollBack'); + $this->cache->expects($this->never())->method('remove'); + $this->activity->expects($this->never())->method('publish'); + + $result = $this->service->purge(categoryNames: [], dryRun: true); + + $this->assertTrue(condition: $result->isDryRun()); + $this->assertSame(expected: 5, actual: $result->getTotalRows()); + } + + /** + * Real purge with a non-zero total MUST publish exactly one + * activity event tagged with the source. + * + * @return void + */ + public function testRealPurgeEmitsOneActivityEvent(): void { + $a = $this->makeCategory(name: 'a', count: 4); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $event = $this->createMock(originalClassName: IEvent::class); + $event->method('setApp')->willReturnSelf(); + $event->method('setType')->willReturnSelf(); + $event->method('setAffectedUser')->willReturnSelf(); + $event->method('setAuthor')->willReturnSelf(); + $event->method('setSubject')->willReturnSelf(); + $event->method('setObject')->willReturnSelf(); + + $this->activity->method('generateEvent')->willReturn($event); + $this->activity->expects($this->once())->method('publish'); + + $this->service->purge( + categoryNames: [], + dryRun: false, + userId: 'admin', + source: 'cli' + ); + } + + /** + * A real purge that finds zero rows MUST NOT emit an activity + * event (avoids audit-log spam from idle daily runs). + * + * @return void + */ + public function testRealPurgeWithZeroRowsDoesNotEmitEvent(): void { + $a = $this->makeCategory(name: 'a', count: 0); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->activity->expects($this->never())->method('publish'); + + $this->service->purge(); + } } diff --git a/tests/Unit/Service/PeopleWidgetServiceTest.php b/tests/Unit/Service/PeopleWidgetServiceTest.php index 390042e87..18b559b8c 100644 --- a/tests/Unit/Service/PeopleWidgetServiceTest.php +++ b/tests/Unit/Service/PeopleWidgetServiceTest.php @@ -18,7 +18,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -44,449 +44,509 @@ * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors constructor. */ -class PeopleWidgetServiceTest extends TestCase -{ - - /** - * @var IUserManager&MockObject - */ - private $userManager; - - /** - * @var IGroupManager&MockObject - */ - private $groupManager; - - /** - * @var IAccountManager&MockObject - */ - private $accountManager; - - /** - * @var IURLGenerator&MockObject - */ - private $urlGenerator; - - /** - * @var AdminTemplateService&MockObject - */ - private $adminTemplateService; - - private PeopleWidgetService $service; - - /** - * @return void - */ - protected function setUp(): void - { - parent::setUp(); - - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); - $this->accountManager = $this->createMock(originalClassName: IAccountManager::class); - $this->urlGenerator = $this->createMock(originalClassName: IURLGenerator::class); - $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); - - $this->urlGenerator->method('linkToRouteAbsolute') - ->willReturnCallback( - callback: static fn(string $route, array $args=[]): string => 'https://example.test/'.$route.'?'.http_build_query(data: $args) - ); - - // Default: any user has no groups. Tests can override per-call. - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - - $this->service = new PeopleWidgetService( - userManager: $this->userManager, - groupManager: $this->groupManager, - accountManager: $this->accountManager, - urlGenerator: $this->urlGenerator, - adminTemplateService: $this->adminTemplateService, - ); - }//end setUp() - - // --------------------------------------------------------------- - // computeDaysToBirthday — pure helper (REQ-PPL-005) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testComputeDaysToBirthdayReturnsNullForBlankInput(): void - { - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: null)); - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: '')); - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: 'not-a-date')); - }//end testComputeDaysToBirthdayReturnsNullForBlankInput() - - /** - * @return void - */ - public function testComputeDaysToBirthdayHandlesIsoInput(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $iso = $today->modify(modifier: '+5 days')->format(format: '1990-m-d'); - - $this->assertSame( - expected: 5, - actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $iso) - ); - }//end testComputeDaysToBirthdayHandlesIsoInput() - - /** - * @return void - */ - public function testComputeDaysToBirthdayHandlesLocaleFormat(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $locale = $today->modify(modifier: '+10 days')->format(format: 'd-m-1990'); - - $this->assertSame( - expected: 10, - actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $locale) - ); - }//end testComputeDaysToBirthdayHandlesLocaleFormat() - - /** - * @return void - */ - public function testComputeDaysToBirthdayWrapsToNextYearWhenPast(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $past = $today->modify(modifier: '-30 days')->format(format: '1990-m-d'); - - $days = PeopleWidgetService::computeDaysToBirthday(birthdate: $past); - $this->assertNotNull(actual: $days); - $this->assertGreaterThan(300, $days); - }//end testComputeDaysToBirthdayWrapsToNextYearWhenPast() - - /** - * Feb-29 birthday must NOT throw on non-leap years; the service falls - * back to Feb-28. We verify by parsing 2027 (not a leap year) as the - * candidate window. - * - * @return void - */ - public function testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear(): void - { - $days = PeopleWidgetService::computeDaysToBirthday(birthdate: '2000-02-29'); - $this->assertNotNull( - actual: $days, - message: 'Feb-29 input must not throw or return null on any year' - ); - }//end testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear() - - // --------------------------------------------------------------- - // listUsers — argument validation (REQ-PPL-003) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testListUsersRejectsLimitOverMax(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(limit: PeopleWidgetService::MAX_LIMIT + 1); - }//end testListUsersRejectsLimitOverMax() - - /** - * @return void - */ - public function testListUsersRejectsZeroLimit(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(limit: 0); - }//end testListUsersRejectsZeroLimit() - - /** - * @return void - */ - public function testListUsersRejectsNegativeOffset(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(offset: -1); - }//end testListUsersRejectsNegativeOffset() - - /** - * @return void - */ - public function testListUsersRejectsRecentActivitySort(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(sortBy: 'recent-activity'); - }//end testListUsersRejectsRecentActivitySort() - - // --------------------------------------------------------------- - // listUsers — pagination + projection (REQ-PPL-003, REQ-PPL-004) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testListUsersReturnsPaginationShape(): void - { - $users = []; - $users[] = $this->makeUser(uid: 'alice', display: 'Alice', email: 'alice@example.test'); - $users[] = $this->makeUser(uid: 'bob', display: 'Bob', email: ''); - $users[] = $this->makeUser(uid: 'carol', display: 'Carol', email: 'carol@example.test'); - - $this->userManager->method('search')->willReturn($users); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - // Empty account so the optional fields are omitted. - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(limit: 2, offset: 0); - - $this->assertSame(expected: 3, actual: $result['total']); - $this->assertTrue(condition: $result['hasMore']); - $this->assertCount(expectedCount: 2, haystack: $result['users']); - - // Default sort = displayName ASC. - $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); - $this->assertSame(expected: 'bob', actual: $result['users'][1]['uid']); - - // Empty email is OMITTED, not nulled (REQ-PPL-004). - $this->assertArrayNotHasKey(key: 'email', array: $result['users'][1]); - $this->assertArrayHasKey(key: 'email', array: $result['users'][0]); - $this->assertSame( - expected: 'alice@example.test', - actual: $result['users'][0]['email'] - ); - - // Avatar URL points to the configured route. - $this->assertStringContainsString( - needle: 'core.avatar.getAvatar', - haystack: $result['users'][0]['avatarUrl'] - ); - }//end testListUsersReturnsPaginationShape() - - /** - * @return void - */ - public function testListUsersLastPageHasMoreFalse(): void - { - $users = []; - $users[] = $this->makeUser(uid: 'alice', display: 'Alice'); - $users[] = $this->makeUser(uid: 'bob', display: 'Bob'); - - $this->userManager->method('search')->willReturn($users); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(limit: 50, offset: 0); - - $this->assertFalse(condition: $result['hasMore']); - $this->assertSame(expected: 2, actual: $result['total']); - $this->assertCount(expectedCount: 2, haystack: $result['users']); - }//end testListUsersLastPageHasMoreFalse() - - /** - * @return void - */ - public function testListUsersExcludesDisabledByDefault(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice', enabled: true); - $eve = $this->makeUser(uid: 'eve', display: 'Eve', enabled: false); - - $this->userManager->method('search')->willReturn([$alice, $eve]); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(); - - $this->assertSame(expected: 1, actual: $result['total']); - $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); - }//end testListUsersExcludesDisabledByDefault() - - // --------------------------------------------------------------- - // listUsers — group filter (REQ-PPL-006) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testGroupFilterUnionDeduplicates(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $bob = $this->makeUser(uid: 'bob', display: 'Bob'); - $carol = $this->makeUser(uid: 'carol', display: 'Carol'); - - $mgmt = $this->createMock(originalClassName: IGroup::class); - $mgmt->method('getUsers')->willReturn([$alice, $bob]); - - $prod = $this->createMock(originalClassName: IGroup::class); - $prod->method('getUsers')->willReturn([$bob, $carol]); - - $this->groupManager->method('get') - ->willReturnCallback( - callback: static function (string $gid) use ($mgmt, $prod) { - if ($gid === 'management') { - return $mgmt; - } - - if ($gid === 'product') { - return $prod; - } - - return null; - } - ); - - // Group sort path consults getUserGroupIds; default sort doesn't. - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers( - filters: [ - [ - 'fieldName' => 'group', - 'operator' => 'in', - 'values' => ['management', 'product'], - ], - ], - ); - - // Bob appears once across both groups (dedup). - $uids = array_map( - callback: static fn(array $u): string => $u['uid'], - array: $result['users'] - ); - $this->assertSame(expected: ['alice', 'bob', 'carol'], actual: $uids); - $this->assertSame(expected: 3, actual: $result['total']); - }//end testGroupFilterUnionDeduplicates() - - /** - * @return void - */ - public function testUnknownGroupYieldsZeroUsersWithoutError(): void - { - $this->groupManager->method('get')->willReturn(null); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers( - filters: [ - [ - 'fieldName' => 'group', - 'operator' => 'in', - 'values' => ['nonexistent'], - ], - ], - ); - - $this->assertSame(expected: 0, actual: $result['total']); - $this->assertSame(expected: [], actual: $result['users']); - $this->assertFalse(condition: $result['hasMore']); - }//end testUnknownGroupYieldsZeroUsersWithoutError() - - // --------------------------------------------------------------- - // listUsers — account-field projection (REQ-PPL-005) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testBirthdateIsNormalisedToIso(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $this->userManager->method('search')->willReturn([$alice]); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - $account = $this->makeAccount( - properties: [ - IAccountManager::PROPERTY_BIRTHDATE => '10-06-1990', - IAccountManager::PROPERTY_ROLE => 'PM', - ] - ); - $this->accountManager->method('getAccount')->willReturn($account); - - $result = $this->service->listUsers(); - - $this->assertSame( - expected: '1990-06-10', - actual: $result['users'][0]['birthdate'] - ); - $this->assertSame(expected: 'PM', actual: $result['users'][0]['role']); - }//end testBirthdateIsNormalisedToIso() - - /** - * @return void - */ - public function testShowBirthdaysFalseStripsBirthdate(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $this->userManager->method('search')->willReturn([$alice]); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - $account = $this->makeAccount( - properties: [IAccountManager::PROPERTY_BIRTHDATE => '1990-06-10'] - ); - $this->accountManager->method('getAccount')->willReturn($account); - - $result = $this->service->listUsers(showBirthdays: false); - - $this->assertArrayNotHasKey( - key: 'birthdate', - array: $result['users'][0] - ); - }//end testShowBirthdaysFalseStripsBirthdate() - - // --------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------- - - /** - * @param string $uid The user id. - * @param string $display Display name. - * @param string $email Email or empty string. - * @param bool $enabled Whether the user is enabled. - * - * @return IUser&MockObject - */ - private function makeUser( - string $uid, - string $display, - string $email='', - bool $enabled=true - ): IUser { - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getUID')->willReturn($uid); - $user->method('getDisplayName')->willReturn($display); - $user->method('getEMailAddress')->willReturn($email === '' ? null : $email); - $user->method('isEnabled')->willReturn($enabled); - return $user; - }//end makeUser() - - /** - * Build an account whose every property returns the empty string — - * matches the "no profile fields set" baseline. - * - * @return IAccount&MockObject - */ - private function emptyAccount(): IAccount - { - return $this->makeAccount(properties: []); - }//end emptyAccount() - - /** - * @param array $properties Map of property name → value. - * Properties absent from the map - * resolve to the empty string. - * - * @return IAccount&MockObject - */ - private function makeAccount(array $properties): IAccount - { - $account = $this->createMock(originalClassName: IAccount::class); - $account->method('getProperty') - ->willReturnCallback( - callback: function (string $name) use ($properties): IAccountProperty { - $prop = $this->createMock(originalClassName: IAccountProperty::class); - $prop->method('getValue')->willReturn($properties[$name] ?? ''); - $prop->method('getName')->willReturn($name); - return $prop; - } - ); - - return $account; - }//end makeAccount() +class PeopleWidgetServiceTest extends TestCase { + + /** + * @var IUserManager&MockObject + */ + private $userManager; + + /** + * @var IGroupManager&MockObject + */ + private $groupManager; + + /** + * @var IAccountManager&MockObject + */ + private $accountManager; + + /** + * @var IURLGenerator&MockObject + */ + private $urlGenerator; + + /** + * @var AdminTemplateService&MockObject + */ + private $adminTemplateService; + + private PeopleWidgetService $service; + + /** + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->accountManager = $this->createMock(originalClassName: IAccountManager::class); + $this->urlGenerator = $this->createMock(originalClassName: IURLGenerator::class); + $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); + + $this->urlGenerator->method('linkToRouteAbsolute') + ->willReturnCallback( + callback: static fn (string $route, array $args = []): string => 'https://example.test/' . $route . '?' . http_build_query(data: $args) + ); + + // Default: any user has no groups. Tests can override per-call. + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + + $this->service = new PeopleWidgetService( + userManager: $this->userManager, + groupManager: $this->groupManager, + accountManager: $this->accountManager, + urlGenerator: $this->urlGenerator, + adminTemplateService: $this->adminTemplateService, + ); + }//end setUp() + + // --------------------------------------------------------------- + // computeDaysToBirthday — pure helper (REQ-PPL-005) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testComputeDaysToBirthdayReturnsNullForBlankInput(): void { + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: null)); + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: '')); + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: 'not-a-date')); + }//end testComputeDaysToBirthdayReturnsNullForBlankInput() + + /** + * @return void + */ + public function testComputeDaysToBirthdayHandlesIsoInput(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $iso = $today->modify(modifier: '+5 days')->format(format: '1990-m-d'); + + $this->assertSame( + expected: 5, + actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $iso) + ); + }//end testComputeDaysToBirthdayHandlesIsoInput() + + /** + * @return void + */ + public function testComputeDaysToBirthdayHandlesLocaleFormat(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $locale = $today->modify(modifier: '+10 days')->format(format: 'd-m-1990'); + + $this->assertSame( + expected: 10, + actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $locale) + ); + }//end testComputeDaysToBirthdayHandlesLocaleFormat() + + /** + * @return void + */ + public function testComputeDaysToBirthdayWrapsToNextYearWhenPast(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $past = $today->modify(modifier: '-30 days')->format(format: '1990-m-d'); + + $days = PeopleWidgetService::computeDaysToBirthday(birthdate: $past); + $this->assertNotNull(actual: $days); + $this->assertGreaterThan(300, $days); + }//end testComputeDaysToBirthdayWrapsToNextYearWhenPast() + + /** + * Feb-29 birthday must NOT throw on non-leap years; the service falls + * back to Feb-28. We verify by parsing 2027 (not a leap year) as the + * candidate window. + * + * @return void + */ + public function testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear(): void { + $days = PeopleWidgetService::computeDaysToBirthday(birthdate: '2000-02-29'); + $this->assertNotNull( + actual: $days, + message: 'Feb-29 input must not throw or return null on any year' + ); + }//end testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear() + + // --------------------------------------------------------------- + // listUsers — argument validation (REQ-PPL-003) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testListUsersRejectsLimitOverMax(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(limit: PeopleWidgetService::MAX_LIMIT + 1); + }//end testListUsersRejectsLimitOverMax() + + /** + * @return void + */ + public function testListUsersRejectsZeroLimit(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(limit: 0); + }//end testListUsersRejectsZeroLimit() + + /** + * @return void + */ + public function testListUsersRejectsNegativeOffset(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(offset: -1); + }//end testListUsersRejectsNegativeOffset() + + /** + * @return void + */ + public function testListUsersRejectsRecentActivitySort(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(sortBy: 'recent-activity'); + }//end testListUsersRejectsRecentActivitySort() + + // --------------------------------------------------------------- + // listUsers — pagination + projection (REQ-PPL-003, REQ-PPL-004) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testListUsersReturnsPaginationShape(): void { + $users = []; + $users[] = $this->makeUser(uid: 'alice', display: 'Alice', email: 'alice@example.test'); + $users[] = $this->makeUser(uid: 'bob', display: 'Bob', email: ''); + $users[] = $this->makeUser(uid: 'carol', display: 'Carol', email: 'carol@example.test'); + + $this->wireDirectory(orderedUsers: $users); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + // Empty account so the optional fields are omitted. + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 2, offset: 0); + + $this->assertSame(expected: 3, actual: $result['total']); + $this->assertTrue(condition: $result['hasMore']); + $this->assertCount(expectedCount: 2, haystack: $result['users']); + + // Default sort = displayName ASC. + $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); + $this->assertSame(expected: 'bob', actual: $result['users'][1]['uid']); + + // Empty email is OMITTED, not nulled (REQ-PPL-004). + $this->assertArrayNotHasKey(key: 'email', array: $result['users'][1]); + $this->assertArrayHasKey(key: 'email', array: $result['users'][0]); + $this->assertSame( + expected: 'alice@example.test', + actual: $result['users'][0]['email'] + ); + + // Avatar URL points to the configured route. + $this->assertStringContainsString( + needle: 'core.avatar.getAvatar', + haystack: $result['users'][0]['avatarUrl'] + ); + }//end testListUsersReturnsPaginationShape() + + /** + * @return void + */ + public function testListUsersLastPageHasMoreFalse(): void { + $users = []; + $users[] = $this->makeUser(uid: 'alice', display: 'Alice'); + $users[] = $this->makeUser(uid: 'bob', display: 'Bob'); + + $this->wireDirectory(orderedUsers: $users); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 50, offset: 0); + + $this->assertFalse(condition: $result['hasMore']); + $this->assertSame(expected: 2, actual: $result['total']); + $this->assertCount(expectedCount: 2, haystack: $result['users']); + }//end testListUsersLastPageHasMoreFalse() + + /** + * @return void + */ + public function testListUsersExcludesDisabledByDefault(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice', enabled: true); + $eve = $this->makeUser(uid: 'eve', display: 'Eve', enabled: false); + + // The backend returns both (display-name order); the bounded page + // path skips the disabled user inside the window, and the exact + // total comes from countUsersTotal() minus countDisabledUsers(). + $this->wireDirectory(orderedUsers: [$alice, $eve], disabledCount: 1); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(); + + $this->assertSame(expected: 1, actual: $result['total']); + $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); + }//end testListUsersExcludesDisabledByDefault() + + // --------------------------------------------------------------- + // listUsers — group filter (REQ-PPL-006) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testGroupFilterUnionDeduplicates(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $bob = $this->makeUser(uid: 'bob', display: 'Bob'); + $carol = $this->makeUser(uid: 'carol', display: 'Carol'); + + $mgmt = $this->createMock(originalClassName: IGroup::class); + $mgmt->method('getUsers')->willReturn([$alice, $bob]); + + $prod = $this->createMock(originalClassName: IGroup::class); + $prod->method('getUsers')->willReturn([$bob, $carol]); + + $this->groupManager->method('get') + ->willReturnCallback( + callback: static function (string $gid) use ($mgmt, $prod) { + if ($gid === 'management') { + return $mgmt; + } + + if ($gid === 'product') { + return $prod; + } + + return null; + } + ); + + // Group sort path consults getUserGroupIds; default sort doesn't. + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers( + filters: [ + [ + 'fieldName' => 'group', + 'operator' => 'in', + 'values' => ['management', 'product'], + ], + ], + ); + + // Bob appears once across both groups (dedup). + $uids = array_map( + callback: static fn (array $u): string => $u['uid'], + array: $result['users'] + ); + $this->assertSame(expected: ['alice', 'bob', 'carol'], actual: $uids); + $this->assertSame(expected: 3, actual: $result['total']); + }//end testGroupFilterUnionDeduplicates() + + /** + * @return void + */ + public function testUnknownGroupYieldsZeroUsersWithoutError(): void { + $this->groupManager->method('get')->willReturn(null); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers( + filters: [ + [ + 'fieldName' => 'group', + 'operator' => 'in', + 'values' => ['nonexistent'], + ], + ], + ); + + $this->assertSame(expected: 0, actual: $result['total']); + $this->assertSame(expected: [], actual: $result['users']); + $this->assertFalse(condition: $result['hasMore']); + }//end testUnknownGroupYieldsZeroUsersWithoutError() + + // --------------------------------------------------------------- + // listUsers — account-field projection (REQ-PPL-005) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testBirthdateIsNormalisedToIso(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $this->wireDirectory(orderedUsers: [$alice]); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + $account = $this->makeAccount( + properties: [ + IAccountManager::PROPERTY_BIRTHDATE => '10-06-1990', + IAccountManager::PROPERTY_ROLE => 'PM', + ] + ); + $this->accountManager->method('getAccount')->willReturn($account); + + $result = $this->service->listUsers(); + + $this->assertSame( + expected: '1990-06-10', + actual: $result['users'][0]['birthdate'] + ); + $this->assertSame(expected: 'PM', actual: $result['users'][0]['role']); + }//end testBirthdateIsNormalisedToIso() + + /** + * @return void + */ + public function testShowBirthdaysFalseStripsBirthdate(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $this->wireDirectory(orderedUsers: [$alice]); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + $account = $this->makeAccount( + properties: [IAccountManager::PROPERTY_BIRTHDATE => '1990-06-10'] + ); + $this->accountManager->method('getAccount')->willReturn($account); + + $result = $this->service->listUsers(showBirthdays: false); + + $this->assertArrayNotHasKey( + key: 'birthdate', + array: $result['users'][0] + ); + }//end testShowBirthdaysFalseStripsBirthdate() + + // --------------------------------------------------------------- + // listUsers — bounded directory scan (fix-people-widget-unbounded-user-scan) + // --------------------------------------------------------------- + + /** + * With no `group` filter and the default `displayName` sort, the + * service MUST page directly from the backend via a bounded + * `searchDisplayName($pattern, $limit, $offset)` call and MUST NOT + * fall back to the unbounded `search('')` full-directory scan. + * + * @return void + */ + public function testDisplayNameSortUsesBoundedSearchNotFullScan(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $bob = $this->makeUser(uid: 'bob', display: 'Bob'); + + // The unbounded scan MUST NOT be used for this path. + $this->userManager->expects($this->never())->method('search'); + + $captured = []; + $this->userManager->expects($this->atLeastOnce()) + ->method('searchDisplayName') + ->willReturnCallback( + function (string $pattern, ?int $limit = null, ?int $offset = null) use (&$captured, $alice, $bob): array { + $captured[] = ['pattern' => $pattern, 'limit' => $limit, 'offset' => $offset]; + return array_slice([$alice, $bob], (int)$offset, ($limit ?? 2)); + } + ); + $this->userManager->method('countUsersTotal')->willReturn(2); + $this->userManager->method('countDisabledUsers')->willReturn(0); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 10, offset: 0); + + // The backend was asked for a bounded, non-null limit. + $this->assertNotEmpty($captured); + $this->assertNotNull($captured[0]['limit']); + $this->assertGreaterThanOrEqual(10, $captured[0]['limit']); + $this->assertSame(0, $captured[0]['offset']); + $this->assertSame('', $captured[0]['pattern']); + + // Envelope semantics unchanged from the caller's point of view. + $this->assertSame(2, $result['total']); + $this->assertFalse($result['hasMore']); + $this->assertSame(['alice', 'bob'], array_column($result['users'], 'uid')); + }//end testDisplayNameSortUsesBoundedSearchNotFullScan() + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Wire the user-directory backend for a no-group-filter, + * display-name-sorted listing: a bounded `searchDisplayName` that + * honours the streamed `limit`/`offset` window plus the + * `countUsersTotal`/`countDisabledUsers` counters the bounded path + * uses to size `total` without a full scan. + * + * @param IUser[] $orderedUsers Users in display-name order (enabled + * and disabled). `countUsersTotal` + * reports the full length. + * @param int $disabledCount Number of disabled users in the set. + * + * @return void + */ + private function wireDirectory(array $orderedUsers, int $disabledCount = 0): void { + $this->userManager->method('searchDisplayName') + ->willReturnCallback( + static function (string $pattern, ?int $limit = null, ?int $offset = null) use ($orderedUsers): array { + return array_slice( + $orderedUsers, + (int)$offset, + ($limit ?? count($orderedUsers)) + ); + } + ); + $this->userManager->method('countUsersTotal')->willReturn(count($orderedUsers)); + $this->userManager->method('countDisabledUsers')->willReturn($disabledCount); + }//end wireDirectory() + + /** + * @param string $uid The user id. + * @param string $display Display name. + * @param string $email Email or empty string. + * @param bool $enabled Whether the user is enabled. + * + * @return IUser&MockObject + */ + private function makeUser( + string $uid, + string $display, + string $email = '', + bool $enabled = true, + ): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('getDisplayName')->willReturn($display); + $user->method('getEMailAddress')->willReturn($email === '' ? null : $email); + $user->method('isEnabled')->willReturn($enabled); + return $user; + }//end makeUser() + + /** + * Build an account whose every property returns the empty string — + * matches the "no profile fields set" baseline. + * + * @return IAccount&MockObject + */ + private function emptyAccount(): IAccount { + return $this->makeAccount(properties: []); + }//end emptyAccount() + + /** + * @param array $properties Map of property name → value. + * Properties absent from the map + * resolve to the empty string. + * + * @return IAccount&MockObject + */ + private function makeAccount(array $properties): IAccount { + $account = $this->createMock(originalClassName: IAccount::class); + $account->method('getProperty') + ->willReturnCallback( + callback: function (string $name) use ($properties): IAccountProperty { + $prop = $this->createMock(originalClassName: IAccountProperty::class); + $prop->method('getValue')->willReturn($properties[$name] ?? ''); + $prop->method('getName')->willReturn($name); + return $prop; + } + ); + + return $account; + }//end makeAccount() }//end class diff --git a/tests/Unit/Service/PlacementServiceQuotaWiringTest.php b/tests/Unit/Service/PlacementServiceQuotaWiringTest.php index 8dd368f59..51ae0aaf6 100644 --- a/tests/Unit/Service/PlacementServiceQuotaWiringTest.php +++ b/tests/Unit/Service/PlacementServiceQuotaWiringTest.php @@ -15,7 +15,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -34,89 +34,83 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class PlacementServiceQuotaWiringTest extends TestCase -{ - - /** @var WidgetPlacementMapper&MockObject */ - private $placementMapper; - - /** @var AdminSettingMapper&MockObject */ - private $settingMapper; - - private PlacementService $service; - - protected function setUp(): void - { - parent::setUp(); - - $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - /** @var DashboardMapper&MockObject $dashboardMapper */ - $dashboardMapper = $this->createMock(DashboardMapper::class); - - $quotaService = new QuotaService( - settingMapper: $this->settingMapper, - dashboardMapper: $dashboardMapper, - placementMapper: $this->placementMapper, - ); - - $this->service = new PlacementService( - placementMapper: $this->placementMapper, - tileUpdater: $this->createMock(TileUpdater::class), - placementUpdater: $this->createMock(PlacementUpdater::class), - publicShareContext: null, - quotaService: $quotaService, - ); - }//end setUp() - - /** - * Wire the widget quota to `$limit`. - * - * @param int $limit The per-dashboard widget quota. - * - * @return void - */ - private function withWidgetLimit(int $limit): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) use ($limit) { - if ($k === AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD) { - return $limit; - } - - return $default; - } - ); - }//end withWidgetLimit() - - public function testAddWidgetThrowsAtQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - $this->placementMapper->expects($this->never())->method('insert'); - - $this->expectException(QuotaExceededException::class); - $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); - }//end testAddWidgetThrowsAtQuota() - - public function testAddTileThrowsAtQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - $this->placementMapper->expects($this->never())->method('insert'); - - $this->expectException(QuotaExceededException::class); - $this->service->addTileFromArray(dashboardId: 7, tileData: ['title' => 'X']); - }//end testAddTileThrowsAtQuota() - - public function testAddWidgetAllowedBelowQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(39); - $this->placementMapper->expects($this->once()) - ->method('insert') - ->willReturnArgument(0); - - $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); - }//end testAddWidgetAllowedBelowQuota() +class PlacementServiceQuotaWiringTest extends TestCase { + + /** @var WidgetPlacementMapper&MockObject */ + private $placementMapper; + + /** @var AdminSettingMapper&MockObject */ + private $settingMapper; + + private PlacementService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + /** @var DashboardMapper&MockObject $dashboardMapper */ + $dashboardMapper = $this->createMock(DashboardMapper::class); + + $quotaService = new QuotaService( + settingMapper: $this->settingMapper, + dashboardMapper: $dashboardMapper, + placementMapper: $this->placementMapper, + ); + + $this->service = new PlacementService( + placementMapper: $this->placementMapper, + tileUpdater: $this->createMock(TileUpdater::class), + placementUpdater: $this->createMock(PlacementUpdater::class), + publicShareContext: null, + quotaService: $quotaService, + ); + }//end setUp() + + /** + * Wire the widget quota to `$limit`. + * + * @param int $limit The per-dashboard widget quota. + * + * @return void + */ + private function withWidgetLimit(int $limit): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) use ($limit) { + if ($k === AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD) { + return $limit; + } + + return $default; + } + ); + }//end withWidgetLimit() + + public function testAddWidgetThrowsAtQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + $this->placementMapper->expects($this->never())->method('insert'); + + $this->expectException(QuotaExceededException::class); + $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); + }//end testAddWidgetThrowsAtQuota() + + public function testAddTileThrowsAtQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + $this->placementMapper->expects($this->never())->method('insert'); + + $this->expectException(QuotaExceededException::class); + $this->service->addTileFromArray(dashboardId: 7, tileData: ['title' => 'X']); + }//end testAddTileThrowsAtQuota() + + public function testAddWidgetAllowedBelowQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(39); + $this->placementMapper->expects($this->once()) + ->method('insert') + ->willReturnArgument(0); + + $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); + }//end testAddWidgetAllowedBelowQuota() }//end class diff --git a/tests/Unit/Service/PublicShareContextTest.php b/tests/Unit/Service/PublicShareContextTest.php index c34e61a47..59bbfeb06 100644 --- a/tests/Unit/Service/PublicShareContextTest.php +++ b/tests/Unit/Service/PublicShareContextTest.php @@ -27,41 +27,32 @@ use OCA\LaunchPad\Service\PublicShareContext; use PHPUnit\Framework\TestCase; -class PublicShareContextTest extends TestCase -{ - - - public function testDefaultsToNonBearer(): void - { - $ctx = new PublicShareContext(); - $this->assertFalse($ctx->isBearer()); - $this->assertNull($ctx->getToken()); - }//end testDefaultsToNonBearer() - - - public function testRequireMutablePassesByDefault(): void - { - $ctx = new PublicShareContext(); - $ctx->requireMutable(); - // No exception — control reaches here. - $this->assertTrue(true); - }//end testRequireMutablePassesByDefault() - - - public function testMarkBearerFlipsFlagAndStoresToken(): void - { - $ctx = new PublicShareContext(); - $ctx->markBearer(token: 'tok_abc123'); - $this->assertTrue($ctx->isBearer()); - $this->assertSame('tok_abc123', $ctx->getToken()); - }//end testMarkBearerFlipsFlagAndStoresToken() - - - public function testRequireMutableThrowsAfterMarkBearer(): void - { - $ctx = new PublicShareContext(); - $ctx->markBearer(token: 'tok_xyz'); - $this->expectException(ShareReadOnlyException::class); - $ctx->requireMutable(); - }//end testRequireMutableThrowsAfterMarkBearer() +class PublicShareContextTest extends TestCase { + + public function testDefaultsToNonBearer(): void { + $ctx = new PublicShareContext(); + $this->assertFalse($ctx->isBearer()); + $this->assertNull($ctx->getToken()); + }//end testDefaultsToNonBearer() + + public function testRequireMutablePassesByDefault(): void { + $ctx = new PublicShareContext(); + $ctx->requireMutable(); + // No exception — control reaches here. + $this->assertTrue(true); + }//end testRequireMutablePassesByDefault() + + public function testMarkBearerFlipsFlagAndStoresToken(): void { + $ctx = new PublicShareContext(); + $ctx->markBearer(token: 'tok_abc123'); + $this->assertTrue($ctx->isBearer()); + $this->assertSame('tok_abc123', $ctx->getToken()); + }//end testMarkBearerFlipsFlagAndStoresToken() + + public function testRequireMutableThrowsAfterMarkBearer(): void { + $ctx = new PublicShareContext(); + $ctx->markBearer(token: 'tok_xyz'); + $this->expectException(ShareReadOnlyException::class); + $ctx->requireMutable(); + }//end testRequireMutableThrowsAfterMarkBearer() }//end class diff --git a/tests/Unit/Service/PublicShareServiceTest.php b/tests/Unit/Service/PublicShareServiceTest.php index 718c4d8ca..7a535bdaf 100644 --- a/tests/Unit/Service/PublicShareServiceTest.php +++ b/tests/Unit/Service/PublicShareServiceTest.php @@ -26,6 +26,7 @@ use OCA\LaunchPad\Db\DashboardMapper; use OCA\LaunchPad\Db\PublicShare; use OCA\LaunchPad\Db\PublicShareMapper; +use OCA\LaunchPad\Db\WidgetPlacementMapper; use OCA\LaunchPad\Exception\ShareNotFoundException; use OCA\LaunchPad\Exception\SharePasswordRequiredException; use OCA\LaunchPad\Service\PublicShareService; @@ -39,268 +40,262 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class PublicShareServiceTest extends TestCase -{ - - /** @var PublicShareMapper&MockObject */ - private $shareMapper; +class PublicShareServiceTest extends TestCase { - /** @var DashboardMapper&MockObject */ - private $dashMapper; + /** @var PublicShareMapper&MockObject */ + private $shareMapper; - /** @var IGroupManager&MockObject */ - private $groupManager; - - /** @var IHasher&MockObject */ - private $hasher; - - /** @var ISecureRandom&MockObject */ - private $secureRandom; + /** @var DashboardMapper&MockObject */ + private $dashMapper; - /** @var IThrottler&MockObject */ - private $throttler; - - /** @var LoggerInterface&MockObject */ - private $logger; - - private PublicShareService $service; - - protected function setUp(): void - { - $this->shareMapper = $this->createMock(PublicShareMapper::class); - $this->dashMapper = $this->createMock(DashboardMapper::class); - $this->groupManager = $this->createMock(IGroupManager::class); - $this->hasher = $this->createMock(IHasher::class); - $this->secureRandom = $this->createMock(ISecureRandom::class); - $this->throttler = $this->createMock(IThrottler::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->service = new PublicShareService( - shareMapper: $this->shareMapper, - dashMapper: $this->dashMapper, - groupManager: $this->groupManager, - hasher: $this->hasher, - secureRandom: $this->secureRandom, - throttler: $this->throttler, - logger: $this->logger, - ); - } - - // ------------------------------------------------------------------------- - // createPublicShare - // ------------------------------------------------------------------------- - - public function testCreateShareOwnerCanCreate(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - $this->secureRandom->method('generate')->willReturn(str_repeat('a', 64)); - - $saved = new PublicShare(); - $saved->setToken(str_repeat('a', 64)); - $this->shareMapper->method('insert')->willReturn($saved); - - $result = $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'alice' - ); - - $this->assertInstanceOf(PublicShare::class, $result); - } - - public function testCreateShareNonOwnerThrowsForbidden(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - - $this->expectException(OCSForbiddenException::class); - - $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'bob' - ); - } - - public function testCreateShareAdminCanCreate(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(true); - $this->secureRandom->method('generate')->willReturn(str_repeat('x', 64)); - - $saved = new PublicShare(); - $saved->setToken(str_repeat('x', 64)); - $this->shareMapper->method('insert')->willReturn($saved); - - $result = $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'admin' - ); - - $this->assertInstanceOf(PublicShare::class, $result); - } - - public function testCreateShareHashesPassword(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - $this->secureRandom->method('generate')->willReturn(str_repeat('t', 64)); - - $this->hasher - ->expects($this->once()) - ->method('hash') - ->with('SecurePass123!') - ->willReturn('$2y$hashed'); - - $saved = new PublicShare(); - $saved->setPasswordHash('$2y$hashed'); - $this->shareMapper->method('insert')->willReturn($saved); - - $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'alice', - password: 'SecurePass123!' - ); - } - - // ------------------------------------------------------------------------- - // renderShareContent - // ------------------------------------------------------------------------- - - public function testRenderInvalidTokenThrowsNotFound(): void - { - $this->shareMapper - ->method('findByToken') - ->willThrowException(new DoesNotExistException('not found')); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'invalid', ip: '127.0.0.1'); - } - - public function testRenderRevokedShareThrowsNotFound(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt('2026-01-01 00:00:00'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'tok', ip: '127.0.0.1'); - } - - public function testRenderExpiredShareThrowsNotFound(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - // Past date. - $share->setExpiresAt('2020-01-01 00:00:00'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'tok', ip: '127.0.0.1'); - } - - public function testRenderPasswordProtectedWithoutPasswordThrowsRequired(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - $share->setDashboardUuid('some-uuid'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(SharePasswordRequiredException::class); - - $this->service->renderShareContent(token: 'tok', ip: '127.0.0.1'); - } - - public function testRenderValidTokenWithoutPasswordSucceeds(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash(null); - $share->setDashboardUuid('some-uuid'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->shareMapper->method('incrementViewCount'); - - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - - $result = $this->service->renderShareContent(token: 'tok', ip: '127.0.0.1'); - - $this->assertArrayHasKey('share', $result); - $this->assertArrayHasKey('dashboard', $result); - } - - // ------------------------------------------------------------------------- - // unlockShare - // ------------------------------------------------------------------------- - - public function testUnlockCorrectPasswordReturnsTrue(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->hasher->method('verify')->willReturn(true); - - $result = $this->service->unlockShare( - token: 'tok', - password: 'SecurePass123!', - ip: '127.0.0.1' - ); - - $this->assertTrue($result); - } - - public function testUnlockWrongPasswordReturnsFalseAndRegistersAttempt(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->hasher->method('verify')->willReturn(false); - - $this->throttler - ->expects($this->once()) - ->method('registerAttempt') - ->with(PublicShareService::ACTION_SHARE_PASSWORD, '127.0.0.1'); - - $result = $this->service->unlockShare( - token: 'tok', - password: 'WrongPassword', - ip: '127.0.0.1' - ); - - $this->assertFalse($result); - } + /** @var IGroupManager&MockObject */ + private $groupManager; + + /** @var IHasher&MockObject */ + private $hasher; + + /** @var ISecureRandom&MockObject */ + private $secureRandom; + + /** @var IThrottler&MockObject */ + private $throttler; + + /** @var LoggerInterface&MockObject */ + private $logger; + + /** @var WidgetPlacementMapper&MockObject */ + private $placementMapper; + + private PublicShareService $service; + + protected function setUp(): void { + $this->shareMapper = $this->createMock(PublicShareMapper::class); + $this->dashMapper = $this->createMock(DashboardMapper::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->hasher = $this->createMock(IHasher::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + $this->throttler = $this->createMock(IThrottler::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + + $this->service = new PublicShareService( + shareMapper: $this->shareMapper, + dashMapper: $this->dashMapper, + groupManager: $this->groupManager, + hasher: $this->hasher, + secureRandom: $this->secureRandom, + throttler: $this->throttler, + logger: $this->logger, + placementMapper: $this->placementMapper, + ); + } + + // ------------------------------------------------------------------------- + // createPublicShare + // ------------------------------------------------------------------------- + + public function testCreateShareOwnerCanCreate(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->secureRandom->method('generate')->willReturn(str_repeat('a', 64)); + + $saved = new PublicShare(); + $saved->setToken(str_repeat('a', 64)); + $this->shareMapper->method('insert')->willReturn($saved); + + $result = $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'alice' + ); + + $this->assertInstanceOf(PublicShare::class, $result); + } + + public function testCreateShareNonOwnerThrowsForbidden(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + + $this->expectException(OCSForbiddenException::class); + + $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'bob' + ); + } + + public function testCreateShareAdminCanCreate(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->secureRandom->method('generate')->willReturn(str_repeat('x', 64)); + + $saved = new PublicShare(); + $saved->setToken(str_repeat('x', 64)); + $this->shareMapper->method('insert')->willReturn($saved); + + $result = $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'admin' + ); + + $this->assertInstanceOf(PublicShare::class, $result); + } + + public function testCreateShareHashesPassword(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->secureRandom->method('generate')->willReturn(str_repeat('t', 64)); + + $this->hasher + ->expects($this->once()) + ->method('hash') + ->with('SecurePass123!') + ->willReturn('$2y$hashed'); + + $saved = new PublicShare(); + $saved->setPasswordHash('$2y$hashed'); + $this->shareMapper->method('insert')->willReturn($saved); + + $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'alice', + password: 'SecurePass123!' + ); + } + + // ------------------------------------------------------------------------- + // renderShareContent + // ------------------------------------------------------------------------- + + public function testRenderInvalidTokenThrowsNotFound(): void { + $this->shareMapper + ->method('findByToken') + ->willThrowException(new DoesNotExistException('not found')); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'invalid', ipAddress: '127.0.0.1'); + } + + public function testRenderRevokedShareThrowsNotFound(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt('2026-01-01 00:00:00'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderExpiredShareThrowsNotFound(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + // Past date. + $share->setExpiresAt('2020-01-01 00:00:00'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderPasswordProtectedWithoutPasswordThrowsRequired(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + $share->setDashboardUuid('some-uuid'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(SharePasswordRequiredException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderValidTokenWithoutPasswordSucceeds(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash(null); + $share->setDashboardUuid('some-uuid'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->shareMapper->method('incrementViewCount'); + + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->placementMapper->method('findByDashboardId')->willReturn([]); + + $result = $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + + $this->assertArrayHasKey('share', $result); + $this->assertArrayHasKey('dashboard', $result); + $this->assertArrayHasKey('placements', $result); + } + + // ------------------------------------------------------------------------- + // unlockShare + // ------------------------------------------------------------------------- + + public function testUnlockCorrectPasswordReturnsTrue(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->hasher->method('verify')->willReturn(true); + + $result = $this->service->unlockShare( + token: 'tok', + password: 'SecurePass123!', + ipAddress: '127.0.0.1' + ); + + $this->assertTrue($result); + } + + public function testUnlockWrongPasswordReturnsFalseAndRegistersAttempt(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->hasher->method('verify')->willReturn(false); + + $this->throttler + ->expects($this->once()) + ->method('registerAttempt') + ->with(PublicShareService::ACTION_SHARE_PASSWORD, '127.0.0.1'); + + $result = $this->service->unlockShare( + token: 'tok', + password: 'WrongPassword', + ipAddress: '127.0.0.1' + ); + + $this->assertFalse($result); + } }//end class diff --git a/tests/Unit/Service/QuotaServiceTest.php b/tests/Unit/Service/QuotaServiceTest.php index 8fbef0f13..12a4f93e5 100644 --- a/tests/Unit/Service/QuotaServiceTest.php +++ b/tests/Unit/Service/QuotaServiceTest.php @@ -27,314 +27,295 @@ use OCA\LaunchPad\Service\QuotaService; use PHPUnit\Framework\TestCase; -class QuotaServiceTest extends TestCase -{ - - private QuotaService $service; - - private AdminSettingMapper $settingMapper; - - private DashboardMapper $dashboardMapper; - - private WidgetPlacementMapper $placementMapper; - - protected function setUp(): void - { - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - $this->dashboardMapper = $this->createMock(DashboardMapper::class); - $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); - $this->service = new QuotaService( - settingMapper: $this->settingMapper, - dashboardMapper: $this->dashboardMapper, - placementMapper: $this->placementMapper, - ); - }//end setUp() - - /** - * Wire the setting mapper's getValue() for a given numeric quota + - * allow-multiple flag. - * - * @param string $key The numeric quota key. - * @param int $value The numeric quota value. - * @param bool $allowMultiple The allow_multiple_dashboards flag. - * - * @return void - */ - private function withSettings(string $key, int $value, bool $allowMultiple=true): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) use ($key, $value, $allowMultiple) { - if ($k === $key) { - return $value; - } - - if ($k === AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS) { - return $allowMultiple; - } - - return $default; - } - ); - }//end withSettings() - - // ----- REQ-QUOTA-002: dashboard count enforcement ----- - - public function testDashboardCreateAllowedBelowLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); - - // No exception => allowed. - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateAllowedBelowLimit() - - public function testDashboardCreateBlockedAtLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(QuotaExceededException::QUOTA_DASHBOARDS, $e->getQuota()); - $this->assertSame(5, $e->getLimit()); - $this->assertSame(5, $e->getCurrent()); - $this->assertSame(409, $e->getHttpStatus()); - $this->assertSame( - [ - 'error' => 'quota_exceeded', - 'quota' => 'dashboards', - 'limit' => 5, - 'current' => 5, - ], - $e->toResponseBody() - ); - }//end try - }//end testDashboardCreateBlockedAtLimit() - - public function testDashboardCreateUnlimitedWhenZero(): void - { - // REQ-QUOTA-001 — 0 means unlimited; count never queried. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 0); - $this->dashboardMapper->expects($this->never()) - ->method('countPersonalByUserId'); - - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateUnlimitedWhenZero() - - public function testDashboardCreateLiveRecountAfterDelete(): void - { - // REQ-QUOTA-002 — count is computed live, so dropping below the - // limit immediately permits a new create. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); - - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateLiveRecountAfterDelete() - - public function testGrandfatheringBlocksWhenOverLoweredLimit(): void - { - // REQ-QUOTA-005 — usage (8) exceeds a lowered limit (5): new - // creation blocked, exception carries the real over-quota count. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(8); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(5, $e->getLimit()); - $this->assertSame(8, $e->getCurrent()); - } - }//end testGrandfatheringBlocksWhenOverLoweredLimit() - - // ----- REQ-QUOTA-002 / D6: most-restrictive-wins ----- - - public function testAllowMultipleFalseGivesEffectiveLimitOne(): void - { - // REQ-QUOTA-002 — allow_multiple_dashboards = false ⇒ effective - // limit 1 regardless of the numeric setting (5). - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5, allowMultiple: false); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(1, $e->getLimit()); - $this->assertSame(1, $e->getCurrent()); - } - }//end testAllowMultipleFalseGivesEffectiveLimitOne() - - public function testNumericQuotaDoesNotLoosenBooleanRestriction(): void - { - // REQ-QUOTA-002 — even with a generous numeric quota, the boolean - // off-switch keeps the effective limit at 1, so a user with 1 - // dashboard is blocked from a second. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 100, allowMultiple: false); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'alice'); - }//end testNumericQuotaDoesNotLoosenBooleanRestriction() - - // ----- REQ-QUOTA-003: widget count enforcement ----- - - public function testWidgetAddAllowedBelowLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->method('countByDashboardId')->willReturn(39); - - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->addToAssertionCount(1); - }//end testWidgetAddAllowedBelowLimit() - - public function testWidgetAddBlockedAtLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - - try { - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(QuotaExceededException::QUOTA_WIDGETS, $e->getQuota()); - $this->assertSame(40, $e->getLimit()); - $this->assertSame(40, $e->getCurrent()); - $this->assertSame('widgets', $e->toResponseBody()['quota']); - }//end try - }//end testWidgetAddBlockedAtLimit() - - public function testWidgetAddUnlimitedWhenZero(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 0); - $this->placementMapper->expects($this->never()) - ->method('countByDashboardId'); - - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->addToAssertionCount(1); - }//end testWidgetAddUnlimitedWhenZero() - - // ----- REQ-QUOTA-004: provisioning bypass ----- - - public function testProvisioningBypassesDashboardQuota(): void - { - // REQ-QUOTA-004 — inside runProvisioning(), an over-quota user's - // creation is NOT blocked (template rollout). Count never queried. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->expects($this->never()) - ->method('countPersonalByUserId'); - - $ran = $this->service->runProvisioning( - function () { - $this->service->assertCanCreateDashboard(userId: 'alice'); - return 'rolled-out'; - } - ); - - $this->assertSame('rolled-out', $ran); - }//end testProvisioningBypassesDashboardQuota() - - public function testProvisioningBypassesWidgetQuota(): void - { - // REQ-QUOTA-004 — compulsory-widget push bypasses the widget quota. - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->expects($this->never()) - ->method('countByDashboardId'); - - $this->service->runProvisioning( - function () { - $this->service->assertCanAddPlacement(dashboardId: 7); - } - ); - $this->addToAssertionCount(1); - }//end testProvisioningBypassesWidgetQuota() - - public function testProvisioningFlagResetsAfterCallEvenOnThrow(): void - { - // REQ-QUOTA-004 — a throwing provisioning call must NOT leave the - // service permanently bypassed: the next user-initiated assert is - // enforced again. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - try { - $this->service->runProvisioning( - function () { - throw new \RuntimeException('boom'); - } - ); - } catch (\RuntimeException) { - // expected - } - - $this->assertFalse($this->service->isProvisioning()); - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'alice'); - }//end testProvisioningFlagResetsAfterCallEvenOnThrow() - - public function testAdminBoundByQuotaOutsideProvisioning(): void - { - // REQ-QUOTA-004 — an admin creating their own personal dashboard - // through the normal flow (no provisioning wrapper) is still bound. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'carol'); - }//end testAdminBoundByQuotaOutsideProvisioning() - - // ----- REQ-QUOTA-006: quota status envelope ----- - - public function testGetQuotaStatusEnvelopeShape(): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) { - return match ($k) { - AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, - AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 40, - AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => true, - default => $default, - }; - } - ); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(3); - - $status = $this->service->getQuotaStatus(userId: 'alice'); - - $this->assertSame( - [ - 'maxDashboards' => 5, - 'dashboardsUsed' => 3, - 'maxWidgetsPerDashboard' => 40, - ], - $status - ); - }//end testGetQuotaStatusEnvelopeShape() - - public function testGetQuotaStatusReflectsEffectiveLimit(): void - { - // REQ-QUOTA-006 / D6 — the envelope surfaces the EFFECTIVE limit, so - // allow_multiple_dashboards = false shows maxDashboards = 1. - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) { - return match ($k) { - AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, - AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 0, - AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => false, - default => $default, - }; - } - ); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - $status = $this->service->getQuotaStatus(userId: 'alice'); - - $this->assertSame(1, $status['maxDashboards']); - $this->assertSame(0, $status['maxWidgetsPerDashboard']); - }//end testGetQuotaStatusReflectsEffectiveLimit() +class QuotaServiceTest extends TestCase { + + private QuotaService $service; + + private AdminSettingMapper $settingMapper; + + private DashboardMapper $dashboardMapper; + + private WidgetPlacementMapper $placementMapper; + + protected function setUp(): void { + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + $this->dashboardMapper = $this->createMock(DashboardMapper::class); + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + $this->service = new QuotaService( + settingMapper: $this->settingMapper, + dashboardMapper: $this->dashboardMapper, + placementMapper: $this->placementMapper, + ); + }//end setUp() + + /** + * Wire the setting mapper's getValue() for a given numeric quota + + * allow-multiple flag. + * + * @param string $key The numeric quota key. + * @param int $value The numeric quota value. + * @param bool $allowMultiple The allow_multiple_dashboards flag. + * + * @return void + */ + private function withSettings(string $key, int $value, bool $allowMultiple = true): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) use ($key, $value, $allowMultiple) { + if ($k === $key) { + return $value; + } + + if ($k === AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS) { + return $allowMultiple; + } + + return $default; + } + ); + }//end withSettings() + + // ----- REQ-QUOTA-002: dashboard count enforcement ----- + + public function testDashboardCreateAllowedBelowLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); + + // No exception => allowed. + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateAllowedBelowLimit() + + public function testDashboardCreateBlockedAtLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(QuotaExceededException::QUOTA_DASHBOARDS, $e->getQuota()); + $this->assertSame(5, $e->getLimit()); + $this->assertSame(5, $e->getCurrent()); + $this->assertSame(409, $e->getHttpStatus()); + $this->assertSame( + [ + 'error' => 'quota_exceeded', + 'quota' => 'dashboards', + 'limit' => 5, + 'current' => 5, + ], + $e->toResponseBody() + ); + }//end try + }//end testDashboardCreateBlockedAtLimit() + + public function testDashboardCreateUnlimitedWhenZero(): void { + // REQ-QUOTA-001 — 0 means unlimited; count never queried. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 0); + $this->dashboardMapper->expects($this->never()) + ->method('countPersonalByUserId'); + + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateUnlimitedWhenZero() + + public function testDashboardCreateLiveRecountAfterDelete(): void { + // REQ-QUOTA-002 — count is computed live, so dropping below the + // limit immediately permits a new create. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); + + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateLiveRecountAfterDelete() + + public function testGrandfatheringBlocksWhenOverLoweredLimit(): void { + // REQ-QUOTA-005 — usage (8) exceeds a lowered limit (5): new + // creation blocked, exception carries the real over-quota count. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(8); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(5, $e->getLimit()); + $this->assertSame(8, $e->getCurrent()); + } + }//end testGrandfatheringBlocksWhenOverLoweredLimit() + + // ----- REQ-QUOTA-002 / D6: most-restrictive-wins ----- + + public function testAllowMultipleFalseGivesEffectiveLimitOne(): void { + // REQ-QUOTA-002 — allow_multiple_dashboards = false ⇒ effective + // limit 1 regardless of the numeric setting (5). + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5, allowMultiple: false); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(1, $e->getLimit()); + $this->assertSame(1, $e->getCurrent()); + } + }//end testAllowMultipleFalseGivesEffectiveLimitOne() + + public function testNumericQuotaDoesNotLoosenBooleanRestriction(): void { + // REQ-QUOTA-002 — even with a generous numeric quota, the boolean + // off-switch keeps the effective limit at 1, so a user with 1 + // dashboard is blocked from a second. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 100, allowMultiple: false); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'alice'); + }//end testNumericQuotaDoesNotLoosenBooleanRestriction() + + // ----- REQ-QUOTA-003: widget count enforcement ----- + + public function testWidgetAddAllowedBelowLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->method('countByDashboardId')->willReturn(39); + + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->addToAssertionCount(1); + }//end testWidgetAddAllowedBelowLimit() + + public function testWidgetAddBlockedAtLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + + try { + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(QuotaExceededException::QUOTA_WIDGETS, $e->getQuota()); + $this->assertSame(40, $e->getLimit()); + $this->assertSame(40, $e->getCurrent()); + $this->assertSame('widgets', $e->toResponseBody()['quota']); + }//end try + }//end testWidgetAddBlockedAtLimit() + + public function testWidgetAddUnlimitedWhenZero(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 0); + $this->placementMapper->expects($this->never()) + ->method('countByDashboardId'); + + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->addToAssertionCount(1); + }//end testWidgetAddUnlimitedWhenZero() + + // ----- REQ-QUOTA-004: provisioning bypass ----- + + public function testProvisioningBypassesDashboardQuota(): void { + // REQ-QUOTA-004 — inside runProvisioning(), an over-quota user's + // creation is NOT blocked (template rollout). Count never queried. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->expects($this->never()) + ->method('countPersonalByUserId'); + + $ran = $this->service->runProvisioning( + function () { + $this->service->assertCanCreateDashboard(userId: 'alice'); + return 'rolled-out'; + } + ); + + $this->assertSame('rolled-out', $ran); + }//end testProvisioningBypassesDashboardQuota() + + public function testProvisioningBypassesWidgetQuota(): void { + // REQ-QUOTA-004 — compulsory-widget push bypasses the widget quota. + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->expects($this->never()) + ->method('countByDashboardId'); + + $this->service->runProvisioning( + function () { + $this->service->assertCanAddPlacement(dashboardId: 7); + } + ); + $this->addToAssertionCount(1); + }//end testProvisioningBypassesWidgetQuota() + + public function testProvisioningFlagResetsAfterCallEvenOnThrow(): void { + // REQ-QUOTA-004 — a throwing provisioning call must NOT leave the + // service permanently bypassed: the next user-initiated assert is + // enforced again. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + try { + $this->service->runProvisioning( + function () { + throw new \RuntimeException('boom'); + } + ); + } catch (\RuntimeException) { + // expected + } + + $this->assertFalse($this->service->isProvisioning()); + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'alice'); + }//end testProvisioningFlagResetsAfterCallEvenOnThrow() + + public function testAdminBoundByQuotaOutsideProvisioning(): void { + // REQ-QUOTA-004 — an admin creating their own personal dashboard + // through the normal flow (no provisioning wrapper) is still bound. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'carol'); + }//end testAdminBoundByQuotaOutsideProvisioning() + + // ----- REQ-QUOTA-006: quota status envelope ----- + + public function testGetQuotaStatusEnvelopeShape(): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) { + return match ($k) { + AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, + AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 40, + AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => true, + default => $default, + }; + } + ); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(3); + + $status = $this->service->getQuotaStatus(userId: 'alice'); + + $this->assertSame( + [ + 'maxDashboards' => 5, + 'dashboardsUsed' => 3, + 'maxWidgetsPerDashboard' => 40, + ], + $status + ); + }//end testGetQuotaStatusEnvelopeShape() + + public function testGetQuotaStatusReflectsEffectiveLimit(): void { + // REQ-QUOTA-006 / D6 — the envelope surfaces the EFFECTIVE limit, so + // allow_multiple_dashboards = false shows maxDashboards = 1. + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) { + return match ($k) { + AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, + AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 0, + AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => false, + default => $default, + }; + } + ); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + $status = $this->service->getQuotaStatus(userId: 'alice'); + + $this->assertSame(1, $status['maxDashboards']); + $this->assertSame(0, $status['maxWidgetsPerDashboard']); + }//end testGetQuotaStatusReflectsEffectiveLimit() }//end class diff --git a/tests/Unit/Service/ReactionServiceTest.php b/tests/Unit/Service/ReactionServiceTest.php index 46fb47f6e..53b95bbfc 100644 --- a/tests/Unit/Service/ReactionServiceTest.php +++ b/tests/Unit/Service/ReactionServiceTest.php @@ -14,7 +14,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -28,9 +28,8 @@ use OCA\LaunchPad\Db\DashboardReactionMapper; use OCA\LaunchPad\Service\PermissionDeniedException; use OCA\LaunchPad\Service\PermissionService; -use OCA\LaunchPad\Service\ReactionService; use OCA\LaunchPad\Service\ReactionsDisabledException; -use OCP\AppFramework\Db\DoesNotExistException; +use OCA\LaunchPad\Service\ReactionService; use OCP\DB\Exception as DbException; use OCP\IAppConfig; use OCP\IUser; @@ -41,348 +40,329 @@ /** * Tests for ReactionService. */ -class ReactionServiceTest extends TestCase -{ - private DashboardReactionMapper&MockObject $reactionMapper; - private DashboardMapper&MockObject $dashboardMapper; - private PermissionService&MockObject $permissionService; - private IAppConfig&MockObject $appConfig; - private IUserManager&MockObject $userManager; - private ReactionService $service; - - protected function setUp(): void - { - $this->reactionMapper = $this->createMock(originalClassName: DashboardReactionMapper::class); - $this->dashboardMapper = $this->createMock(originalClassName: DashboardMapper::class); - $this->permissionService = $this->createMock(originalClassName: PermissionService::class); - $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - - $this->service = new ReactionService( - reactionMapper: $this->reactionMapper, - dashboardMapper: $this->dashboardMapper, - permissionService: $this->permissionService, - appConfig: $this->appConfig, - userManager: $this->userManager, - ); - } - - private function makeDashboard(?int $perDashFlag, int $id=1, string $uuid='dash-123'): Dashboard - { - $dashboard = new Dashboard(); - // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - // Entity __call uses $args[0] — named args break the magic forwarding. - $dashboard->setId($id); - $dashboard->setUuid($uuid); - $dashboard->setReactionsEnabled($perDashFlag); - // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - return $dashboard; - } - - /** - * REQ-RXN-006 — null/1/0 tri-state resolution. - */ - public function testIsReactionsEnabledTriState(): void - { - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 1))); - $this->assertFalse($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 0))); - $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: null))); - } - - /** - * REQ-RXN-007 scenario "Admin updates the allowed emoji list". - */ - public function testValidateEmojiRejectsNonWhitelisted(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); - - $this->expectException(InvalidArgumentException::class); - $this->service->validateEmoji(emoji: '🚀'); - } - - public function testValidateEmojiAcceptsWhitelisted(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); - - $this->service->validateEmoji(emoji: '❤️'); - $this->expectNotToPerformAssertions(); - } - - public function testValidateEmojiRejectsEmpty(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍"]'); - $this->expectException(InvalidArgumentException::class); - $this->service->validateEmoji(emoji: ''); - } - - /** - * REQ-RXN-007 scenario "Default allowed emoji list". - */ - public function testGetAllowedEmojisDefaults(): void - { - $this->appConfig->method('getValueString')->willReturn(''); - $this->assertSame( - ReactionService::DEFAULT_ALLOWED_EMOJIS, - $this->service->getAllowedEmojis() - ); - } - - public function testGetAllowedEmojisFallsBackOnCorruptJson(): void - { - $this->appConfig->method('getValueString')->willReturn('not-json'); - $this->assertSame( - ReactionService::DEFAULT_ALLOWED_EMOJIS, - $this->service->getAllowedEmojis() - ); - } - - /** - * REQ-RXN-007 scenario "Empty emoji in whitelist" — admin-set - * empty list returned as-is so validateEmoji rejects everything. - */ - public function testGetAllowedEmojisEmptyAdminListSurfacesAsEmpty(): void - { - $this->appConfig->method('getValueString')->willReturn('[]'); - $this->assertSame([], $this->service->getAllowedEmojis()); - } - - /** - * REQ-RXN-008 — non-VIEW user rejected with PermissionDeniedException. - */ - public function testAddReactionPermissionDenied(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(false); - - $this->expectException(PermissionDeniedException::class); - $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'bob', - emoji: '👍' - ); - } - - /** - * REQ-RXN-005 — global off + per-dashboard null returns - * ReactionsDisabledException on POST. - */ - public function testAddReactionDisabledThrows(): void - { - $dash = $this->makeDashboard(perDashFlag: null); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(false); - - $this->expectException(ReactionsDisabledException::class); - $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - } - - /** - * REQ-RXN-001 scenario "User re-posts the same emoji" — duplicate - * insert (unique constraint) is swallowed; summary returned as if - * the row already existed. - */ - public function testAddReactionIdempotentOnUniqueConstraint(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueString')->willReturn('["👍"]'); - $this->appConfig->method('getValueBool')->willReturn(true); - - $duplicate = $this->createMock(originalClassName: DbException::class); - $duplicate->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); - $this->reactionMapper->method('addReaction')->willThrowException($duplicate); - - $this->reactionMapper->method('countByEmoji')->willReturn(['👍' => 1]); - $existing = new DashboardReaction(); - $existing->setEmoji('👍'); - $this->reactionMapper->method('findByUser')->willReturn([$existing]); - - $summary = $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - - $this->assertTrue($summary['enabled']); - $this->assertSame(['👍'], $summary['mine']); - $this->assertSame(['👍' => 1], (array) $summary['counts']); - } - - /** - * REQ-RXN-003 scenario "Reactions disabled on dashboard" — GET - * returns the empty-shape summary regardless of stored rows. - */ - public function testGetReactionsSummaryDisabledShape(): void - { - $dash = $this->makeDashboard(perDashFlag: 0); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->reactionMapper->expects($this->never())->method('countByEmoji'); - - $summary = $this->service->getReactionsSummary( - dashboardUuid: 'dash-123', - userId: 'alice' - ); - - $this->assertFalse($summary['enabled']); - $this->assertSame([], (array) $summary['counts']); - $this->assertSame([], $summary['mine']); - } - - /** - * REQ-RXN-003 scenario "User retrieves reactions on a dashboard - * they can view" — counts + mine populated from mapper. - */ - public function testGetReactionsSummaryEnabledShape(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->reactionMapper->method('countByEmoji')->willReturn([ - '👍' => 3, - '❤️' => 1, - '🎉' => 2, - ]); - - $a = new DashboardReaction(); - $a->setEmoji('👍'); - $b = new DashboardReaction(); - $b->setEmoji('🎉'); - $this->reactionMapper->method('findByUser')->willReturn([$a, $b]); - - $summary = $this->service->getReactionsSummary( - dashboardUuid: 'dash-123', - userId: 'alice' - ); - - $this->assertTrue($summary['enabled']); - $this->assertSame( - ['👍' => 3, '❤️' => 1, '🎉' => 2], - (array) $summary['counts'] - ); - $this->assertSame(['👍', '🎉'], $summary['mine']); - } - - /** - * REQ-RXN-002 — DELETE delegates to mapper; idempotent return - * value bubbles up. - */ - public function testRemoveReactionDelegatesToMapper(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $this->reactionMapper->expects($this->once()) - ->method('removeReaction') - ->with( - $this->equalTo('dash-123'), - $this->equalTo('alice'), - $this->equalTo('👍'), - ) - ->willReturn(true); - - $result = $this->service->removeReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - - $this->assertTrue($result); - } - - /** - * REQ-RXN-009 — cascade delete delegates to mapper. - */ - public function testDeleteReactionsByDashboardDelegates(): void - { - $this->reactionMapper->expects($this->once()) - ->method('deleteByDashboardUuid') - ->with($this->equalTo('dash-123')) - ->willReturn(7); - - $this->assertSame( - 7, - $this->service->deleteReactionsByDashboard(dashboardUuid: 'dash-123') - ); - } - - /** - * REQ-RXN-004 — pagination cap + cursor advance. - */ - public function testGetReactorsByEmojiPagination(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $rows = []; - for ($i = 0; $i < ReactionService::REACTORS_PAGE_SIZE; $i++) { - $r = new DashboardReaction(); - $r->setUserId(sprintf('user%d', $i)); - $r->setEmoji('🎉'); - $rows[] = $r; - } - - $this->reactionMapper->method('findByEmoji')->willReturn($rows); - $this->reactionMapper->method('countReactorsByEmoji')->willReturn(150); - - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getDisplayName')->willReturn('User'); - $this->userManager->method('get')->willReturn($user); - - $page = $this->service->getReactorsByEmoji( - dashboardUuid: 'dash-123', - emoji: '🎉', - userId: 'alice', - cursor: null - ); - - $this->assertCount(ReactionService::REACTORS_PAGE_SIZE, $page['items']); - $this->assertSame('100', $page['nextCursor']); - $this->assertSame(150, $page['total']); - } - - /** - * Last page exposes nextCursor === null. - */ - public function testGetReactorsByEmojiLastPageNoCursor(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $r = new DashboardReaction(); - $r->setUserId('alice'); - $r->setEmoji('🎉'); - $this->reactionMapper->method('findByEmoji')->willReturn([$r]); - $this->reactionMapper->method('countReactorsByEmoji')->willReturn(1); - - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getDisplayName')->willReturn('Alice'); - $this->userManager->method('get')->willReturn($user); - - $page = $this->service->getReactorsByEmoji( - dashboardUuid: 'dash-123', - emoji: '🎉', - userId: 'alice', - cursor: null - ); - - $this->assertNull($page['nextCursor']); - $this->assertSame(1, $page['total']); - } +class ReactionServiceTest extends TestCase { + private DashboardReactionMapper&MockObject $reactionMapper; + private DashboardMapper&MockObject $dashboardMapper; + private PermissionService&MockObject $permissionService; + private IAppConfig&MockObject $appConfig; + private IUserManager&MockObject $userManager; + private ReactionService $service; + + protected function setUp(): void { + $this->reactionMapper = $this->createMock(originalClassName: DashboardReactionMapper::class); + $this->dashboardMapper = $this->createMock(originalClassName: DashboardMapper::class); + $this->permissionService = $this->createMock(originalClassName: PermissionService::class); + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + + $this->service = new ReactionService( + reactionMapper: $this->reactionMapper, + dashboardMapper: $this->dashboardMapper, + permissionService: $this->permissionService, + appConfig: $this->appConfig, + userManager: $this->userManager, + ); + } + + private function makeDashboard(?int $perDashFlag, int $id = 1, string $uuid = 'dash-123'): Dashboard { + $dashboard = new Dashboard(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + // Entity __call uses $args[0] — named args break the magic forwarding. + $dashboard->setId($id); + $dashboard->setUuid($uuid); + $dashboard->setReactionsEnabled($perDashFlag); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + return $dashboard; + } + + /** + * REQ-RXN-006 — null/1/0 tri-state resolution. + */ + public function testIsReactionsEnabledTriState(): void { + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 1))); + $this->assertFalse($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 0))); + $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: null))); + } + + /** + * REQ-RXN-007 scenario "Admin updates the allowed emoji list". + */ + public function testValidateEmojiRejectsNonWhitelisted(): void { + $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); + + $this->expectException(InvalidArgumentException::class); + $this->service->validateEmoji(emoji: '🚀'); + } + + public function testValidateEmojiAcceptsWhitelisted(): void { + $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); + + $this->service->validateEmoji(emoji: '❤️'); + $this->expectNotToPerformAssertions(); + } + + public function testValidateEmojiRejectsEmpty(): void { + $this->appConfig->method('getValueString')->willReturn('["👍"]'); + $this->expectException(InvalidArgumentException::class); + $this->service->validateEmoji(emoji: ''); + } + + /** + * REQ-RXN-007 scenario "Default allowed emoji list". + */ + public function testGetAllowedEmojisDefaults(): void { + $this->appConfig->method('getValueString')->willReturn(''); + $this->assertSame( + ReactionService::DEFAULT_ALLOWED_EMOJIS, + $this->service->getAllowedEmojis() + ); + } + + public function testGetAllowedEmojisFallsBackOnCorruptJson(): void { + $this->appConfig->method('getValueString')->willReturn('not-json'); + $this->assertSame( + ReactionService::DEFAULT_ALLOWED_EMOJIS, + $this->service->getAllowedEmojis() + ); + } + + /** + * REQ-RXN-007 scenario "Empty emoji in whitelist" — admin-set + * empty list returned as-is so validateEmoji rejects everything. + */ + public function testGetAllowedEmojisEmptyAdminListSurfacesAsEmpty(): void { + $this->appConfig->method('getValueString')->willReturn('[]'); + $this->assertSame([], $this->service->getAllowedEmojis()); + } + + /** + * REQ-RXN-008 — non-VIEW user rejected with PermissionDeniedException. + */ + public function testAddReactionPermissionDenied(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(false); + + $this->expectException(PermissionDeniedException::class); + $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'bob', + emoji: '👍' + ); + } + + /** + * REQ-RXN-005 — global off + per-dashboard null returns + * ReactionsDisabledException on POST. + */ + public function testAddReactionDisabledThrows(): void { + $dash = $this->makeDashboard(perDashFlag: null); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(false); + + $this->expectException(ReactionsDisabledException::class); + $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + } + + /** + * REQ-RXN-001 scenario "User re-posts the same emoji" — duplicate + * insert (unique constraint) is swallowed; summary returned as if + * the row already existed. + */ + public function testAddReactionIdempotentOnUniqueConstraint(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueString')->willReturn('["👍"]'); + $this->appConfig->method('getValueBool')->willReturn(true); + + $duplicate = $this->createMock(originalClassName: DbException::class); + $duplicate->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); + $this->reactionMapper->method('addReaction')->willThrowException($duplicate); + + $this->reactionMapper->method('countByEmoji')->willReturn(['👍' => 1]); + $existing = new DashboardReaction(); + $existing->setEmoji('👍'); + $this->reactionMapper->method('findByUser')->willReturn([$existing]); + + $summary = $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + + $this->assertTrue($summary['enabled']); + $this->assertSame(['👍'], $summary['mine']); + $this->assertSame(['👍' => 1], (array)$summary['counts']); + } + + /** + * REQ-RXN-003 scenario "Reactions disabled on dashboard" — GET + * returns the empty-shape summary regardless of stored rows. + */ + public function testGetReactionsSummaryDisabledShape(): void { + $dash = $this->makeDashboard(perDashFlag: 0); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->reactionMapper->expects($this->never())->method('countByEmoji'); + + $summary = $this->service->getReactionsSummary( + dashboardUuid: 'dash-123', + userId: 'alice' + ); + + $this->assertFalse($summary['enabled']); + $this->assertSame([], (array)$summary['counts']); + $this->assertSame([], $summary['mine']); + } + + /** + * REQ-RXN-003 scenario "User retrieves reactions on a dashboard + * they can view" — counts + mine populated from mapper. + */ + public function testGetReactionsSummaryEnabledShape(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->reactionMapper->method('countByEmoji')->willReturn([ + '👍' => 3, + '❤️' => 1, + '🎉' => 2, + ]); + + $a = new DashboardReaction(); + $a->setEmoji('👍'); + $b = new DashboardReaction(); + $b->setEmoji('🎉'); + $this->reactionMapper->method('findByUser')->willReturn([$a, $b]); + + $summary = $this->service->getReactionsSummary( + dashboardUuid: 'dash-123', + userId: 'alice' + ); + + $this->assertTrue($summary['enabled']); + $this->assertSame( + ['👍' => 3, '❤️' => 1, '🎉' => 2], + (array)$summary['counts'] + ); + $this->assertSame(['👍', '🎉'], $summary['mine']); + } + + /** + * REQ-RXN-002 — DELETE delegates to mapper; idempotent return + * value bubbles up. + */ + public function testRemoveReactionDelegatesToMapper(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $this->reactionMapper->expects($this->once()) + ->method('removeReaction') + ->with( + $this->equalTo('dash-123'), + $this->equalTo('alice'), + $this->equalTo('👍'), + ) + ->willReturn(true); + + $result = $this->service->removeReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + + $this->assertTrue($result); + } + + /** + * REQ-RXN-009 — cascade delete delegates to mapper. + */ + public function testDeleteReactionsByDashboardDelegates(): void { + $this->reactionMapper->expects($this->once()) + ->method('deleteByDashboardUuid') + ->with($this->equalTo('dash-123')) + ->willReturn(7); + + $this->assertSame( + 7, + $this->service->deleteReactionsByDashboard(dashboardUuid: 'dash-123') + ); + } + + /** + * REQ-RXN-004 — pagination cap + cursor advance. + */ + public function testGetReactorsByEmojiPagination(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $rows = []; + for ($i = 0; $i < ReactionService::REACTORS_PAGE_SIZE; $i++) { + $r = new DashboardReaction(); + $r->setUserId(sprintf('user%d', $i)); + $r->setEmoji('🎉'); + $rows[] = $r; + } + + $this->reactionMapper->method('findByEmoji')->willReturn($rows); + $this->reactionMapper->method('countReactorsByEmoji')->willReturn(150); + + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getDisplayName')->willReturn('User'); + $this->userManager->method('get')->willReturn($user); + + $page = $this->service->getReactorsByEmoji( + dashboardUuid: 'dash-123', + emoji: '🎉', + userId: 'alice', + cursor: null + ); + + $this->assertCount(ReactionService::REACTORS_PAGE_SIZE, $page['items']); + $this->assertSame('100', $page['nextCursor']); + $this->assertSame(150, $page['total']); + } + + /** + * Last page exposes nextCursor === null. + */ + public function testGetReactorsByEmojiLastPageNoCursor(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $r = new DashboardReaction(); + $r->setUserId('alice'); + $r->setEmoji('🎉'); + $this->reactionMapper->method('findByEmoji')->willReturn([$r]); + $this->reactionMapper->method('countReactorsByEmoji')->willReturn(1); + + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getDisplayName')->willReturn('Alice'); + $this->userManager->method('get')->willReturn($user); + + $page = $this->service->getReactorsByEmoji( + dashboardUuid: 'dash-123', + emoji: '🎉', + userId: 'alice', + cursor: null + ); + + $this->assertNull($page['nextCursor']); + $this->assertSame(1, $page['total']); + } } diff --git a/tests/Unit/Service/ResourceServeServiceTest.php b/tests/Unit/Service/ResourceServeServiceTest.php index b615ea470..791859a2b 100644 --- a/tests/Unit/Service/ResourceServeServiceTest.php +++ b/tests/Unit/Service/ResourceServeServiceTest.php @@ -13,7 +13,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2024 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -30,143 +30,129 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class ResourceServeServiceTest extends TestCase -{ - private ResourceServeService $service; - - /** @var IAppData&MockObject */ - private $appData; - - /** @var LoggerInterface&MockObject */ - private $logger; - - /** @var ISimpleFolder&MockObject */ - private $folder; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->folder = $this->createMock(ISimpleFolder::class); - - $this->service = new ResourceServeService( - appData: $this->appData, - logger: $this->logger, - ); - } - - public function testFindFileReturnsFileWhenPresent(): void - { - $file = $this->createMock(ISimpleFile::class); - $this->appData->method('getFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - $this->folder->method('getFile')->with('resource_abc.png')->willReturn($file); - - $this->assertSame($file, $this->service->findFile(filename: 'resource_abc.png')); - } - - public function testFindFileReturnsNullWhenFolderMissing(): void - { - $this->appData->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertNull($this->service->findFile(filename: 'whatever.png')); - } - - public function testFindFileReturnsNullWhenFileMissing(): void - { - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $this->assertNull($this->service->findFile(filename: 'gone.png')); - } - - public function testFindFileReturnsNullOnUnexpectedException(): void - { - $this->appData->method('getFolder') - ->willThrowException(new \RuntimeException('boom')); - $this->logger->expects($this->once())->method('warning'); - - $this->assertNull($this->service->findFile(filename: 'whatever.png')); - } - - public function testListFilesReturnsAllSimpleFiles(): void - { - $a = $this->createMock(ISimpleFile::class); - $b = $this->createMock(ISimpleFile::class); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getDirectoryListing')->willReturn([$a, $b]); - - $this->assertSame([$a, $b], $this->service->listFiles()); - } - - public function testListFilesReturnsEmptyArrayWhenFolderMissing(): void - { - $this->appData->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertSame([], $this->service->listFiles()); - } - - public function testListFilesReturnsEmptyArrayOnUnexpectedException(): void - { - $this->appData->method('getFolder') - ->willThrowException(new \RuntimeException('disk failure')); - $this->logger->expects($this->once())->method('warning'); - - $this->assertSame([], $this->service->listFiles()); - } - - public function testListFilesSkipsNonFileEntries(): void - { - $a = $this->createMock(ISimpleFile::class); - $bogus = new \stdClass(); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getDirectoryListing')->willReturn([$a, $bogus]); - - $this->assertSame([$a], $this->service->listFiles()); - } - - /** - * @dataProvider contentTypeProvider - */ - public function testContentTypeForFilename(string $filename, string $expected): void - { - $this->assertSame($expected, $this->service->contentTypeForFilename(filename: $filename)); - } - - /** - * @return array> - */ - public static function contentTypeProvider(): array - { - return [ - 'png lowercase' => ['resource_a.png', 'image/png'], - 'jpg lowercase' => ['resource_a.jpg', 'image/jpeg'], - 'jpeg lowercase' => ['resource_a.jpeg', 'image/jpeg'], - 'gif lowercase' => ['resource_a.gif', 'image/gif'], - 'svg lowercase' => ['resource_a.svg', 'image/svg+xml'], - 'webp lowercase' => ['resource_a.webp', 'image/webp'], - 'png uppercase' => ['resource_a.PNG', 'image/png'], - 'svg uppercase' => ['resource_a.SVG', 'image/svg+xml'], - 'unknown ext' => ['resource_a.bin', 'application/octet-stream'], - 'no extension' => ['noext', 'application/octet-stream'], - 'empty extension' => ['weird.', 'application/octet-stream'], - 'dotfile' => ['.hidden', 'application/octet-stream'], - ]; - } - - public function testFormatTimestampReturnsIso8601Utc(): void - { - // 2023-11-14T22:13:20+00:00 - $iso = $this->service->formatTimestamp(epoch: 1700000000); - $this->assertSame('2023-11-14T22:13:20+00:00', $iso); - } - - public function testFormatTimestampUsesUtcRegardlessOfPhpDefault(): void - { - $iso = $this->service->formatTimestamp(epoch: 0); - $this->assertSame('1970-01-01T00:00:00+00:00', $iso); - } +class ResourceServeServiceTest extends TestCase { + private ResourceServeService $service; + + /** @var IAppData&MockObject */ + private $appData; + + /** @var LoggerInterface&MockObject */ + private $logger; + + /** @var ISimpleFolder&MockObject */ + private $folder; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->folder = $this->createMock(ISimpleFolder::class); + + $this->service = new ResourceServeService( + appData: $this->appData, + logger: $this->logger, + ); + } + + public function testFindFileReturnsFileWhenPresent(): void { + $file = $this->createMock(ISimpleFile::class); + $this->appData->method('getFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + $this->folder->method('getFile')->with('resource_abc.png')->willReturn($file); + + $this->assertSame($file, $this->service->findFile(filename: 'resource_abc.png')); + } + + public function testFindFileReturnsNullWhenFolderMissing(): void { + $this->appData->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertNull($this->service->findFile(filename: 'whatever.png')); + } + + public function testFindFileReturnsNullWhenFileMissing(): void { + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $this->assertNull($this->service->findFile(filename: 'gone.png')); + } + + public function testFindFileReturnsNullOnUnexpectedException(): void { + $this->appData->method('getFolder') + ->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + + $this->assertNull($this->service->findFile(filename: 'whatever.png')); + } + + public function testListFilesReturnsAllSimpleFiles(): void { + $a = $this->createMock(ISimpleFile::class); + $b = $this->createMock(ISimpleFile::class); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getDirectoryListing')->willReturn([$a, $b]); + + $this->assertSame([$a, $b], $this->service->listFiles()); + } + + public function testListFilesReturnsEmptyArrayWhenFolderMissing(): void { + $this->appData->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertSame([], $this->service->listFiles()); + } + + public function testListFilesReturnsEmptyArrayOnUnexpectedException(): void { + $this->appData->method('getFolder') + ->willThrowException(new \RuntimeException('disk failure')); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame([], $this->service->listFiles()); + } + + public function testListFilesSkipsNonFileEntries(): void { + $a = $this->createMock(ISimpleFile::class); + $bogus = new \stdClass(); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getDirectoryListing')->willReturn([$a, $bogus]); + + $this->assertSame([$a], $this->service->listFiles()); + } + + /** + * @dataProvider contentTypeProvider + */ + public function testContentTypeForFilename(string $filename, string $expected): void { + $this->assertSame($expected, $this->service->contentTypeForFilename(filename: $filename)); + } + + /** + * @return array> + */ + public static function contentTypeProvider(): array { + return [ + 'png lowercase' => ['resource_a.png', 'image/png'], + 'jpg lowercase' => ['resource_a.jpg', 'image/jpeg'], + 'jpeg lowercase' => ['resource_a.jpeg', 'image/jpeg'], + 'gif lowercase' => ['resource_a.gif', 'image/gif'], + 'svg lowercase' => ['resource_a.svg', 'image/svg+xml'], + 'webp lowercase' => ['resource_a.webp', 'image/webp'], + 'png uppercase' => ['resource_a.PNG', 'image/png'], + 'svg uppercase' => ['resource_a.SVG', 'image/svg+xml'], + 'unknown ext' => ['resource_a.bin', 'application/octet-stream'], + 'no extension' => ['noext', 'application/octet-stream'], + 'empty extension' => ['weird.', 'application/octet-stream'], + 'dotfile' => ['.hidden', 'application/octet-stream'], + ]; + } + + public function testFormatTimestampReturnsIso8601Utc(): void { + // 2023-11-14T22:13:20+00:00 + $iso = $this->service->formatTimestamp(epoch: 1700000000); + $this->assertSame('2023-11-14T22:13:20+00:00', $iso); + } + + public function testFormatTimestampUsesUtcRegardlessOfPhpDefault(): void { + $iso = $this->service->formatTimestamp(epoch: 0); + $this->assertSame('1970-01-01T00:00:00+00:00', $iso); + } } diff --git a/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php b/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php index 8a6a862ef..1a74dec60 100644 --- a/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php +++ b/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php @@ -21,7 +21,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2024 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -38,148 +38,139 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ResourceServiceSvgIntegrationTest extends TestCase -{ - private ResourceService $service; - - /** @var IAppData&MockObject */ - private $appData; - - /** @var ISimpleFolder&MockObject */ - private $folder; - - /** Captures whatever bytes the service writes to disk. */ - private string $persistedBytes = ''; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->folder = $this->createMock(ISimpleFolder::class); - $this->appData->method('getFolder')->willReturn($this->folder); - - $this->folder->method('newFile')->willReturnCallback( - function (string $name, $content): ISimpleFile { - $this->persistedBytes = (string) $content; - return $this->createMock(ISimpleFile::class); - } - ); - - $this->service = new ResourceService( - appData: $this->appData, - mimeValidator: new ImageMimeValidator(), - svgSanitiser: new SvgSanitiser(), - ); - } - - private function dataUrl(string $bytes): string - { - return 'data:image/svg+xml;base64,' . base64_encode($bytes); - } - - public function testMaliciousSvgUploadStripsScriptAndPersists(): void - { - $svg = '' - . '' - . '' - . ''; - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); - $this->assertStringNotContainsString('persistedBytes); - $this->assertStringNotContainsString('alert', $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } - - public function testGarbagePayloadThrowsInvalidSvg(): void - { - $this->folder->expects($this->never())->method('newFile'); - - $this->expectException(InvalidSvgException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl('service->upload( - base64DataUrl: $this->dataUrl('fail('Expected InvalidSvgException'); - } catch (InvalidSvgException $exception) { - $this->assertSame('invalid_svg', $exception->getErrorCode()); - $this->assertSame(400, $exception->getHttpStatus()); - } - } - - public function testOversizeSvgBelowCapAfterSanitisationIsAccepted(): void - { - // Build an SVG whose original bytes exceed 5 MB but whose - // sanitised form (script stripped) drops below the cap. Pad - // a stripped-out ' - . '' - . ''; - - $this->assertGreaterThan( - (5 * 1024 * 1024), - strlen($svg), - 'Original payload must exceed the 5 MB cap' - ); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - // Sanitised result MUST be under the cap. - $this->assertLessThan( - ResourceService::MAX_BYTES, - $result['size'], - 'Sanitised payload size must be under 5 MB' - ); - $this->assertStringNotContainsString($payload, $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } - - public function testNearCapCleanSvgIsAccepted(): void - { - // A clean 4.9 MB-ish SVG whose sanitised output is roughly the - // same size — must succeed, well under the 5 MB cap. - $padding = str_repeat(' ', (int) (4.8 * 1024 * 1024)); - $svg = '' - . '' . $padding . '' - . '' - . ''; - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - $this->assertLessThan(ResourceService::MAX_BYTES, $result['size']); - $this->assertStringContainsString('persistedBytes); - } - - public function testPersistedBytesAreSanitisedNotOriginal(): void - { - $svg = '' - . '' - . '' - . ''; - - $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - // Persisted bytes MUST NOT be byte-equal to the original. - $this->assertNotSame($svg, $this->persistedBytes); - $this->assertStringNotContainsString('EVIL_MARKER_42', $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } +class ResourceServiceSvgIntegrationTest extends TestCase { + private ResourceService $service; + + /** @var IAppData&MockObject */ + private $appData; + + /** @var ISimpleFolder&MockObject */ + private $folder; + + /** Captures whatever bytes the service writes to disk. */ + private string $persistedBytes = ''; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->folder = $this->createMock(ISimpleFolder::class); + $this->appData->method('getFolder')->willReturn($this->folder); + + $this->folder->method('newFile')->willReturnCallback( + function (string $name, $content): ISimpleFile { + $this->persistedBytes = (string)$content; + return $this->createMock(ISimpleFile::class); + } + ); + + $this->service = new ResourceService( + appData: $this->appData, + mimeValidator: new ImageMimeValidator(), + svgSanitiser: new SvgSanitiser(), + ); + } + + private function dataUrl(string $bytes): string { + return 'data:image/svg+xml;base64,' . base64_encode($bytes); + } + + public function testMaliciousSvgUploadStripsScriptAndPersists(): void { + $svg = '' + . '' + . '' + . ''; + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringNotContainsString('persistedBytes); + $this->assertStringNotContainsString('alert', $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } + + public function testGarbagePayloadThrowsInvalidSvg(): void { + $this->folder->expects($this->never())->method('newFile'); + + $this->expectException(InvalidSvgException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl('service->upload( + base64DataUrl: $this->dataUrl('fail('Expected InvalidSvgException'); + } catch (InvalidSvgException $exception) { + $this->assertSame('invalid_svg', $exception->getErrorCode()); + $this->assertSame(400, $exception->getHttpStatus()); + } + } + + public function testOversizeSvgBelowCapAfterSanitisationIsAccepted(): void { + // Build an SVG whose original bytes exceed 5 MB but whose + // sanitised form (script stripped) drops below the cap. Pad + // a stripped-out ' + . '' + . ''; + + $this->assertGreaterThan( + (5 * 1024 * 1024), + strlen($svg), + 'Original payload must exceed the 5 MB cap' + ); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + // Sanitised result MUST be under the cap. + $this->assertLessThan( + ResourceService::MAX_BYTES, + $result['size'], + 'Sanitised payload size must be under 5 MB' + ); + $this->assertStringNotContainsString($payload, $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } + + public function testNearCapCleanSvgIsAccepted(): void { + // A clean 4.9 MB-ish SVG whose sanitised output is roughly the + // same size — must succeed, well under the 5 MB cap. + $padding = str_repeat(' ', (int)(4.8 * 1024 * 1024)); + $svg = '' + . '' . $padding . '' + . '' + . ''; + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + $this->assertLessThan(ResourceService::MAX_BYTES, $result['size']); + $this->assertStringContainsString('persistedBytes); + } + + public function testPersistedBytesAreSanitisedNotOriginal(): void { + $svg = '' + . '' + . '' + . ''; + + $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + // Persisted bytes MUST NOT be byte-equal to the original. + $this->assertNotSame($svg, $this->persistedBytes); + $this->assertStringNotContainsString('EVIL_MARKER_42', $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } } diff --git a/tests/Unit/Service/ResourceServiceTest.php b/tests/Unit/Service/ResourceServiceTest.php index 3c7dd00e5..2b24e4310 100644 --- a/tests/Unit/Service/ResourceServiceTest.php +++ b/tests/Unit/Service/ResourceServiceTest.php @@ -10,7 +10,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2024 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -33,211 +33,289 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ResourceServiceTest extends TestCase -{ - private ResourceService $service; - - /** - * @var IAppData&MockObject - */ - private $appData; - - /** - * @var ImageMimeValidator&MockObject - */ - private $mimeValidator; - - /** - * @var ISimpleFolder&MockObject - */ - private $folder; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->mimeValidator = $this->createMock(ImageMimeValidator::class); - $this->folder = $this->createMock(ISimpleFolder::class); - - $this->service = new ResourceService( - appData: $this->appData, - mimeValidator: $this->mimeValidator, - svgSanitiser: new SvgSanitiser(), - ); - } - - /** - * Tiny 1x1 PNG bytes (red pixel). - */ - private function tinyPng(): string - { - return base64_decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', - true - ); - } - - /** - * Build a base64 data URL from raw bytes and a declared type. - */ - private function dataUrl(string $type, string $bytes): string - { - return 'data:image/' . $type . ';base64,' . base64_encode($bytes); - } - - public function testMissingDataUrlPrefixIsRejected(): void - { - $this->expectException(InvalidDataUrlException::class); - $this->service->upload(base64DataUrl: 'iVBORw0KGgo'); - } - - public function testEmptyInputIsRejected(): void - { - $this->expectException(InvalidDataUrlException::class); - $this->service->upload(base64DataUrl: ''); - } - - public function testDisallowedTypeIsRejected(): void - { - $this->expectException(InvalidImageFormatException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'bmp', bytes: 'whatever') - ); - } - - public function testMixedCaseDeclaredTypeIsAcceptedAndLowercased(): void - { - $this->mimeValidator->expects($this->once())->method('validate') - ->with('png', $this->tinyPng()); - - $this->folder->expects($this->once())->method('newFile') - ->willReturnCallback(function (string $name, $content): ISimpleFile { - $this->assertStringEndsWith('.png', $name); - $this->assertStringStartsWith('resource_', $name); - - return $this->createMock(ISimpleFile::class); - }); - - $this->appData->expects($this->once())->method('getFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: 'data:image/PNG;base64,' . base64_encode($this->tinyPng()) - ); - - $this->assertSame('success', 'success'); // sanity - $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); - $this->assertStringEndsWith('.png', $result['url']); - $this->assertSame(strlen($this->tinyPng()), $result['size']); - } - - public function testSvgPlusXmlNormalisesToSvg(): void - { - $svg = ''; - $this->mimeValidator->expects($this->once())->method('validate') - ->with('svg', $svg); - - $this->folder->method('newFile') - ->willReturnCallback(function (string $name): ISimpleFile { - $this->assertStringEndsWith('.svg', $name); - return $this->createMock(ISimpleFile::class); - }); - - $this->appData->method('getFolder')->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'svg+xml', bytes: $svg) - ); - - $this->assertStringEndsWith('.svg', $result['name']); - } - - public function testOversizePayloadIsRejectedBeforeValidator(): void - { - // 6 MB blob. - $oversize = str_repeat('A', (6 * 1024 * 1024)); - $this->mimeValidator->expects($this->never())->method('validate'); - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(FileTooLargeException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $oversize) - ); - } - - public function testSizeAtCapIsAccepted(): void - { - // Exactly 5 MB → allowed (cap is "exceeds", not "equals"). - $atCap = str_repeat('A', (5 * 1024 * 1024)); - $this->mimeValidator->expects($this->once())->method('validate'); - $this->folder->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - $this->appData->method('getFolder')->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $atCap) - ); - - $this->assertSame((5 * 1024 * 1024), $result['size']); - } - - public function testMimeMismatchBubblesUp(): void - { - $this->mimeValidator->method('validate') - ->willThrowException(new MimeMismatchException()); - - $this->expectException(MimeMismatchException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: 'whatever') - ); - } - - public function testFolderAutoCreatedWhenMissing(): void - { - $this->mimeValidator->method('validate'); - $this->appData->expects($this->once())->method('getFolder') - ->with(ResourceService::FOLDER) - ->willThrowException(new NotFoundException()); - $this->appData->expects($this->once())->method('newFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - $this->folder->expects($this->once())->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - - $this->assertStringStartsWith('resource_', $result['name']); - } - - public function testStorageFailureIsWrapped(): void - { - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('newFile') - ->willThrowException(new NotPermittedException('disk full')); - - $this->expectException(StorageFailureException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - } - - public function testFilenameMatchesSpecPattern(): void - { - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - - // resource_.png — uniqid with - // more_entropy=true returns hex + dot + decimal. - $this->assertMatchesRegularExpression( - '#^resource_[a-f0-9.]+\.(jpeg|jpg|png|gif|svg|webp)$#', - $result['name'] - ); - } +class ResourceServiceTest extends TestCase { + private ResourceService $service; + + /** + * @var IAppData&MockObject + */ + private $appData; + + /** + * @var ImageMimeValidator&MockObject + */ + private $mimeValidator; + + /** + * @var ISimpleFolder&MockObject + */ + private $folder; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->mimeValidator = $this->createMock(ImageMimeValidator::class); + $this->folder = $this->createMock(ISimpleFolder::class); + + $this->service = new ResourceService( + appData: $this->appData, + mimeValidator: $this->mimeValidator, + svgSanitiser: new SvgSanitiser(), + ); + } + + /** + * Tiny 1x1 PNG bytes (red pixel). + */ + private function tinyPng(): string { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + true + ); + } + + /** + * Build a base64 data URL from raw bytes and a declared type. + */ + private function dataUrl(string $type, string $bytes): string { + return 'data:image/' . $type . ';base64,' . base64_encode($bytes); + } + + public function testMissingDataUrlPrefixIsRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->service->upload(base64DataUrl: 'iVBORw0KGgo'); + } + + public function testEmptyInputIsRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->service->upload(base64DataUrl: ''); + } + + public function testDisallowedTypeIsRejected(): void { + $this->expectException(InvalidImageFormatException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'bmp', bytes: 'whatever') + ); + } + + public function testMixedCaseDeclaredTypeIsAcceptedAndLowercased(): void { + $this->mimeValidator->expects($this->once())->method('validate') + ->with('png', $this->tinyPng()); + + $this->folder->expects($this->once())->method('newFile') + ->willReturnCallback(function (string $name, $content): ISimpleFile { + $this->assertStringEndsWith('.png', $name); + $this->assertStringStartsWith('resource_', $name); + + return $this->createMock(ISimpleFile::class); + }); + + $this->appData->expects($this->once())->method('getFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: 'data:image/PNG;base64,' . base64_encode($this->tinyPng()) + ); + + $this->assertSame('success', 'success'); // sanity + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringEndsWith('.png', $result['url']); + $this->assertSame(strlen($this->tinyPng()), $result['size']); + } + + public function testSvgPlusXmlNormalisesToSvg(): void { + $svg = ''; + $this->mimeValidator->expects($this->once())->method('validate') + ->with('svg', $svg); + + $this->folder->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringEndsWith('.svg', $name); + return $this->createMock(ISimpleFile::class); + }); + + $this->appData->method('getFolder')->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'svg+xml', bytes: $svg) + ); + + $this->assertStringEndsWith('.svg', $result['name']); + } + + public function testOversizePayloadIsRejectedBeforeValidator(): void { + // 6 MB blob. + $oversize = str_repeat('A', (6 * 1024 * 1024)); + $this->mimeValidator->expects($this->never())->method('validate'); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(FileTooLargeException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $oversize) + ); + } + + public function testSizeAtCapIsAccepted(): void { + // Exactly 5 MB → allowed (cap is "exceeds", not "equals"). + $atCap = str_repeat('A', (5 * 1024 * 1024)); + $this->mimeValidator->expects($this->once())->method('validate'); + $this->folder->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + $this->appData->method('getFolder')->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $atCap) + ); + + $this->assertSame((5 * 1024 * 1024), $result['size']); + } + + public function testMimeMismatchBubblesUp(): void { + $this->mimeValidator->method('validate') + ->willThrowException(new MimeMismatchException()); + + $this->expectException(MimeMismatchException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: 'whatever') + ); + } + + public function testFolderAutoCreatedWhenMissing(): void { + $this->mimeValidator->method('validate'); + $this->appData->expects($this->once())->method('getFolder') + ->with(ResourceService::FOLDER) + ->willThrowException(new NotFoundException()); + $this->appData->expects($this->once())->method('newFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + $this->folder->expects($this->once())->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + + $this->assertStringStartsWith('resource_', $result['name']); + } + + public function testStorageFailureIsWrapped(): void { + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willThrowException(new NotPermittedException('disk full')); + + $this->expectException(StorageFailureException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + } + + public function testFilenameMatchesSpecPattern(): void { + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + + // resource_.png — uniqid with + // more_entropy=true returns hex + dot + decimal. + $this->assertMatchesRegularExpression( + '#^resource_[a-f0-9.]+\.(jpeg|jpg|png|gif|svg|webp)$#', + $result['name'] + ); + } + + // --------------------------------------------------------------- + // uploadRaw() — raw multipart path (REQ-RES-014). + // --------------------------------------------------------------- + + public function testUploadRawStoresBytesAndReturnsEnvelope(): void { + $this->mimeValidator->expects($this->once())->method('validate') + ->with('png', $this->tinyPng()); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->expects($this->once())->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringStartsWith('resource_', $name); + $this->assertStringEndsWith('.png', $name); + return $this->createMock(ISimpleFile::class); + }); + + $result = $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'png'); + + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringEndsWith('.png', $result['url']); + $this->assertSame(strlen($this->tinyPng()), $result['size']); + } + + public function testUploadRawNormalisesUppercaseAndSvgXmlType(): void { + $svg = ''; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringEndsWith('.svg', $name); + return $this->createMock(ISimpleFile::class); + }); + + // 'SVG+XML' (uppercase, +xml suffix) must normalise to 'svg'. + $result = $this->service->uploadRaw(bytes: $svg, declaredType: 'SVG+XML'); + + $this->assertStringEndsWith('.svg', $result['name']); + } + + public function testUploadRawEmptyBytesRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: '', declaredType: 'png'); + } + + public function testUploadRawDisallowedTypeRejected(): void { + $this->expectException(InvalidImageFormatException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: 'whatever', declaredType: 'bmp'); + } + + public function testUploadRawOversizeRejectedBeforeValidator(): void { + $oversize = str_repeat('A', (6 * 1024 * 1024)); + $this->mimeValidator->expects($this->never())->method('validate'); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(FileTooLargeException::class); + $this->service->uploadRaw(bytes: $oversize, declaredType: 'png'); + } + + public function testUploadRawSanitisesSvgScriptBeforePersisting(): void { + // Uses the real SvgSanitiser (wired in setUp): a '; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + + $persisted = null; + $this->folder->method('newFile') + ->willReturnCallback(function (string $name, $content) use (&$persisted): ISimpleFile { + $persisted = $content; + return $this->createMock(ISimpleFile::class); + }); + + $this->service->uploadRaw(bytes: $svg, declaredType: 'svg'); + + $this->assertIsString($persisted); + $this->assertStringNotContainsStringIgnoringCase('assertStringContainsString('mimeValidator->method('validate') + ->willThrowException(new MimeMismatchException()); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(MimeMismatchException::class); + $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'webp'); + } } diff --git a/tests/Unit/Service/RoleFeaturePermissionServiceTest.php b/tests/Unit/Service/RoleFeaturePermissionServiceTest.php index 8fe32ae17..abf20faeb 100644 --- a/tests/Unit/Service/RoleFeaturePermissionServiceTest.php +++ b/tests/Unit/Service/RoleFeaturePermissionServiceTest.php @@ -15,7 +15,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -37,263 +37,251 @@ use OCP\IUserManager; use PHPUnit\Framework\TestCase; -class RoleFeaturePermissionServiceTest extends TestCase -{ - private RoleFeaturePermissionService $service; - - private RoleFeaturePermissionMapper $permMapper; - - private RoleLayoutDefaultMapper $defaultMapper; - - private WidgetPlacementMapper $placementMapper; - - private AdminSettingsService $adminSettings; - - private AdminTemplateService $adminTemplateService; - - private IUserManager $userManager; - - private IGroupManager $groupManager; - - protected function setUp(): void - { - $this->permMapper = $this->createMock(originalClassName: RoleFeaturePermissionMapper::class); - $this->defaultMapper = $this->createMock(originalClassName: RoleLayoutDefaultMapper::class); - $this->placementMapper = $this->createMock(originalClassName: WidgetPlacementMapper::class); - $this->adminSettings = $this->createMock(originalClassName: AdminSettingsService::class); - $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); - - // Default: the user under test is NOT an admin so the existing - // role-resolution assertions exercise the group-matching path. - $this->groupManager->method('isAdmin')->willReturn(false); - - $this->service = new RoleFeaturePermissionService( - permissionMapper: $this->permMapper, - defaultMapper: $this->defaultMapper, - placementMapper: $this->placementMapper, - adminSettings: $this->adminSettings, - adminTemplateService: $this->adminTemplateService, - userManager: $this->userManager, - groupManager: $this->groupManager, - ); - }//end setUp() - - /** - * Build a RoleFeaturePermission entity from arrays. - */ - private function makePerm( - string $groupId, - array $allowed, - array $denied = [] - ): RoleFeaturePermission { - $entity = new RoleFeaturePermission(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setName('perm-' . $groupId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGroupId($groupId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setAllowedWidgets(json_encode(value: $allowed)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setDeniedWidgets(json_encode(value: $denied)); - return $entity; - }//end makePerm() - - /** - * Mock IUserManager + AdminTemplateService so the user is in a list - * of groups. The service calls - * {@see AdminTemplateService::getUserGroupIdsFor()} to honour the - * REQ-TMPL-013 routing-resolver invariant — group lookups never - * touch IGroupManager directly. - */ - private function withUserGroups(string $userId, array $groupIds): void - { - $user = $this->createMock(originalClassName: IUser::class); - $this->userManager->method('get') - ->willReturn(value: $user); - $this->adminTemplateService->method('getUserGroupIdsFor') - ->willReturn(value: $groupIds); - }//end withUserGroups() - - public function testNoRestrictionConfiguredReturnsNull(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: []); - $this->permMapper->method('findByGroupId') - ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertNull(actual: $result); - }//end testNoRestrictionConfiguredReturnsNull() - - public function testSingleGroupReturnsAllowedSet(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'managers']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity', 'recommendations']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity', 'recommendations'], actual: $result); - }//end testSingleGroupReturnsAllowedSet() - - public function testMultiGroupUnionWidens(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'managers']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'managers']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity']), - $this->makePerm(groupId: 'managers', allowed: ['analytics']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity', 'analytics'], actual: $result); - }//end testMultiGroupUnionWidens() - - public function testDenyWinsOverAllow(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'security']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'security']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity', 'analytics']), - $this->makePerm(groupId: 'security', allowed: [], denied: ['analytics']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity'], actual: $result); - }//end testDenyWinsOverAllow() - - public function testFallbackToDefaultGroupWhenNoGroupOrderMatch(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['unmapped']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: []); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: []); - $this->permMapper->method('findByGroupId') - ->with($this->equalTo(value: RoleFeaturePermission::GROUP_DEFAULT)) - ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['recommendations'])); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['recommendations'], actual: $result); - }//end testFallbackToDefaultGroupWhenNoGroupOrderMatch() - - public function testIsWidgetAllowedTrueWhenUnconfigured(): void - { - $this->withUserGroups(userId: 'alice', groupIds: []); - $this->permMapper->method('findByGroupId') - ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); - - $this->assertTrue(condition: $this->service->isWidgetAllowed( - userId: 'alice', - widgetId: 'whatever' - )); - }//end testIsWidgetAllowedTrueWhenUnconfigured() - - /** - * Admin break-glass: a Nextcloud admin is never restricted by the - * role-feature-permission allow-list, even when a restrictive `default` - * row exists (the bug — admins were falling back to the demo-seeded - * `default` row and getting 403 on their own dashboard). - */ - public function testAdminBypassesDefaultRestriction(): void - { - $admin = $this->createMock(originalClassName: IGroupManager::class); - $admin->method('isAdmin')->willReturn(true); - - $service = new RoleFeaturePermissionService( - permissionMapper: $this->permMapper, - defaultMapper: $this->defaultMapper, - placementMapper: $this->placementMapper, - adminSettings: $this->adminSettings, - adminTemplateService: $this->adminTemplateService, - userManager: $this->userManager, - groupManager: $admin, - ); - - // Even if a restrictive `default` row would be returned, the admin - // short-circuit must run first: getAllowedWidgetIds → null (no - // restriction) and isWidgetAllowed → true for any widget. - $this->permMapper->method('findByGroupId') - ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['activity'])); - - $this->assertNull(actual: $service->getAllowedWidgetIds(userId: 'admin')); - $this->assertTrue(condition: $service->isWidgetAllowed( - userId: 'admin', - widgetId: 'links' - )); - }//end testAdminBypassesDefaultRestriction() - - public function testSeedLayoutNoOpWhenDashboardHasPlacements(): void - { - $dashboard = new Dashboard(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $dashboard->setId(42); - $this->placementMapper->method('findByDashboardId') - ->willReturn(value: ['existing-placement']); - - // No mapper / group manager calls expected because the guard fires first. - $this->defaultMapper->expects($this->never()) - ->method('findByGroupId'); - - $created = $this->service->seedLayoutFromRoleDefaults( - userId: 'alice', - dashboard: $dashboard - ); - $this->assertSame(expected: 0, actual: $created); - }//end testSeedLayoutNoOpWhenDashboardHasPlacements() - - public function testSeedLayoutCreatesPlacementsWhenEmpty(): void - { - $dashboard = new Dashboard(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $dashboard->setId(99); - - $this->placementMapper->method('findByDashboardId') - ->willReturn(value: []); - $this->withUserGroups(userId: 'alice', groupIds: ['managers']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['managers']); - - $rld = new RoleLayoutDefault(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setName('manager-activity'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGroupId('managers'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setWidgetId('activity'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridX(0); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridY(0); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridWidth(6); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridHeight(5); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setSortOrder(0); - - $this->defaultMapper->method('findByGroupId') - ->willReturn(value: [$rld]); - - $this->placementMapper->expects($this->once()) - ->method('insert'); - - $created = $this->service->seedLayoutFromRoleDefaults( - userId: 'alice', - dashboard: $dashboard - ); - $this->assertSame(expected: 1, actual: $created); - }//end testSeedLayoutCreatesPlacementsWhenEmpty() +class RoleFeaturePermissionServiceTest extends TestCase { + private RoleFeaturePermissionService $service; + + private RoleFeaturePermissionMapper $permMapper; + + private RoleLayoutDefaultMapper $defaultMapper; + + private WidgetPlacementMapper $placementMapper; + + private AdminSettingsService $adminSettings; + + private AdminTemplateService $adminTemplateService; + + private IUserManager $userManager; + + private IGroupManager $groupManager; + + protected function setUp(): void { + $this->permMapper = $this->createMock(originalClassName: RoleFeaturePermissionMapper::class); + $this->defaultMapper = $this->createMock(originalClassName: RoleLayoutDefaultMapper::class); + $this->placementMapper = $this->createMock(originalClassName: WidgetPlacementMapper::class); + $this->adminSettings = $this->createMock(originalClassName: AdminSettingsService::class); + $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + + // Default: the user under test is NOT an admin so the existing + // role-resolution assertions exercise the group-matching path. + $this->groupManager->method('isAdmin')->willReturn(false); + + $this->service = new RoleFeaturePermissionService( + permissionMapper: $this->permMapper, + defaultMapper: $this->defaultMapper, + placementMapper: $this->placementMapper, + adminSettings: $this->adminSettings, + adminTemplateService: $this->adminTemplateService, + userManager: $this->userManager, + groupManager: $this->groupManager, + ); + }//end setUp() + + /** + * Build a RoleFeaturePermission entity from arrays. + */ + private function makePerm( + string $groupId, + array $allowed, + array $denied = [], + ): RoleFeaturePermission { + $entity = new RoleFeaturePermission(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setName('perm-' . $groupId); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGroupId($groupId); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setAllowedWidgets(json_encode(value: $allowed)); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setDeniedWidgets(json_encode(value: $denied)); + return $entity; + }//end makePerm() + + /** + * Mock IUserManager + AdminTemplateService so the user is in a list + * of groups. The service calls + * {@see AdminTemplateService::getUserGroupIdsFor()} to honour the + * REQ-TMPL-013 routing-resolver invariant — group lookups never + * touch IGroupManager directly. + */ + private function withUserGroups(string $userId, array $groupIds): void { + $user = $this->createMock(originalClassName: IUser::class); + $this->userManager->method('get') + ->willReturn(value: $user); + $this->adminTemplateService->method('getUserGroupIdsFor') + ->willReturn(value: $groupIds); + }//end withUserGroups() + + public function testNoRestrictionConfiguredReturnsNull(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: []); + $this->permMapper->method('findByGroupId') + ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertNull(actual: $result); + }//end testNoRestrictionConfiguredReturnsNull() + + public function testSingleGroupReturnsAllowedSet(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'managers']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity', 'recommendations']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity', 'recommendations'], actual: $result); + }//end testSingleGroupReturnsAllowedSet() + + public function testMultiGroupUnionWidens(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'managers']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'managers']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity']), + $this->makePerm(groupId: 'managers', allowed: ['analytics']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity', 'analytics'], actual: $result); + }//end testMultiGroupUnionWidens() + + public function testDenyWinsOverAllow(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'security']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'security']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity', 'analytics']), + $this->makePerm(groupId: 'security', allowed: [], denied: ['analytics']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity'], actual: $result); + }//end testDenyWinsOverAllow() + + public function testFallbackToDefaultGroupWhenNoGroupOrderMatch(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['unmapped']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: []); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: []); + $this->permMapper->method('findByGroupId') + ->with($this->equalTo(value: RoleFeaturePermission::GROUP_DEFAULT)) + ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['recommendations'])); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['recommendations'], actual: $result); + }//end testFallbackToDefaultGroupWhenNoGroupOrderMatch() + + public function testIsWidgetAllowedTrueWhenUnconfigured(): void { + $this->withUserGroups(userId: 'alice', groupIds: []); + $this->permMapper->method('findByGroupId') + ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); + + $this->assertTrue(condition: $this->service->isWidgetAllowed( + userId: 'alice', + widgetId: 'whatever' + )); + }//end testIsWidgetAllowedTrueWhenUnconfigured() + + /** + * Admin break-glass: a Nextcloud admin is never restricted by the + * role-feature-permission allow-list, even when a restrictive `default` + * row exists (the bug — admins were falling back to the demo-seeded + * `default` row and getting 403 on their own dashboard). + */ + public function testAdminBypassesDefaultRestriction(): void { + $admin = $this->createMock(originalClassName: IGroupManager::class); + $admin->method('isAdmin')->willReturn(true); + + $service = new RoleFeaturePermissionService( + permissionMapper: $this->permMapper, + defaultMapper: $this->defaultMapper, + placementMapper: $this->placementMapper, + adminSettings: $this->adminSettings, + adminTemplateService: $this->adminTemplateService, + userManager: $this->userManager, + groupManager: $admin, + ); + + // Even if a restrictive `default` row would be returned, the admin + // short-circuit must run first: getAllowedWidgetIds → null (no + // restriction) and isWidgetAllowed → true for any widget. + $this->permMapper->method('findByGroupId') + ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['activity'])); + + $this->assertNull(actual: $service->getAllowedWidgetIds(userId: 'admin')); + $this->assertTrue(condition: $service->isWidgetAllowed( + userId: 'admin', + widgetId: 'links' + )); + }//end testAdminBypassesDefaultRestriction() + + public function testSeedLayoutNoOpWhenDashboardHasPlacements(): void { + $dashboard = new Dashboard(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setId(42); + $this->placementMapper->method('findByDashboardId') + ->willReturn(value: ['existing-placement']); + + // No mapper / group manager calls expected because the guard fires first. + $this->defaultMapper->expects($this->never()) + ->method('findByGroupId'); + + $created = $this->service->seedLayoutFromRoleDefaults( + userId: 'alice', + dashboard: $dashboard + ); + $this->assertSame(expected: 0, actual: $created); + }//end testSeedLayoutNoOpWhenDashboardHasPlacements() + + public function testSeedLayoutCreatesPlacementsWhenEmpty(): void { + $dashboard = new Dashboard(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setId(99); + + $this->placementMapper->method('findByDashboardId') + ->willReturn(value: []); + $this->withUserGroups(userId: 'alice', groupIds: ['managers']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['managers']); + + $rld = new RoleLayoutDefault(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setName('manager-activity'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGroupId('managers'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setWidgetId('activity'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridX(0); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridY(0); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridWidth(6); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridHeight(5); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setSortOrder(0); + + $this->defaultMapper->method('findByGroupId') + ->willReturn(value: [$rld]); + + $this->placementMapper->expects($this->once()) + ->method('insert'); + + $created = $this->service->seedLayoutFromRoleDefaults( + userId: 'alice', + dashboard: $dashboard + ); + $this->assertSame(expected: 1, actual: $created); + }//end testSeedLayoutCreatesPlacementsWhenEmpty() }//end class diff --git a/tests/Unit/Service/RoleServiceTest.php b/tests/Unit/Service/RoleServiceTest.php index 530a19750..7a3662671 100644 --- a/tests/Unit/Service/RoleServiceTest.php +++ b/tests/Unit/Service/RoleServiceTest.php @@ -14,7 +14,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -39,324 +39,300 @@ * @SuppressWarnings(PHPMD.TooManyMethods) * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ -class RoleServiceTest extends TestCase -{ - /** @var RoleAssignmentMapper&MockObject */ - private $mapper; - /** @var IUserManager&MockObject */ - private $userManager; - /** @var IGroupManager&MockObject */ - private $groupManager; - /** @var AdminTemplateService&MockObject */ - private $adminTemplateService; - - private RoleService $service; - - protected function setUp(): void - { - parent::setUp(); - - $this->mapper = $this->createMock(RoleAssignmentMapper::class); - $this->userManager = $this->createMock(IUserManager::class); - $this->groupManager = $this->createMock(IGroupManager::class); - $this->adminTemplateService = $this->createMock(AdminTemplateService::class); - - $this->service = new RoleService( - mapper: $this->mapper, - userManager: $this->userManager, - groupManager: $this->groupManager, - adminTemplateService: $this->adminTemplateService, - ); - } - - private function makeAssignment( - ?string $userId, - ?string $groupId, - string $role - ): RoleAssignment { - $assignment = new RoleAssignment(); - $assignment->setUserId($userId); - $assignment->setGroupId($groupId); - $assignment->setRole($role); - $assignment->setAssignedBy('admin-user'); - $assignment->setAssignedAt('2026-05-02T12:00:00+00:00'); - return $assignment; - } - - // ================================================================== - // Effective-role resolution (REQ-ROLE-005) - // ================================================================== - - public function testNcAdminAlwaysGetsAdminRole(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - - // No mapper lookup is required for NC admins. - $this->mapper->expects($this->never())->method('findByUser'); - $this->mapper->expects($this->never())->method('findByGroupIds'); - - $this->assertSame( - RoleAssignment::ROLE_ADMIN, - $this->service->getEffectiveRole(userId: 'alice') - ); - $this->assertSame( - RoleAssignment::SOURCE_NC_ADMIN, - $this->service->getRoleSource(userId: 'alice') - ); - } - - public function testDirectUserAssignmentUsedAsIs(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->with('bob')->willReturn( - [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] - ); - - // Group lookups MUST be skipped per REQ-ROLE-005 step 2. - $this->mapper->expects($this->never())->method('findByGroupIds'); - $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor'); - - $this->assertSame( - RoleAssignment::ROLE_VIEWER, - $this->service->getEffectiveRole(userId: 'bob') - ); - } - - public function testDirectAssignmentBeatsHigherGroupRole(): void - { - // REQ-ROLE-009 scenario 1: direct viewer beats group admin. - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->with('bob')->willReturn( - [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] - ); - - $this->mapper->expects($this->never())->method('findByGroupIds'); - - $this->assertSame( - RoleAssignment::ROLE_VIEWER, - $this->service->getEffectiveRole(userId: 'bob') - ); - $this->assertSame( - RoleAssignment::SOURCE_USER_ASSIGNED, - $this->service->getRoleSource(userId: 'bob') - ); - } - - public function testHighestGroupRoleWins(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn( - ['engineering', 'sales'] - ); - - $this->mapper->method('findByGroupIds')->willReturn([ - $this->makeAssignment(null, 'sales', RoleAssignment::ROLE_VIEWER), - $this->makeAssignment(null, 'engineering', RoleAssignment::ROLE_EDITOR), - ]); - - $this->assertSame( - RoleAssignment::ROLE_EDITOR, - $this->service->getEffectiveRole(userId: 'charlie') - ); - $this->assertSame( - RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX.'engineering', - $this->service->getRoleSource(userId: 'charlie') - ); - } - - public function testNoAssignmentReturnsNullRoleAndSource(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - $this->mapper->method('findByGroupIds')->willReturn([]); - - $this->assertNull($this->service->getEffectiveRole(userId: 'eve')); - $this->assertNull($this->service->getRoleSource(userId: 'eve')); - } - - // ================================================================== - // Validation (REQ-ROLE-004) - // ================================================================== - - public function testValidateRoleAcceptsKnownRoles(): void - { - $this->service->validateRole(role: 'admin'); - $this->service->validateRole(role: 'editor'); - $this->service->validateRole(role: 'viewer'); - $this->expectNotToPerformAssertions(); - } - - public function testValidateRoleRejectsUnknown(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateRole(role: 'superuser'); - } - - public function testValidateTargetRequiresOneOfUserOrGroup(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: null, groupId: null); - } - - public function testValidateTargetRejectsBoth(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: 'bob', groupId: 'engineering'); - } - - public function testValidateTargetRejectsUnknownUser(): void - { - $this->userManager->method('userExists')->with('ghost')->willReturn(false); - - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: 'ghost', groupId: null); - } - - public function testValidateTargetRejectsUnknownGroup(): void - { - $this->groupManager->method('groupExists')->with('phantom')->willReturn(false); - - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: null, groupId: 'phantom'); - } - - // ================================================================== - // Assignment CRUD (REQ-ROLE-004) - // ================================================================== - - public function testAssignRolePersistsAndReturnsEntity(): void - { - $this->userManager->method('userExists')->with('bob')->willReturn(true); - $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn(null); - - $this->mapper->expects($this->once()) - ->method('insert') - ->willReturnCallback(static fn(RoleAssignment $a) => $a); - - $assignment = $this->service->assignRole( - userId: 'bob', - groupId: null, - role: 'editor', - assignedBy: 'admin-user' - ); - - $this->assertSame('bob', $assignment->getUserId()); - $this->assertNull($assignment->getGroupId()); - $this->assertSame('editor', $assignment->getRole()); - $this->assertSame('admin-user', $assignment->getAssignedBy()); - $this->assertNotNull($assignment->getAssignedAt()); - } - - public function testAssignRoleRejectsDuplicateUserRole(): void - { - $this->userManager->method('userExists')->with('bob')->willReturn(true); - $existing = $this->makeAssignment('bob', null, 'editor'); - $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn($existing); - - $this->expectException(DuplicateRoleAssignmentException::class); - $this->service->assignRole( - userId: 'bob', - groupId: null, - role: 'editor', - assignedBy: 'admin-user' - ); - } - - public function testAssignRoleRejectsDuplicateGroupRole(): void - { - $this->groupManager->method('groupExists')->with('engineering')->willReturn(true); - $existing = $this->makeAssignment(null, 'engineering', 'editor'); - $this->mapper->method('findGroupRole')->with('engineering', 'editor')->willReturn($existing); - - $this->expectException(DuplicateRoleAssignmentException::class); - $this->service->assignRole( - userId: null, - groupId: 'engineering', - role: 'editor', - assignedBy: 'admin-user' - ); - } - - public function testRemoveRoleThrowsWhenNoRowAffected(): void - { - $this->mapper->method('deleteById')->with(99)->willReturn(0); - - $this->expectException(DoesNotExistException::class); - $this->service->removeRole(id: 99); - } - - public function testRemoveRoleSucceedsWhenRowDeleted(): void - { - $this->mapper->method('deleteById')->with(7)->willReturn(1); - - $this->service->removeRole(id: 7); - $this->expectNotToPerformAssertions(); - } - - // ================================================================== - // Cascade entry points (REQ-ROLE-010, REQ-ROLE-011) - // ================================================================== - - public function testDeleteByUserIdDelegatesToMapper(): void - { - $this->mapper->expects($this->once()) - ->method('deleteByUserId') - ->with('bob') - ->willReturn(2); - - $this->assertSame(2, $this->service->deleteByUserId(userId: 'bob')); - } - - public function testDeleteByGroupIdDelegatesToMapper(): void - { - $this->mapper->expects($this->once()) - ->method('deleteByGroupId') - ->with('engineering') - ->willReturn(3); - - $this->assertSame( - 3, - $this->service->deleteByGroupId(groupId: 'engineering') - ); - } - - // ================================================================== - // Authorization helpers (REQ-ROLE-001..003, REQ-ROLE-008) - // ================================================================== - - public function testIsAdminForNcAdmin(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - $this->assertTrue($this->service->isAdmin(userId: 'alice')); - } - - public function testIsViewerWhenViewerAssigned(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn( - [$this->makeAssignment('charlie', null, RoleAssignment::ROLE_VIEWER)] - ); - - $this->assertTrue($this->service->isViewer(userId: 'charlie')); - $this->assertFalse($this->service->canMutate(userId: 'charlie')); - } - - public function testCanMutateForUnassignedUser(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - $this->mapper->method('findByGroupIds')->willReturn([]); - - $this->assertTrue($this->service->canMutate(userId: 'eve')); - } - - public function testIsEditorOrHigherTrueForAdminAndEditor(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - $this->assertTrue($this->service->isEditorOrHigher(userId: 'alice')); - } +class RoleServiceTest extends TestCase { + /** @var RoleAssignmentMapper&MockObject */ + private $mapper; + /** @var IUserManager&MockObject */ + private $userManager; + /** @var IGroupManager&MockObject */ + private $groupManager; + /** @var AdminTemplateService&MockObject */ + private $adminTemplateService; + + private RoleService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->mapper = $this->createMock(RoleAssignmentMapper::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->adminTemplateService = $this->createMock(AdminTemplateService::class); + + $this->service = new RoleService( + mapper: $this->mapper, + userManager: $this->userManager, + groupManager: $this->groupManager, + adminTemplateService: $this->adminTemplateService, + ); + } + + private function makeAssignment( + ?string $userId, + ?string $groupId, + string $role, + ): RoleAssignment { + $assignment = new RoleAssignment(); + $assignment->setUserId($userId); + $assignment->setGroupId($groupId); + $assignment->setRole($role); + $assignment->setAssignedBy('admin-user'); + $assignment->setAssignedAt('2026-05-02T12:00:00+00:00'); + return $assignment; + } + + // ================================================================== + // Effective-role resolution (REQ-ROLE-005) + // ================================================================== + + public function testNcAdminAlwaysGetsAdminRole(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + + // No mapper lookup is required for NC admins. + $this->mapper->expects($this->never())->method('findByUser'); + $this->mapper->expects($this->never())->method('findByGroupIds'); + + $this->assertSame( + RoleAssignment::ROLE_ADMIN, + $this->service->getEffectiveRole(userId: 'alice') + ); + $this->assertSame( + RoleAssignment::SOURCE_NC_ADMIN, + $this->service->getRoleSource(userId: 'alice') + ); + } + + public function testDirectUserAssignmentUsedAsIs(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->with('bob')->willReturn( + [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] + ); + + // Group lookups MUST be skipped per REQ-ROLE-005 step 2. + $this->mapper->expects($this->never())->method('findByGroupIds'); + $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor'); + + $this->assertSame( + RoleAssignment::ROLE_VIEWER, + $this->service->getEffectiveRole(userId: 'bob') + ); + } + + public function testDirectAssignmentBeatsHigherGroupRole(): void { + // REQ-ROLE-009 scenario 1: direct viewer beats group admin. + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->with('bob')->willReturn( + [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] + ); + + $this->mapper->expects($this->never())->method('findByGroupIds'); + + $this->assertSame( + RoleAssignment::ROLE_VIEWER, + $this->service->getEffectiveRole(userId: 'bob') + ); + $this->assertSame( + RoleAssignment::SOURCE_USER_ASSIGNED, + $this->service->getRoleSource(userId: 'bob') + ); + } + + public function testHighestGroupRoleWins(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn( + ['engineering', 'sales'] + ); + + $this->mapper->method('findByGroupIds')->willReturn([ + $this->makeAssignment(null, 'sales', RoleAssignment::ROLE_VIEWER), + $this->makeAssignment(null, 'engineering', RoleAssignment::ROLE_EDITOR), + ]); + + $this->assertSame( + RoleAssignment::ROLE_EDITOR, + $this->service->getEffectiveRole(userId: 'charlie') + ); + $this->assertSame( + RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX . 'engineering', + $this->service->getRoleSource(userId: 'charlie') + ); + } + + public function testNoAssignmentReturnsNullRoleAndSource(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + $this->mapper->method('findByGroupIds')->willReturn([]); + + $this->assertNull($this->service->getEffectiveRole(userId: 'eve')); + $this->assertNull($this->service->getRoleSource(userId: 'eve')); + } + + // ================================================================== + // Validation (REQ-ROLE-004) + // ================================================================== + + public function testValidateRoleAcceptsKnownRoles(): void { + $this->service->validateRole(role: 'admin'); + $this->service->validateRole(role: 'editor'); + $this->service->validateRole(role: 'viewer'); + $this->expectNotToPerformAssertions(); + } + + public function testValidateRoleRejectsUnknown(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateRole(role: 'superuser'); + } + + public function testValidateTargetRequiresOneOfUserOrGroup(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: null, groupId: null); + } + + public function testValidateTargetRejectsBoth(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: 'bob', groupId: 'engineering'); + } + + public function testValidateTargetRejectsUnknownUser(): void { + $this->userManager->method('userExists')->with('ghost')->willReturn(false); + + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: 'ghost', groupId: null); + } + + public function testValidateTargetRejectsUnknownGroup(): void { + $this->groupManager->method('groupExists')->with('phantom')->willReturn(false); + + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: null, groupId: 'phantom'); + } + + // ================================================================== + // Assignment CRUD (REQ-ROLE-004) + // ================================================================== + + public function testAssignRolePersistsAndReturnsEntity(): void { + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn(null); + + $this->mapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(static fn (RoleAssignment $a) => $a); + + $assignment = $this->service->assignRole( + userId: 'bob', + groupId: null, + role: 'editor', + assignedBy: 'admin-user' + ); + + $this->assertSame('bob', $assignment->getUserId()); + $this->assertNull($assignment->getGroupId()); + $this->assertSame('editor', $assignment->getRole()); + $this->assertSame('admin-user', $assignment->getAssignedBy()); + $this->assertNotNull($assignment->getAssignedAt()); + } + + public function testAssignRoleRejectsDuplicateUserRole(): void { + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $existing = $this->makeAssignment('bob', null, 'editor'); + $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn($existing); + + $this->expectException(DuplicateRoleAssignmentException::class); + $this->service->assignRole( + userId: 'bob', + groupId: null, + role: 'editor', + assignedBy: 'admin-user' + ); + } + + public function testAssignRoleRejectsDuplicateGroupRole(): void { + $this->groupManager->method('groupExists')->with('engineering')->willReturn(true); + $existing = $this->makeAssignment(null, 'engineering', 'editor'); + $this->mapper->method('findGroupRole')->with('engineering', 'editor')->willReturn($existing); + + $this->expectException(DuplicateRoleAssignmentException::class); + $this->service->assignRole( + userId: null, + groupId: 'engineering', + role: 'editor', + assignedBy: 'admin-user' + ); + } + + public function testRemoveRoleThrowsWhenNoRowAffected(): void { + $this->mapper->method('deleteById')->with(99)->willReturn(0); + + $this->expectException(DoesNotExistException::class); + $this->service->removeRole(id: 99); + } + + public function testRemoveRoleSucceedsWhenRowDeleted(): void { + $this->mapper->method('deleteById')->with(7)->willReturn(1); + + $this->service->removeRole(id: 7); + $this->expectNotToPerformAssertions(); + } + + // ================================================================== + // Cascade entry points (REQ-ROLE-010, REQ-ROLE-011) + // ================================================================== + + public function testDeleteByUserIdDelegatesToMapper(): void { + $this->mapper->expects($this->once()) + ->method('deleteByUserId') + ->with('bob') + ->willReturn(2); + + $this->assertSame(2, $this->service->deleteByUserId(userId: 'bob')); + } + + public function testDeleteByGroupIdDelegatesToMapper(): void { + $this->mapper->expects($this->once()) + ->method('deleteByGroupId') + ->with('engineering') + ->willReturn(3); + + $this->assertSame( + 3, + $this->service->deleteByGroupId(groupId: 'engineering') + ); + } + + // ================================================================== + // Authorization helpers (REQ-ROLE-001..003, REQ-ROLE-008) + // ================================================================== + + public function testIsAdminForNcAdmin(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + $this->assertTrue($this->service->isAdmin(userId: 'alice')); + } + + public function testIsViewerWhenViewerAssigned(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn( + [$this->makeAssignment('charlie', null, RoleAssignment::ROLE_VIEWER)] + ); + + $this->assertTrue($this->service->isViewer(userId: 'charlie')); + $this->assertFalse($this->service->canMutate(userId: 'charlie')); + } + + public function testCanMutateForUnassignedUser(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + $this->mapper->method('findByGroupIds')->willReturn([]); + + $this->assertTrue($this->service->canMutate(userId: 'eve')); + } + + public function testIsEditorOrHigherTrueForAdminAndEditor(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + $this->assertTrue($this->service->isEditorOrHigher(userId: 'alice')); + } }//end class diff --git a/tests/Unit/Service/SetupWizardServiceTest.php b/tests/Unit/Service/SetupWizardServiceTest.php index 30bdb39b8..ce7840d91 100644 --- a/tests/Unit/Service/SetupWizardServiceTest.php +++ b/tests/Unit/Service/SetupWizardServiceTest.php @@ -14,7 +14,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -29,161 +29,149 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class SetupWizardServiceTest extends TestCase -{ - private SetupWizardService $service; - - /** @var AdminSettingMapper&MockObject */ - private $settingMapper; - - /** @var IAppManager&MockObject */ - private $appManager; - - protected function setUp(): void - { - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - $this->appManager = $this->createMock(IAppManager::class); - $this->service = new SetupWizardService( - settingMapper: $this->settingMapper, - appManager: $this->appManager - ); - } - - public function testGetWizardStateOnFreshInstance(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(false); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $state = $this->service->getWizardState(); - - $this->assertFalse($state['complete']); - $this->assertSame(2, $state['currentRecommendedStep']); - $this->assertSame('done', $state['stepStatuses']['1']); - $this->assertSame('pending', $state['stepStatuses']['2']); - $this->assertSame('pending', $state['stepStatuses']['3']); - $this->assertSame('skipped', $state['stepStatuses']['4']); - $this->assertSame('skipped', $state['stepStatuses']['5']); - $this->assertSame('skipped', $state['stepStatuses']['6']); - $this->assertSame('pending', $state['stepStatuses']['7']); - } - - public function testGetWizardStateAfterStorageWritten(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(false); - $this->settingMapper->method('getAllAsArray')->willReturn([ - AdminSetting::KEY_CONTENT_STORAGE => 'database', - ]); - - $state = $this->service->getWizardState(); - - $this->assertSame('done', $state['stepStatuses']['2']); - // Step 3 still pending so the recommended step jumps to 3. - $this->assertSame(3, $state['currentRecommendedStep']); - } - - public function testGetWizardStateAfterCompletion(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([ - AdminSetting::KEY_CONTENT_STORAGE => 'database', - AdminSetting::KEY_GROUP_ORDER => ['engineering'], - AdminSetting::KEY_FOOTER_CONFIG => ['layout' => 'structured'], - ]); - - $state = $this->service->getWizardState(); - - $this->assertTrue($state['complete']); - $this->assertSame('done', $state['stepStatuses']['7']); - $this->assertSame('done', $state['stepStatuses']['6']); - // Steps 4/5 are 'skipped' (sibling capabilities pending), so the - // first non-'done' status is Step 4. The wizard "complete" flag - // is the source of truth for hiding the banner; the recommended - // step is purely a UX hint per REQ-WIZ-008. - $this->assertSame(4, $state['currentRecommendedStep']); - } - - public function testMarkWizardCompleteSetsFlagAndReturnsState(): void - { - $this->settingMapper - ->expects($this->once()) - ->method('setSetting') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, true); - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $state = $this->service->markWizardComplete(); - - $this->assertTrue($state['complete']); - } - - public function testMarkWizardCompleteIsIdempotent(): void - { - // Even when already true the service still writes (idempotent - // semantics — the controller doesn't need a defensive guard). - $this->settingMapper - ->expects($this->exactly(2)) - ->method('setSetting'); - $this->settingMapper->method('getValue')->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $first = $this->service->markWizardComplete(); - $second = $this->service->markWizardComplete(); - - $this->assertSame($first, $second); - } - - public function testGetGroupfolderAvailabilityDelegates(): void - { - $this->appManager - ->expects($this->once()) - ->method('isInstalled') - ->with('groupfolders') - ->willReturn(true); - - $this->assertTrue($this->service->hasGroupfolderApp()); - } - - public function testSetContentStorageRejectsUnsupportedValue(): void - { - $this->settingMapper->expects($this->never())->method('setSetting'); - $this->expectException(InvalidArgumentException::class); - - $this->service->setContentStorage(value: 'cassette-tape'); - } - - public function testSetContentStoragePersistsKnownValues(): void - { - $this->settingMapper - ->expects($this->once()) - ->method('setSetting') - ->with(AdminSetting::KEY_CONTENT_STORAGE, 'groupfolder'); - - $this->service->setContentStorage(value: SetupWizardService::STORAGE_GROUPFOLDER); - } - - public function testGetContentStorageDefaultsToDatabase(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_CONTENT_STORAGE, null) - ->willReturn(null); - - $this->assertSame('database', $this->service->getContentStorage()); - } - - public function testGetContentStorageReturnsPersisted(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_CONTENT_STORAGE, null) - ->willReturn('groupfolder'); - - $this->assertSame('groupfolder', $this->service->getContentStorage()); - } +class SetupWizardServiceTest extends TestCase { + private SetupWizardService $service; + + /** @var AdminSettingMapper&MockObject */ + private $settingMapper; + + /** @var IAppManager&MockObject */ + private $appManager; + + protected function setUp(): void { + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->service = new SetupWizardService( + settingMapper: $this->settingMapper, + appManager: $this->appManager + ); + } + + public function testGetWizardStateOnFreshInstance(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(false); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $state = $this->service->getWizardState(); + + $this->assertFalse($state['complete']); + $this->assertSame(2, $state['currentRecommendedStep']); + $this->assertSame('done', $state['stepStatuses']['1']); + $this->assertSame('pending', $state['stepStatuses']['2']); + $this->assertSame('pending', $state['stepStatuses']['3']); + $this->assertSame('skipped', $state['stepStatuses']['4']); + $this->assertSame('skipped', $state['stepStatuses']['5']); + $this->assertSame('skipped', $state['stepStatuses']['6']); + $this->assertSame('pending', $state['stepStatuses']['7']); + } + + public function testGetWizardStateAfterStorageWritten(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(false); + $this->settingMapper->method('getAllAsArray')->willReturn([ + AdminSetting::KEY_CONTENT_STORAGE => 'database', + ]); + + $state = $this->service->getWizardState(); + + $this->assertSame('done', $state['stepStatuses']['2']); + // Step 3 still pending so the recommended step jumps to 3. + $this->assertSame(3, $state['currentRecommendedStep']); + } + + public function testGetWizardStateAfterCompletion(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([ + AdminSetting::KEY_CONTENT_STORAGE => 'database', + AdminSetting::KEY_GROUP_ORDER => ['engineering'], + AdminSetting::KEY_FOOTER_CONFIG => ['layout' => 'structured'], + ]); + + $state = $this->service->getWizardState(); + + $this->assertTrue($state['complete']); + $this->assertSame('done', $state['stepStatuses']['7']); + $this->assertSame('done', $state['stepStatuses']['6']); + // Steps 4/5 are 'skipped' (sibling capabilities pending), so the + // first non-'done' status is Step 4. The wizard "complete" flag + // is the source of truth for hiding the banner; the recommended + // step is purely a UX hint per REQ-WIZ-008. + $this->assertSame(4, $state['currentRecommendedStep']); + } + + public function testMarkWizardCompleteSetsFlagAndReturnsState(): void { + $this->settingMapper + ->expects($this->once()) + ->method('setSetting') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, true); + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $state = $this->service->markWizardComplete(); + + $this->assertTrue($state['complete']); + } + + public function testMarkWizardCompleteIsIdempotent(): void { + // Even when already true the service still writes (idempotent + // semantics — the controller doesn't need a defensive guard). + $this->settingMapper + ->expects($this->exactly(2)) + ->method('setSetting'); + $this->settingMapper->method('getValue')->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $first = $this->service->markWizardComplete(); + $second = $this->service->markWizardComplete(); + + $this->assertSame($first, $second); + } + + public function testGetGroupfolderAvailabilityDelegates(): void { + $this->appManager + ->expects($this->once()) + ->method('isInstalled') + ->with('groupfolders') + ->willReturn(true); + + $this->assertTrue($this->service->hasGroupfolderApp()); + } + + public function testSetContentStorageRejectsUnsupportedValue(): void { + $this->settingMapper->expects($this->never())->method('setSetting'); + $this->expectException(InvalidArgumentException::class); + + $this->service->setContentStorage(value: 'cassette-tape'); + } + + public function testSetContentStoragePersistsKnownValues(): void { + $this->settingMapper + ->expects($this->once()) + ->method('setSetting') + ->with(AdminSetting::KEY_CONTENT_STORAGE, 'groupfolder'); + + $this->service->setContentStorage(value: SetupWizardService::STORAGE_GROUPFOLDER); + } + + public function testGetContentStorageDefaultsToDatabase(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_CONTENT_STORAGE, null) + ->willReturn(null); + + $this->assertSame('database', $this->service->getContentStorage()); + } + + public function testGetContentStorageReturnsPersisted(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_CONTENT_STORAGE, null) + ->willReturn('groupfolder'); + + $this->assertSame('groupfolder', $this->service->getContentStorage()); + } } diff --git a/tests/Unit/Service/SlugGeneratorTest.php b/tests/Unit/Service/SlugGeneratorTest.php index 918f0c96f..03356506f 100644 --- a/tests/Unit/Service/SlugGeneratorTest.php +++ b/tests/Unit/Service/SlugGeneratorTest.php @@ -12,7 +12,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2026 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -25,114 +25,104 @@ /** * Unit tests for {@see SlugGenerator}. */ -class SlugGeneratorTest extends TestCase -{ - /** - * Standard alphanumeric name → simple lowercase slug. - * - * @return void - */ - public function testSlugifyLowercases(): void - { - $this->assertSame('marketing', SlugGenerator::slugify(name: 'Marketing')); - }//end testSlugifyLowercases() +class SlugGeneratorTest extends TestCase { + /** + * Standard alphanumeric name → simple lowercase slug. + * + * @return void + */ + public function testSlugifyLowercases(): void { + $this->assertSame('marketing', SlugGenerator::slugify(name: 'Marketing')); + }//end testSlugifyLowercases() - /** - * Multi-word names → dash-joined. - * - * @return void - */ - public function testSlugifySpacesBecomeDashes(): void - { - $this->assertSame( - 'q1-campaigns', - SlugGenerator::slugify(name: 'Q1 Campaigns') - ); - }//end testSlugifySpacesBecomeDashes() + /** + * Multi-word names → dash-joined. + * + * @return void + */ + public function testSlugifySpacesBecomeDashes(): void { + $this->assertSame( + 'q1-campaigns', + SlugGenerator::slugify(name: 'Q1 Campaigns') + ); + }//end testSlugifySpacesBecomeDashes() - /** - * Punctuation outside the grammar is stripped. - * - * @return void - */ - public function testSlugifyStripsPunctuation(): void - { - $this->assertSame( - 'hello-world', - SlugGenerator::slugify(name: 'Hello, World!') - ); - }//end testSlugifyStripsPunctuation() + /** + * Punctuation outside the grammar is stripped. + * + * @return void + */ + public function testSlugifyStripsPunctuation(): void { + $this->assertSame( + 'hello-world', + SlugGenerator::slugify(name: 'Hello, World!') + ); + }//end testSlugifyStripsPunctuation() - /** - * Repeated separators collapse to one dash. - * - * @return void - */ - public function testSlugifyCollapsesRepeatedDashes(): void - { - $this->assertSame( - 'foo-bar', - SlugGenerator::slugify(name: 'foo --- bar') - ); - }//end testSlugifyCollapsesRepeatedDashes() + /** + * Repeated separators collapse to one dash. + * + * @return void + */ + public function testSlugifyCollapsesRepeatedDashes(): void { + $this->assertSame( + 'foo-bar', + SlugGenerator::slugify(name: 'foo --- bar') + ); + }//end testSlugifyCollapsesRepeatedDashes() - /** - * Names that yield no legal characters → empty slug. - * - * @return void - */ - public function testSlugifyReturnsEmptyOnNoLegalChars(): void - { - $this->assertSame('', SlugGenerator::slugify(name: '!!!')); - }//end testSlugifyReturnsEmptyOnNoLegalChars() + /** + * Names that yield no legal characters → empty slug. + * + * @return void + */ + public function testSlugifyReturnsEmptyOnNoLegalChars(): void { + $this->assertSame('', SlugGenerator::slugify(name: '!!!')); + }//end testSlugifyReturnsEmptyOnNoLegalChars() - /** - * Slug exceeding 128 characters → truncated. - * - * @return void - */ - public function testSlugifyTruncatesLongInput(): void - { - $longName = str_repeat('a', 200); - $slug = SlugGenerator::slugify(name: $longName); + /** + * Slug exceeding 128 characters → truncated. + * + * @return void + */ + public function testSlugifyTruncatesLongInput(): void { + $longName = str_repeat('a', 200); + $slug = SlugGenerator::slugify(name: $longName); - $this->assertLessThanOrEqual(SlugGenerator::MAX_LENGTH, strlen($slug)); - }//end testSlugifyTruncatesLongInput() + $this->assertLessThanOrEqual(SlugGenerator::MAX_LENGTH, strlen($slug)); + }//end testSlugifyTruncatesLongInput() - /** - * isValid: legal grammar accepted. - * - * @return void - */ - public function testIsValidAcceptsLegalSlugs(): void - { - $this->assertTrue(SlugGenerator::isValid(slug: 'q1-campaigns')); - $this->assertTrue(SlugGenerator::isValid(slug: 'snake_case')); - $this->assertTrue(SlugGenerator::isValid(slug: 'abc123')); - }//end testIsValidAcceptsLegalSlugs() + /** + * isValid: legal grammar accepted. + * + * @return void + */ + public function testIsValidAcceptsLegalSlugs(): void { + $this->assertTrue(SlugGenerator::isValid(slug: 'q1-campaigns')); + $this->assertTrue(SlugGenerator::isValid(slug: 'snake_case')); + $this->assertTrue(SlugGenerator::isValid(slug: 'abc123')); + }//end testIsValidAcceptsLegalSlugs() - /** - * isValid: empty / uppercase / punctuation rejected. - * - * @return void - */ - public function testIsValidRejectsIllegalSlugs(): void - { - $this->assertFalse(SlugGenerator::isValid(slug: '')); - $this->assertFalse(SlugGenerator::isValid(slug: 'Q1')); - $this->assertFalse(SlugGenerator::isValid(slug: 'q1 campaigns')); - $this->assertFalse(SlugGenerator::isValid(slug: 'q1!')); - }//end testIsValidRejectsIllegalSlugs() + /** + * isValid: empty / uppercase / punctuation rejected. + * + * @return void + */ + public function testIsValidRejectsIllegalSlugs(): void { + $this->assertFalse(SlugGenerator::isValid(slug: '')); + $this->assertFalse(SlugGenerator::isValid(slug: 'Q1')); + $this->assertFalse(SlugGenerator::isValid(slug: 'q1 campaigns')); + $this->assertFalse(SlugGenerator::isValid(slug: 'q1!')); + }//end testIsValidRejectsIllegalSlugs() - /** - * isValid: 128-character cap enforced. - * - * @return void - */ - public function testIsValidRejectsOverLengthSlugs(): void - { - $this->assertFalse( - SlugGenerator::isValid(slug: str_repeat('a', 129)) - ); - }//end testIsValidRejectsOverLengthSlugs() + /** + * isValid: 128-character cap enforced. + * + * @return void + */ + public function testIsValidRejectsOverLengthSlugs(): void { + $this->assertFalse( + SlugGenerator::isValid(slug: str_repeat('a', 129)) + ); + }//end testIsValidRejectsOverLengthSlugs() }//end class diff --git a/tests/Unit/Service/SvgSanitiserTest.php b/tests/Unit/Service/SvgSanitiserTest.php index ae3dcece1..cbe9d4b7f 100644 --- a/tests/Unit/Service/SvgSanitiserTest.php +++ b/tests/Unit/Service/SvgSanitiserTest.php @@ -17,7 +17,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * * SPDX-FileCopyrightText: 2024 LaunchPad Contributors - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-License-Identifier: EUPL-1.2 */ declare(strict_types=1); @@ -25,243 +25,224 @@ namespace Unit\Service; use OCA\LaunchPad\Service\SvgSanitiser; -use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; -class SvgSanitiserTest extends TestCase -{ - private SvgSanitiser $sanitiser; - - protected function setUp(): void - { - $this->sanitiser = new SvgSanitiser(); - } - - public function testCleanSvgRoundTrips(): void - { - $svg = '' - . '' - . ''; - - $result = $this->sanitiser->sanitize($svg); - - $this->assertNotNull($result); - $this->assertStringContainsString('assertStringContainsString('fill="red"', $result); - } - - public function testScriptElementRemoved(): void - { - $svg = '' - . '' - . '' - . ''; - - $result = $this->sanitiser->sanitize($svg); - - $this->assertNotNull($result); - $this->assertStringNotContainsString('assertStringNotContainsString('alert', $result); - $this->assertStringContainsString('