Skip to content

fix(routing): make public share, send and fill links reach the recipient - #617

Merged
remko48 merged 14 commits into
developmentfrom
fix/public-share-link-routing-2
Sep 4, 2026
Merged

fix(routing): make public share, send and fill links reach the recipient#617
remko48 merged 14 commits into
developmentfrom
fix/public-share-link-routing-2

Conversation

@remko48

@remko48 remko48 commented Sep 4, 2026

Copy link
Copy Markdown
Member

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:

  1. serve the anonymous shell at every /public subpath — a publicShell catch-all route (/public/{path}, mirroring the AppHost dashboard catch-all) serves the #[PublicPage] shell for every recipient path, ahead of the authenticated /{path} fallback.

  2. resolve recipient routes as paths and rescue sent hash linksrouterBase() extends to /apps/keepiq/public so 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.

  3. emit path links from the recipient link builders — link shares, application fill links, fillLink.js and sends; the send access page reads the key from the fragment with a legacy-query fallback.

  4. 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.

  5. 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.

  6. 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.

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

  8. 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:

  1. fix(tests): restore the em dashes mangled in the notifier expectations — four KeepiqNotifierTest tests 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.

  2. 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 @spec line.

  3. fix(routing): match the public shell on a segment boundaryisPublicSurface() 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 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.

  4. test(routes): forbid authenticated endpoints under /publicpublicShell#pageCatchAll matches 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 /public route resolves to a method declaring #[PublicPage] and #[NoCSRFRequired], with a negative control.

  5. 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_BASE rather than the session-expecting user layout, the wasm-unsafe-eval directive 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 into development, 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.

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.
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.
@remko48 remko48 changed the title fix(routing): make public share, send and fill links reach the recipient- #610 fix(routing): make public share, send and fill links reach the recipient Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 12ce3f2

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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@remko48
remko48 merged commit 07f3ffc into development Sep 4, 2026
97 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants