From 8a33116f9949106d1fcb1d7064c4d65cd2397017 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 10:04:26 +0200 Subject: [PATCH] feat(router): move keepiq off hash routing to clean path URLs Fourth app off `#` routing, after stackiq (softwarecatalog#899), zaakafhandelapp (#609) and opencatalogi (#1341). Two parts: 1. createWebHashHistory -> createWebHistory. 2. routerBase(), derived from the URL actually being served. Nextcloud serves the app under BOTH /apps/keepiq/... and /index.php/apps/keepiq/..., but generateUrl() returns only the form the instance is configured for. Arriving on the other leaves the path outside the router base, vue-router cannot resolve it, and the catch-all redirects to '/' -- no error, deep link silently swallowed. This app has the sharpest version of that problem: keepiq's share and send links (/share/link/:token, /send/:token, /share/request/:token) are handed to people OUTSIDE the app, in messages and emails, in whichever URL shape the sender's client produced. A swallowed deep link there is a share that silently does nothing. 6 spec files built URLs with a hash; all now use real paths. Verified against the published @conduction/nextcloud-vue (USE_LOCAL_LIB=false). The vault is locked in this environment, so every route redirects to the lock screen -- which is itself the proof that the router parsed the request first: /apps/keepiq/secrets -> /apps/keepiq/lock?returnUrl=/secrets /index.php/apps/keepiq/secrets -> /index.php/apps/keepiq/lock?returnUrl=/secrets RELOAD on the /index.php form -> 200, still on the lock path No hash anywhere, both URL forms work, and the requested route survives into returnUrl. (/folders answers returnUrl=/ because the real route is /folders/:folderId, so the catch-all correctly claims the bare path.) eslint exits 0 and prettier --check on the FULL glob is clean. --- src/main.js | 33 +++++++++++++++++-- tests/e2e/spec-coverage/navigation.spec.ts | 4 +-- tests/e2e/visual/keepiq-pages.visual.spec.ts | 6 ++-- tests/e2e/visual/keepiq.visual.spec.ts | 2 +- .../e2e/workflows/compromise-recovery.spec.ts | 4 +-- tests/e2e/workflows/page-surfaces.spec.ts | 6 ++-- tests/e2e/workflows/password-health.spec.ts | 2 +- 7 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/main.js b/src/main.js index 4b95ba040..48ed7d63c 100644 --- a/src/main.js +++ b/src/main.js @@ -35,7 +35,7 @@ import { } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { createApp, h } from 'vue' -import { createRouter, createWebHashHistory } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import { ensureSkipActionsTarget } from './bootstrap/skip-actions.js' import appIcons from './icons.js' @@ -133,8 +133,37 @@ function routesFromManifest(manifest) { return routes } +/** + * The router base for THIS page load. + * + * ⚠️ `generateUrl('/apps/keepiq')` alone is not enough. Nextcloud serves the + * app under BOTH `/apps/keepiq/...` and `/index.php/apps/keepiq/...`, but + * `generateUrl()` returns only the form the instance is configured for. A + * visitor arriving on the other form — a bookmark, an emailed deep link, an + * integration that hardcodes `/index.php` — falls outside the router base, + * vue-router cannot resolve the path, and the catch-all redirects to `/`. They + * land on the dashboard with no error: the deep link is silently swallowed. + * + * Hash routing never had this, because the route travelled in the fragment and + * the path prefix was irrelevant. This app's own e2e helpers use the + * `/index.php` form (`APP_BASE = '/index.php/apps/keepiq'`), so without this + * every deep link the suite makes would break. + * + * It matters more here than elsewhere: keepiq's share and send links + * (`/share/link/:token`, `/send/:token`, `/share/request/:token`) are handed to + * people OUTSIDE the app, in messages and emails, in whichever URL shape the + * sender's client produced. A swallowed deep link there is a share that + * silently does nothing. + * + * @return {string} The base path vue-router should strip from the URL. + */ +function routerBase() { + const match = window.location.pathname.match(/^(.*\/apps\/keepiq)(?:\/|$)/) + return match ? match[1] : generateUrl('/apps/keepiq') +} + const router = createRouter({ - history: createWebHashHistory(generateUrl('/apps/keepiq')), + history: createWebHistory(routerBase()), routes: routesFromManifest(mergedManifest), }) diff --git a/tests/e2e/spec-coverage/navigation.spec.ts b/tests/e2e/spec-coverage/navigation.spec.ts index b48612409..891ba9a0c 100644 --- a/tests/e2e/spec-coverage/navigation.spec.ts +++ b/tests/e2e/spec-coverage/navigation.spec.ts @@ -74,7 +74,7 @@ test.describe('App navigation — manifest menu', () => { // The Dashboard entry points at the keepiq app root (not /apps/dashboard). // Matched on the hash SUFFIX, not the whole href: under vue-router 4 the // hash-history links render relative (`#/`) rather than carrying the - // absolute app base (`/apps/keepiq/#/`). Both resolve to the same route + // absolute app base (`/apps/keepiq/`). Both resolve to the same route // from any keepiq page; asserting the literal absolute form would pin a // router implementation detail rather than the requirement. await expect(nav.locator('a[href$="#/"]').first()).toBeVisible() @@ -93,7 +93,7 @@ test.describe('App navigation — manifest menu', () => { }) => { await unlockVault(page) - // "Lock vault" is a keepiq-owned route (/apps/keepiq/#/lock) in the + // "Lock vault" is a keepiq-owned route (/apps/keepiq/lock) in the // settings foldout — expand it and click the entry. App.vue's $route // watcher calls session.lock() on entering /lock while unlocked, so // this drives the real re-lock flow end to end. diff --git a/tests/e2e/visual/keepiq-pages.visual.spec.ts b/tests/e2e/visual/keepiq-pages.visual.spec.ts index c6613ab91..cf262c8d6 100644 --- a/tests/e2e/visual/keepiq-pages.visual.spec.ts +++ b/tests/e2e/visual/keepiq-pages.visual.spec.ts @@ -177,7 +177,7 @@ test.describe('Keepiq — routed page baselines', () => { (r: any) => r.status === 'pending', ) expect(pending, 'no pending secret request to fill').toBeTruthy() - await page.goto(`${APP_BASE}/#/share/request/${pending.token}`, { + await page.goto(`${APP_BASE}/share/request/${pending.token}`, { waitUntil: 'domcontentloaded', }) await shootComponent(page, 'secret-request-fill', 'secret-request-fill.png') @@ -205,7 +205,7 @@ test.describe('Keepiq — routed page baselines', () => { ).toBeLessThan(300) const token = created.json.token ?? created.json.data?.token expect(token, 'the created link share carries no token').toBeTruthy() - await page.goto(`${APP_BASE}/#/share/link/${token}`, { + await page.goto(`${APP_BASE}/share/link/${token}`, { waitUntil: 'domcontentloaded', }) await shootComponent(page, 'link-share-access', 'link-share-access.png') @@ -228,7 +228,7 @@ test.describe('Keepiq — routed page baselines', () => { ).toBeLessThan(300) const token = created.json.token ?? created.json.data?.token expect(token, 'the created ephemeral send carries no token').toBeTruthy() - await page.goto(`${APP_BASE}/#/send/${token}`, { + await page.goto(`${APP_BASE}/send/${token}`, { waitUntil: 'domcontentloaded', }) await shootComponent(page, 'send-access-page', 'ephemeral-send-access.png') diff --git a/tests/e2e/visual/keepiq.visual.spec.ts b/tests/e2e/visual/keepiq.visual.spec.ts index 0cd3d445b..27d315ab1 100644 --- a/tests/e2e/visual/keepiq.visual.spec.ts +++ b/tests/e2e/visual/keepiq.visual.spec.ts @@ -17,6 +17,6 @@ const APP = '/index.php/apps/keepiq' test.describe('Keepiq — visual baselines', () => { test('dashboard', async ({ page }) => { - await shootSurface(page, `${APP}/#/`, 'dashboard.png') + await shootSurface(page, `${APP}/`, 'dashboard.png') }) }) diff --git a/tests/e2e/workflows/compromise-recovery.spec.ts b/tests/e2e/workflows/compromise-recovery.spec.ts index 3478ee946..78b11b7cc 100644 --- a/tests/e2e/workflows/compromise-recovery.spec.ts +++ b/tests/e2e/workflows/compromise-recovery.spec.ts @@ -109,7 +109,7 @@ async function clickByLabel(page: Page, label: string): Promise { * @return True when setup ran; false when the user already owned a suite. */ async function setUpVault(page: Page): Promise { - await page.goto(`${APP_BASE}/#/lock`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP_BASE}/lock`, { waitUntil: 'domcontentloaded' }) await page .locator('.lock-screen__card') .waitFor({ state: 'visible', timeout: 30_000 }) @@ -410,7 +410,7 @@ test.describe('Workflow: compromise recovery — encryption-suites/spec.md', () // The negative case is the one that regresses quietly: a banner shown to // everyone gets noticed immediately, one shown to nobody does not. await loginAsVaultUser(page) - await page.goto(`${APP_BASE}/#/lock`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP_BASE}/lock`, { waitUntil: 'domcontentloaded' }) await page .locator('.lock-screen__card') .waitFor({ state: 'visible', timeout: 30_000 }) diff --git a/tests/e2e/workflows/page-surfaces.spec.ts b/tests/e2e/workflows/page-surfaces.spec.ts index 77a3d67d7..dc365b9c4 100644 --- a/tests/e2e/workflows/page-surfaces.spec.ts +++ b/tests/e2e/workflows/page-surfaces.spec.ts @@ -399,7 +399,7 @@ test.describe('Routed page surfaces — public recipient routes', () => { pending, 'no pending secret request — the dev seed is missing', ).toBeTruthy() - await page.goto(`${APP_BASE}/#/share/request/${pending.token}`, { + await page.goto(`${APP_BASE}/share/request/${pending.token}`, { waitUntil: 'domcontentloaded', }) await expect( @@ -448,7 +448,7 @@ test.describe('Routed page surfaces — public recipient routes', () => { ).toBeLessThan(300) const token = created.json.token ?? created.json.data?.token expect(token, 'the created link share carries no token').toBeTruthy() - await page.goto(`${APP_BASE}/#/share/link/${token}`, { + await page.goto(`${APP_BASE}/share/link/${token}`, { waitUntil: 'domcontentloaded', }) await expect(page.locator('[data-testid="link-share-access"]')).toBeVisible({ @@ -487,7 +487,7 @@ test.describe('Routed page surfaces — public recipient routes', () => { ).toBeLessThan(300) const token = created.json.token ?? created.json.data?.token expect(token, 'the created ephemeral send carries no token').toBeTruthy() - await page.goto(`${APP_BASE}/#/send/${token}`, { + await page.goto(`${APP_BASE}/send/${token}`, { waitUntil: 'domcontentloaded', }) await expect(page.locator('[data-testid="send-access-page"]')).toBeVisible({ diff --git a/tests/e2e/workflows/password-health.spec.ts b/tests/e2e/workflows/password-health.spec.ts index c493bdbde..567a419b1 100644 --- a/tests/e2e/workflows/password-health.spec.ts +++ b/tests/e2e/workflows/password-health.spec.ts @@ -87,7 +87,7 @@ test.describe('password health', () => { // redirected to the lock gate by the zero-knowledge router guard (the same // behaviour the gated-routes spec verifies for every in-app route), so the // dashboard — and any health data — never mounts. - await page.goto(`${APP_BASE}/#/`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP_BASE}/`, { waitUntil: 'domcontentloaded' }) await expect(page.locator('.lock-screen')).toBeVisible({ timeout: 20_000 }) // No health data leaks while locked: no strength badges, no dashboard card.