fix(routing): make public share, send and fill links reach the recipient - #617
Conversation
The SPA is moving its recipient links from hash URLs to paths under the
anonymous shell (/apps/keepiq/public/share/link/<token> and friends), so
a load or refresh of any such path must serve the shell without a
Nextcloud session. A publicShell catch-all route (/public/{path},
mirroring the AppHost dashboard#page / dashboard#catchAll split - a
distinct name, because Symfony silently replaces same-named routes)
serves the same #[PublicPage] template at every subpath. It sits in
$extra, so it precedes the authenticated /{path} fallback.
…links The router runs createWebHistory, which never reads a URL fragment - yet the recipient links in the wild are hash URLs (/apps/keepiq/public#/share/link/<token>): vue-router saw the bare /public path, the catch-all redirected to /, and the vault guard bounced account-less recipients onto the lock screen, asking for a master password they do not have. Two changes, one per link population: - routerBase() extends to /apps/keepiq/public on the anonymous shell, so the SAME manifest routes (/share/link/:token, /send/:token, /share/request/:token) resolve on both shells unchanged. - Links already sent cannot be regenerated, so a bootstrap handoff (src/bootstrap/hash-route-handoff.js) rewrites any legacy #/ URL in place with replaceState before the router is created. A legacy fragment-mode send key (?k= INSIDE the old SPA fragment) moves to a new #k= fragment, never into the real query string, which the browser would send to the server on the next refresh. isPublicSurface() now classifies by path prefix, keeping the hash prefixes for reads that can happen before the handoff.
The builders of recipient-facing links still emitted the retired hash form, which the createWebHistory router never reads - recipients got the lock screen. All four now emit paths under the anonymous shell: - LinkShareController (share links) - ApplicationSecretRequestsController (application fill links, a fifth site the fix brief's list missed) - fillLink.js (secret-request fill links) - ephemeralSend.js (sends) The ephemeral-send content key rides as a #k= fragment - still never transmitted - and the access page reads it from the fragment with a fallback to the legacy route query, so links from before this change keep decrypting. The send page's card styling rides along with its fragment-key reader. The stale hash-routing wording in the manifest note is corrected.
… PWA manifest Sweeping for the retired hash scheme turned up three more builders outside the fix brief's list, all on the AUTHENTICATED shell: the unified-search provider, the notification deep link, and the PWA manifest's vault shortcut. Their /apps/keepiq/#/secrets/<id> links fell through the catch-all to the dashboard - the deep link silently swallowed. They now emit paths; the gated route lands on the lock screen with the path as returnUrl and resumes there after unlock.
No spec ever opened a share, send, or fill link without a session - the existing page-surface specs drive the same components on the authenticated shell with the admin cookie jar, which is precisely why dead recipient links could ship. This spec mints fixtures through the authenticated API (the server stores only opaque blobs, so no real crypto is needed) and opens the links in a browser context with an EMPTY storage state: the path form, the RETIRED hash form (through the bootstrap handoff), a legacy fragment-mode send - asserting the content key stays out of the query string - and a fill link.
…d-password message Every string an account-less recipient sees on the share access and ephemeral-send pages now has a Dutch translation (18 keys; the fill page was already covered). The nl parity ratchet is lowered 382 -> 362 so the paid-off debt cannot silently return. Also adds "Invalid password. Please try again.", translated into all 36 locales, for the link-share access page to adopt: that page compares nothing against a second entry, so the existing "The password does not match" wording - correct on the lock screen, where it compares the two typed setup passwords - is wrong there.
…ecret as copyable fields The recipient card sat pinned in the top-left corner: core's guest layout makes #content.app-public a flex row, so #keepiq-app shrank to the card's width and the card's `margin: auto` had no free space to distribute. The app mount now fills the flex row (app.css) and the public shell centres its card on both axes with flex + margin:auto, which stays scrollable when the card outgrows the viewport. All three recipient views share the same card treatment. The revealed link share was a bare <dl> that printed a card secret as its raw JSON composite. It now renders labelled field rows with a copy button per row (CopyButton, clipboard auto-clear) and masked sensitive values behind an eye toggle - card/identity composites are parsed via parsePayload() into Card number / Expiry / CVV / PIN / Cardholder rows (number, CVV, PIN and BSN masked), additional fields render as masked rows, and a plain key stays one masked "Secret value" row. The usage-cap notice moved to the top of the card, under the heading, where a whole-card status banner belongs. The terminal not-found/expired state is a recognisable error page (broken-link icon, "Link not found or expired" heading, translated explanation) instead of the raw English server message, and a failed decrypt now says "Invalid password. Please try again." The password form moved to house-style controls.
The freshly created link and password - which cannot be retrieved again - render in a highlighted panel so they read as the thing to take away from the dialog. Sections gained real padding and separators, rows breathe, the password is proper monospace at a readable size, and the disabled user-sharing affordance is visually set apart from the working controls. No copy changes; the folder-sharing e2e selectors are unchanged.
…ic-share-link-routing
Four KeepiqNotifierTest tests failed across all six CI combos, and the URLs were not why: commit 33bf6c7 already moved every expectation to the path form. That same commit round-tripped the file through a CP1252 reader, turning all eight em dashes into "a-euro-quote" (c3a2 e282ac e2809d where e2 8094 belongs). Four of the eight sit inside asserted message strings, so exactly four tests failed - the other four occurrences are in comments and were silent. Restores U+2014 in all eight places. Nothing else about the file changes; development still carries the correct bytes, so the diff against it is now empty for these lines.
Hydra gate-16 failed on src/views/EphemeralSendAccess.vue::fragmentKey - the computed was added with an inline comment but no docblock, so the changed-method scan found no @SPEC line. Adds the docblock with the anonymous-recipient anchor the neighbouring mounted() hook already uses.
isPublicSurface() classified any path merely CONTAINING '/apps/keepiq/public' as a recipient surface, so /apps/keepiq/publications/..., /apps/keepiq/publicfoo and /apps/keepiq/secrets/public all answered true. It is not the security gate - access is authorised server-side by scoped token, and the locked-vault gate is the name-based isPublicRoute() - but a classifier that is wrong for a whole family of paths invites being mistaken for one, and its docblock read as if it were authoritative. Splits the app-relative remainder off the pathname first, then compares prefixes against that. The app segment is still LOCATED rather than anchored, so sub-directory installs (/nextcloud/index.php/apps/keepiq/..) keep resolving; what it now requires is that the remainder begin at a "/" boundary, which also rules out another app whose id merely starts with this one. The docblock says plainly what the function is not. Specs cover the former false positives, the neighbouring-app id, and the webroot install the boundary check must not cost.
publicShell#pageCatchAll ('path' => '.+') matches every GET below
/public/ and hands it to a #[PublicPage] controller, so a session-gated
route mistakenly placed there is shadowed: the anonymous shell renders,
the intended handler never runs, and the author sees a working page
rather than a 404. The shell exposes no data, so there is nothing to
exploit today - the hazard is that a routing slip becomes a silent auth
bypass instead of an error.
A comment cannot enforce that, so this adds both: the warning on the
route entry, and a contract test that reads the route table as source
text and asserts every /public route resolves to a method actually
declaring #[PublicPage] (and #[NoCSRFRequired], which a session-less
recipient needs). Injecting dashboard#summary at /public/oops fails it
with the route named. A negative control keeps the check honest.
Not done instead: rejecting requests that carry a valid Nextcloud
session. A logged-in user opening a recipient link is ordinary - the
sharer checking their own link, or a colleague with an account but no
vault suite - and that reject would break it. The rationale is recorded
in the test.
The coverage ratchet blocks this branch on PublicShellController, and the reason only became visible once PHPUnit itself stopped failing first. The guard scopes to changed PHP files and keeps head-only method buckets on the head side, so pageCatchAll() — two statements, added by this branch, with no test class anywhere for the file — entered the denominator uncovered and pulled the scoped ratio under the merge base. The other five changed lib/ files are comment and string edits inside methods that were already covered, so nothing else moved. Covering the file rather than the method: page() was untested too, and a shell at 0% is what let a new public method land uncovered without anything looking wrong. The assertions pin what would actually strand a recipient — RENDER_AS_BASE rather than the session-expecting user layout, the wasm-unsafe-eval directive the Argon2id derivation needs to run at all, and the identity of the two methods across the three live link shapes, since page() answers /public and pageCatchAll() answers everything below it and a divergence would break only one half with a correct-looking route table.
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 536/536 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-04 13:57 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (Quick mode)
This is the same tree I reviewed on #610 — head SHA 2b64bc5 is byte-identical, and #617's diff is a strict subset (108 of those 144 files, zero files that weren't already in #610's diff). The diff shrank only because the parent stack (#601/#605/#607) landed in development, so the PR body's "thirteen commits and nothing else" claim checks out.
I re-verified the four fixes independently rather than taking the commit messages at their word:
| Fix | Commit | Result |
|---|---|---|
isPublicSurface() segment boundary |
c9d7cc3 |
appRelativePath() requires the remainder to be empty or /-prefixed, so keepiq-old and keepiqfoo reject; /publications/… no longer matches /public/. All three PUBLIC_PATH_PREFIXES end in /, so they're boundary-safe by construction. |
/public catch-all guardrail |
c9d7cc3 + 6de55b7 |
Warning sits on the route entry where a future author will actually see it. The contract test is stronger than what I asked for — it has anti-vacuity guards (testTheRouteTableParses, assertGreaterThanOrEqual) so it can't pass on an empty parse, plus a real negative control on dashboard#summary. |
| Em-dash mojibake | 430b6bb |
Exactly 8 proper U+2014 (xxd confirms e2 80 94), zero â sequences — matches "all eight places". |
PublicShellController coverage |
2b64bc5 |
Pins RENDER_AS_BASE, the wasm-unsafe-eval directive the Argon2id derivation needs, method identity across all three live link shapes, and that the subpath stays out of the template params. |
CI is green at head: 0 failures, 0 pending — Hydra Gates, the 6-combo PHPUnit matrix, Newman, CodeQL and the coverage ratchet all pass.
The two non-blocking 🟢 nits from #610 (the PR-body "browser context" wording, and commit 8 labelled style(…) while restructuring the template) are deliberately not re-raised here — you already saw both and chose to leave them, and neither affects the code.
Nice work on the follow-up sweep. Finding isPublicSurface()'s substring match yourself and then splitting the remainder on a boundary — rather than patching the one path I named — is the right shape of fix, and the routing-slip contract test closes a hazard that would otherwise have been invisible until it bit someone.
Important
New branch. This supersedes #610, which is closed. Its branch carried a git-history defect that made GitHub declare every review stale the moment it was submitted, so no approval could ever stick. The work is unchanged and re-landed on a clean branch (
fix/public-share-link-routing-2), plus the follow-up commits listed below. Review here; #610 needs no further attention.Every recipient link (share / send / fill) was dead for the person it was sent to. The router moved to
createWebHistory, but the link builders still emitted hash URLs (/apps/keepiq/public#/share/link/<token>); the fragment is never read under path routing, so the catch-all redirected to/and the vault guard bounced account-less recipients onto the lock screen — asked for a master password they do not have.The fix lands as a stack, each commit green on its own:
serve the anonymous shell at every /public subpath — a
publicShellcatch-all route (/public/{path}, mirroring the AppHost dashboard catch-all) serves the#[PublicPage]shell for every recipient path, ahead of the authenticated/{path}fallback.resolve recipient routes as paths and rescue sent hash links —
routerBase()extends to/apps/keepiq/publicso the same manifest routes resolve on both shells, and a bootstrap handoff rewrites any legacy#/URL in place (links already in inboxes cannot be regenerated). A legacy in-fragment?k=send key moves to a new#k=fragment — never into the real query string, which the browser would send to the server on refresh.emit path links from the recipient link builders — link shares, application fill links,
fillLink.jsand sends; the send access page reads the key from the fragment with a legacy-query fallback.emit path deep links from search, notifications and the PWA manifest — three more hash builders the sweep found on the authenticated shell; their deep links were silently swallowed by the catch-all.
test(e2e): cover the anonymous recipient — the first specs to open share/send/fill links in a browser context with no session, including the legacy hash form and the key-stays-out-of-the-query assertion. This gap is why the bug shipped.
fix(l10n) — every recipient-facing string translated into Dutch (nl ratchet lowered 382 → 362), plus a new "Invalid password. Please try again." key in all 36 locales — the link-share page compares nothing against a second entry, so "does not match" was wrong there.
fix(public-pages) — the recipient card centres on the guest layout (the app mount shrank inside core's flex row); the revealed secret is labelled field rows with per-field copy and masked sensitive values (card/identity JSON parsed into fields, no raw JSON); the not-found/expired state is a proper error page instead of the raw English server message.
style(share-dialog) — the one-time link + password in a highlighted panel, real padding and separated sections.
Five more commits landed after #610 was opened, from CI and from a follow-up sweep of the new route:
fix(tests): restore the em dashes mangled in the notifier expectations — four
KeepiqNotifierTesttests failed in all six CI combos, and the URLs were not why: commit 4 had already moved every expectation to the path form. That same commit round-tripped the file through a CP1252 reader, corrupting all eight em dashes; four sit inside asserted message strings, so exactly four tests failed and the other four occurrences were silent. Restores U+2014 in all eight places, nothing else.fix(spec-coverage): tag the send access page's fragment-key computed — hydra gate-16 failed on
EphemeralSendAccess.vue::fragmentKey; the computed was added with an inline comment but no docblock, so the changed-method scan found no@specline.fix(routing): match the public shell on a segment boundary —
isPublicSurface()classified any path merely containing/apps/keepiq/publicas a recipient surface, so/apps/keepiq/publications/…,/apps/keepiq/publicfooand/apps/keepiq/secrets/publicall answered true. It is not the security gate (access is authorised server-side by scoped token, and the locked-vault gate is the name-basedisPublicRoute()), but a classifier wrong for a whole family of paths invites being mistaken for one. The remainder is now split off first and required to begin at a/boundary; sub-directory installs keep resolving, and the docblock says plainly what the function is not.test(routes): forbid authenticated endpoints under /public —
publicShell#pageCatchAllmatches every GET below/public/, so a session-gated route mistakenly placed there would be shadowed: the anonymous shell renders, the intended handler never runs, and the author sees a working page rather than a 404. Nothing is exploitable today (the shell exposes no data); the hazard is that a routing slip becomes a silent auth bypass. Adds a warning on the route entry and a contract test that reads the route table as source text and asserts every/publicroute resolves to a method declaring#[PublicPage]and#[NoCSRFRequired], with a negative control.test(coverage): cover the public shell controller — the coverage ratchet blocked the branch on
PublicShellController, visible only once PHPUnit stopped failing first:pageCatchAll()entered the scoped denominator uncovered, in a file with no test class at all. Covers the file rather than the method (page()was untested too) and pins what would actually strand a recipient:RENDER_AS_BASErather than the session-expecting user layout, thewasm-unsafe-evaldirective the Argon2id derivation needs, and the identity of the two methods across the three live link shapes.Review note: the parent branch this work was originally stacked on (
fix/cards-table-type-icon-strength-badge, #607) has since merged intodevelopment, so the diff here is the thirteen commits above and nothing else; the merge commit carrying that parent in is the only extra entry in the commit list.