diff --git a/README.md b/README.md index aa38ff521..b33158bf5 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ npm run lite:serve # http://localhost:4173/ `out/` then holds one shell per locale and surface plus the deployer files: `config.json` (server URL, app name, login branding; editable without a rebuild), `policy.json`, `manifest.webmanifest`, `_redirects` and `_headers` (Netlify/Cloudflare), `nginx.conf.example`, `Caddyfile.example`, a `404.html` that replays deep links on hosts without rewrite rules, and `LITE-README.md` with the three-step setup. Build inputs: `LITE_JMAP_SERVER_URL`, `LITE_APP_NAME`, `LITE_ALLOW_CUSTOM_ENDPOINT`, `LITE_REMEMBER_ME`, `LITE_DEMO_MODE`, `LITE_LOCALES` (e.g. `en,de`), `NEXT_PUBLIC_BASE_PATH`. -Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright. +Lite keeps everything that runs in the browser (multi-account, password and TOTP login, "remember me" via Stalwart refresh tokens, themes, deep links, demo mode). Server-backed features are off: the admin console and setup wizard, plugins and sidebar apps, settings sync, OAuth/SSO, the account security tab, ICS URL subscriptions and CalDAV discovery, sender favicons, office editing, web push, the update banner, the "Default apps" protocol-handler registration. Password logins go through Stalwart's token endpoints in the browser (`/api/auth` + `/auth/token`, so those need CORS as well); without "remember me" the refresh token lives in sessionStorage and the session ends with the tab. `scripts/lite/verify.mjs` fails the build if a client chunk references a server endpoint that is not documented in `scripts/lite/lib.mjs`, and `npm run test:lite-smoke` drives a demo export with Playwright. ### Development diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 179450356..41cb94283 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +import { IS_LITE } from '@/lib/lite'; import { MessageListOrderSettings } from './message-list-order-settings'; const DENSITY_PREVIEW: Record = { @@ -129,9 +130,12 @@ export function AppearanceSettings() { )} - - updateSetting('senderFavicons', checked)} /> - + {/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */} + {!IS_LITE && ( + + updateSetting('senderFavicons', checked)} /> + + )} updateSetting('showAvatarsInJunk', checked)} /> diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 93fa43b68..2864b3958 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -22,6 +22,7 @@ import { revokePushDevice, } from '@/lib/web-push'; import type { PushDevice } from '@/lib/web-push'; +import { IS_LITE } from '@/lib/lite'; import { resolveActiveRelayUrl, resolvePushRelayOptions, @@ -104,8 +105,10 @@ export function NotificationSettings() { const busy = pushStatus.kind === 'busy'; const pushEnabled = pushStatus.kind === 'enabled'; + // In the static Lite build push is off by design (no service worker, see + // components/service-worker-registration.tsx), not a browser limitation. const statusDescription = pushStatus.kind === 'unsupported' - ? `${t('push.status_unsupported')} ${t('push.ios_hint')}` + ? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) : busy ? t('push.status_busy') : pushEnabled diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index b17f360a1..463ffe50b 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -89,6 +89,7 @@ import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useIsFocusedProTab } from '@/hooks/use-pane-context'; import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; +import { IS_LITE } from '@/lib/lite'; import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; import { @@ -535,7 +536,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, - { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, + // The handler URLs point at /protocol/*, server routes the static Lite export does not ship. + ...(IS_LITE ? [] : [{ id: 'protocol_handlers' as Tab, label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' as TabGroup }]), // Appearance { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, diff --git a/hooks/__tests__/use-lite-link-segments.test.ts b/hooks/__tests__/use-lite-link-segments.test.ts index c4df9b78d..c3210bcd3 100644 --- a/hooks/__tests__/use-lite-link-segments.test.ts +++ b/hooks/__tests__/use-lite-link-segments.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderHook } from '@testing-library/react'; +import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import { Suspense, createElement } from 'react'; const liteState = vi.hoisted(() => ({ IS_LITE: false })); @@ -53,6 +54,38 @@ describe('useLiteLinkSegments', () => { expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBeNull(); }); + it('survives a discarded first render (a sibling suspending on useSearchParams)', async () => { + liteState.IS_LITE = true; + window.history.replaceState({}, '', '/en/contacts/'); + sessionStorage.setItem(LITE_PENDING_PATH_KEY, '/en/contacts/c1/edit'); + + // React throws the first render attempt away when something below the + // Suspense boundary suspends, then renders again from scratch. The hook's + // state initializer runs twice; the parked link must still be there. + let suspended = false; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const seen: Array = []; + function Surface() { + const segments = useLiteLinkSegments('contacts', []); + seen.push(segments); + if (!suspended) { + suspended = true; + throw gate; + } + return createElement('span', { 'data-testid': 'segments' }, JSON.stringify(segments)); + } + render(createElement(Suspense, { fallback: null }, createElement(Surface))); + release(); + await waitFor(() => expect(screen.getByTestId('segments').textContent).toBe('["c1","edit"]')); + // The initializer ran again for the retry and still found the parked link + // (consuming it in the first attempt would have left the retry with []). + expect(seen.length).toBeGreaterThanOrEqual(2); + expect(seen.every((s) => JSON.stringify(s) === '["c1","edit"]')).toBe(true); + expect(`${window.location.pathname}`).toBe('/en/contacts/c1/edit'); + expect(sessionStorage.getItem(LITE_PENDING_PATH_KEY)).toBeNull(); + }); + it('leaves a link parked for another surface untouched', () => { liteState.IS_LITE = true; window.history.replaceState({}, '', '/en/mail/'); diff --git a/hooks/use-lite-link-segments.ts b/hooks/use-lite-link-segments.ts index c76d140fd..fcda099f4 100644 --- a/hooks/use-lite-link-segments.ts +++ b/hooks/use-lite-link-segments.ts @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { IS_LITE } from '@/lib/lite'; import { getPathPrefix } from '@/lib/browser-navigation'; -import { liteSegmentsFromPath, takePendingLitePath } from '@/lib/lite-link-segments'; +import { clearPendingLitePath, liteSegmentsFromPath, peekPendingLitePath } from '@/lib/lite-link-segments'; import type { AppSurface } from '@/lib/deep-links'; /** @@ -24,7 +24,10 @@ export function useLiteLinkSegments(surface: AppSurface, linkSegments?: string[] const [segments] = useState(() => { if (!IS_LITE || typeof window === 'undefined') return linkSegments; const prefix = getPathPrefix(); - const pending = takePendingLitePath(surface, prefix); + // Peek, don't consume: this initializer may run more than once when the + // first render attempt is thrown away (a sibling suspending on + // useSearchParams), and the parked link must still be there for the retry. + const pending = peekPendingLitePath(surface, prefix); if (pending) { restoreUrl.current = pending; return liteSegmentsFromPath(pending, surface, prefix); @@ -32,13 +35,15 @@ export function useLiteLinkSegments(surface: AppSurface, linkSegments?: string[] return liteSegmentsFromPath(window.location.pathname, surface, prefix); }); - // Restore the parked URL in an effect: Next's router writes its own - // canonical URL (the surface root it hydrated) from an insertion effect - // during hydration, which would undo a replaceState made while rendering. + // Consume the parked link and restore its URL in an effect, i.e. once this + // render has committed. Next's router writes its own canonical URL (the + // surface root it hydrated) from an insertion effect during hydration, which + // would undo a replaceState made while rendering. useEffect(() => { const url = restoreUrl.current; if (!url) return; restoreUrl.current = null; + clearPendingLitePath(); try { window.history.replaceState(window.history.state, '', url); } catch { diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index c77c65e1d..32af279b1 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { LITE_API_STRING_ALLOWLIST, + LITE_PENDING_PATH_KEY, + LOCALE_STORAGE_KEY, buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, + buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, @@ -22,6 +25,7 @@ import { import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs'; import { runPostbuild } from '../../scripts/lite/postbuild.mjs'; import { verifyExport } from '../../scripts/lite/verify.mjs'; +import { LITE_PENDING_PATH_KEY as APP_LITE_PENDING_PATH_KEY } from '../lite'; /** The scripts take `process.env`-shaped input; tests pass plain objects. */ function env(values: Record = {}): NodeJS.ProcessEnv { @@ -95,6 +99,9 @@ describe('lite build helpers', () => { expect(html).toContain('var fallback = "en"'); expect(html).toContain('var base = "/webmail"'); expect(html).toContain('href="/webmail/en/"'); + // The language picked in Settings (zustand persist key of stores/locale-store.ts) wins. + expect(html).toContain(`localStorage.getItem("${LOCALE_STORAGE_KEY}")`); + expect(LOCALE_STORAGE_KEY).toBe('locale-storage'); // An unknown default falls back to the first built locale. expect(buildRootRedirect({ locales: ['de'], defaultLocale: 'fr' })).toContain('var fallback = "de"'); }); @@ -108,10 +115,33 @@ describe('lite build helpers', () => { expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/); }); + it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => { + const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] }); + expect(html).toContain('var base = "/webmail"'); + expect(html).toContain('var locales = ["de","en"]'); + expect(html).toContain('var surfaces = ["mail","calendar","contacts","files","settings"]'); + expect(html).toContain(`sessionStorage.setItem("${LITE_PENDING_PATH_KEY}"`); + // Only allowlisted segments are ever assembled into the redirect target. + expect(html).toContain('var target = base + "/" + parts[0] + "/" + parts[1] + "/"'); + expect(html).toContain('location.replace(target)'); + expect(html).toContain('href="/webmail/"'); + // The key the shim writes is the one the app reads. + expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY); + }); + it('renders host snippets and the README for the built variant', () => { expect(buildNginxExample({ basePath: '' })).toContain('(?mail|calendar|contacts|files|settings)'); - expect(buildNginxExample({ basePath: '/w' })).toContain('alias /var/www/bulwark-lite/'); + // Root-relative everywhere: a sub-path mount means the files live in + // (alias does not work inside a regex location and produced a redirect cycle). + const nginxSub = buildNginxExample({ basePath: '/w' }); + expect(nginxSub).not.toContain('alias '); + expect(nginxSub).toContain('Unzip the archive into /var/www/bulwark-lite/w'); + expect(nginxSub).toContain('try_files $uri $uri/ $uri/index.html /w/$locale/$surface/index.html'); + expect(nginxSub).toContain('error_page 404 /w/404.html'); + expect(nginxSub).toContain('default_type application/manifest+json'); expect(buildCaddyExample({ basePath: '' })).toContain('try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html'); + expect(buildCaddyExample({ basePath: '/w' })).toContain('rewrite @notfound /w/404.html'); + expect(buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'] })).toContain('unzip into `/w`'); const readme = buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'], jmapServerUrl: 'https://m.example', demoMode: true }); expect(readme).toContain('# Bulwark Lite 1.10.0 (abc1234)'); expect(readme).toContain('Demo mode is ON'); @@ -203,11 +233,18 @@ describe('prepare / postbuild / verify against a fake checkout', () => { expect(existsSync(join(out, file)), file).toBe(true); } expect(collectApiStrings(join(out, '_next', 'static'))).toEqual(['/api/config', '/api/plugins']); + // Next's default not-found page was replaced by the park-and-replay shim. + expect(readFileSync(join(out, '404.html'), 'utf8')).toContain(LITE_PENDING_PATH_KEY); const verdict = verifyExport({ root }); expect(verdict.problems).toEqual([]); expect(verdict.locales).toEqual(['de', 'en']); + // An export whose 404.html is still Next's default fails verification. + writeFileSync(join(out, '404.html'), '404: This page could not be found.'); + expect(verifyExport({ root }).problems.join('\n')).toContain('404.html is not the Lite shim'); + writeFileSync(join(out, '404.html'), buildNotFoundShim({ basePath: '/w', locales: ['de', 'en'] })); + // A new, undocumented endpoint in a chunk is a verification failure. writeFileSync(join(out, '_next', 'static', 'chunks', 'new.js'), 'apiFetch("/api/brand-new")'); expect(verifyExport({ root }).problems.join('\n')).toContain('/api/brand-new'); diff --git a/lib/lite-link-segments.ts b/lib/lite-link-segments.ts index 876e98eec..cc69fdd10 100644 --- a/lib/lite-link-segments.ts +++ b/lib/lite-link-segments.ts @@ -81,18 +81,39 @@ export function stashPendingLitePath(pathWithSearch: string): boolean { } /** - * Consumes a parked deep link if it belongs to `surface`. Anything parked for - * another surface is left alone so its own shell can pick it up. + * Reads a parked deep link if it belongs to `surface`, leaving it in place. + * Anything parked for another surface is ignored so its own shell can pick it + * up. Safe to call from a render (or a `useState` initializer): React may + * discard that render attempt when a sibling suspends (contacts reads + * `useSearchParams` under a Suspense boundary) and run it again, so the entry + * must survive until `clearPendingLitePath` runs from a committed effect. */ -export function takePendingLitePath(surface: AppSurface, prefix = ''): string | null { +export function peekPendingLitePath(surface: AppSurface, prefix = ''): string | null { try { const pending = sessionStorage.getItem(LITE_PENDING_PATH_KEY); if (!pending) return null; const parsed = parseLitePath(pending, prefix); - if (parsed.surface !== surface) return null; - sessionStorage.removeItem(LITE_PENDING_PATH_KEY); - return pending; + return parsed.surface === surface ? pending : null; } catch { return null; } } + +/** Removes the parked deep link once the surface has committed to it. */ +export function clearPendingLitePath(): void { + try { + sessionStorage.removeItem(LITE_PENDING_PATH_KEY); + } catch { + // ignore + } +} + +/** + * Consumes a parked deep link if it belongs to `surface` (peek + clear in one + * step, for callers that run outside a React render). + */ +export function takePendingLitePath(surface: AppSurface, prefix = ''): string | null { + const pending = peekPendingLitePath(surface, prefix); + if (pending) clearPendingLitePath(); + return pending; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 6fbb47655..53d4bf6d8 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1157,6 +1157,7 @@ "status_active": "نشط على هذا الجهاز", "status_inactive": "غير مفعّل على هذا الجهاز", "status_unsupported": "هذا المتصفح لا يدعم Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "جارٍ العمل…", "enable": "تفعيل", "reenable": "إعادة التسجيل", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3af898a14..686039db0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1157,6 +1157,7 @@ "status_active": "Actiu en aquest dispositiu", "status_inactive": "No activat en aquest dispositiu", "status_unsupported": "Aquest navegador no admet Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Treballant…", "enable": "Activa", "reenable": "Torna a registrar", diff --git a/locales/cs/common.json b/locales/cs/common.json index 2f8a7925e..5262ee69d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Pracuji…", "status_inactive": "Není povoleno na tomto zařízení", "status_unsupported": "Tento prohlížeč nepodporuje Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Oznámení na pozadí", "devices_title": "Registrovaná zařízení", "devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.", diff --git a/locales/da/common.json b/locales/da/common.json index b9e72cc05..09552435b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1157,6 +1157,7 @@ "status_active": "Aktiv på denne enhed", "status_inactive": "Ikke aktiveret på denne enhed", "status_unsupported": "Denne browser understøtter ikke Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Arbejder…", "enable": "Aktivér", "reenable": "Genregistrér", diff --git a/locales/de/common.json b/locales/de/common.json index f05ba7afe..2d39bfc03 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Wird verarbeitet…", "status_inactive": "Auf diesem Gerät nicht aktiviert", "status_unsupported": "Dieser Browser unterstützt Web Push nicht", + "status_lite": "Web Push ist in Bulwark Lite nicht verfügbar; es benötigt den Bulwark-Server.", "title": "Hintergrundbenachrichtigungen", "devices_title": "Registrierte Geräte", "devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.", diff --git a/locales/en/common.json b/locales/en/common.json index 0e22473c8..78a509e09 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1157,6 +1157,7 @@ "status_active": "Active on this device", "status_inactive": "Not enabled on this device", "status_unsupported": "This browser does not support Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Working…", "enable": "Enable", "reenable": "Re-register", diff --git a/locales/es/common.json b/locales/es/common.json index d5b41e1f4..ae2413a7f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Trabajando…", "status_inactive": "No habilitado en este dispositivo", "status_unsupported": "Este navegador no admite Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Notificaciones en segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 690c9e0f0..c454007f8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1157,6 +1157,7 @@ "status_active": "فعال روی این دستگاه", "status_inactive": "فعال نشده", "status_unsupported": "مرورگر پشتیبانی نمی‌کند", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "در حال کار…", "enable": "فعال کردن", "reenable": "ثبت مجدد", diff --git a/locales/fr/common.json b/locales/fr/common.json index fcf27a3d8..738fd0b3f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Traitement en cours…", "status_inactive": "Non activé sur cet appareil", "status_unsupported": "Ce navigateur ne prend pas en charge Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Notifications en arrière-plan", "devices_title": "Appareils enregistrés", "devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.", diff --git a/locales/he/common.json b/locales/he/common.json index 00886598f..a03914d4f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1143,6 +1143,7 @@ "status_active": "פעיל בהתקן זה", "status_inactive": "לא מופעל בהתקן זה", "status_unsupported": "דפדפן זה אינו תומך בדחיפה ברשת", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "עובד…", "enable": "הפעל", "reenable": "רשום מחדש", diff --git a/locales/hu/common.json b/locales/hu/common.json index b40386470..d348bccb5 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1157,6 +1157,7 @@ "status_active": "Aktív ezen az eszközön", "status_inactive": "Nincs engedélyezve ezen az eszközön", "status_unsupported": "Ez a böngésző nem támogatja a Web Push-t", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Munka...", "enable": "Engedélyezés", "reenable": "Újraregisztráció", diff --git a/locales/it/common.json b/locales/it/common.json index d0cdd0cda..30c7ab7af 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1185,6 +1185,7 @@ "status_busy": "In elaborazione…", "status_inactive": "Non abilitato su questo dispositivo", "status_unsupported": "Questo browser non supporta Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Notifiche in background", "devices_title": "Dispositivi registrati", "devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.", diff --git a/locales/ja/common.json b/locales/ja/common.json index f9c017784..c6555f190 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1185,6 +1185,7 @@ "status_busy": "処理中…", "status_inactive": "このデバイスでは有効になっていません", "status_unsupported": "このブラウザは Web Push をサポートしていません", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "バックグラウンド通知", "devices_title": "登録済みのデバイス", "devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 115130fed..93f56bda1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1185,6 +1185,7 @@ "status_busy": "처리 중…", "status_inactive": "이 기기에서 활성화되지 않음", "status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "백그라운드 알림", "devices_title": "등록된 기기", "devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index f192d88b9..b31dd7047 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Notiek darbs…", "status_inactive": "Nav iespējots šajā ierīcē", "status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Fona paziņojumi", "devices_title": "Reģistrētās ierīces", "devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 0086c0688..0fc2b17f2 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1157,6 +1157,7 @@ "status_active": "Энэ төхөөрөмжид идэвхтэй байна", "status_inactive": "Энэ төхөөрөмж дээр идэвхжээгүй", "status_unsupported": "Энэ хөтөч Web Push-ийг дэмждэггүй", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Ажиллаж байна…", "enable": "Идэвхжүүлэх", "reenable": "Дахин бүртгүүлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index a6005b906..3648dccfc 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1157,6 +1157,7 @@ "status_active": "Aktiv på denne enheten", "status_inactive": "Ikke aktivert på denne enheten", "status_unsupported": "Denne nettleseren støtter ikke Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Behandler...", "enable": "Aktiver", "reenable": "Registrer på nytt", diff --git a/locales/nl/common.json b/locales/nl/common.json index c3c838bb2..12e564a77 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Bezig…", "status_inactive": "Niet ingeschakeld op dit apparaat", "status_unsupported": "Deze browser ondersteunt geen Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Achtergrondmeldingen", "devices_title": "Geregistreerde apparaten", "devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3cd722eed..e903226ee 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Przetwarzanie…", "status_inactive": "Niewłączone na tym urządzeniu", "status_unsupported": "Ta przeglądarka nie obsługuje Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Powiadomienia w tle", "devices_title": "Zarejestrowane urządzenia", "devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.", diff --git a/locales/pt/common.json b/locales/pt/common.json index e292d189f..bb8f8c8fc 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Processando…", "status_inactive": "Não ativado neste dispositivo", "status_unsupported": "Este navegador não oferece suporte a Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Notificações em segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 6f878c573..0af8fbf90 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1157,6 +1157,7 @@ "status_active": "Activ pe acest dispozitiv", "status_inactive": "Nu este activat pe acest dispozitiv", "status_unsupported": "Acest browser nu acceptă Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Se lucrează…", "enable": "Activați", "reenable": "Reînregistrează-te", diff --git a/locales/ru/common.json b/locales/ru/common.json index e8e4fb715..bd08b879c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Выполняется…", "status_inactive": "Не включено на этом устройстве", "status_unsupported": "Этот браузер не поддерживает Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Фоновые уведомления", "devices_title": "Зарегистрированные устройства", "devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 89bec8f37..2e17b787a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1181,6 +1181,7 @@ "status_active": "Aktívne na tomto zariadení", "status_inactive": "Nie je povolené na tomto zariadení", "status_unsupported": "Tento prehliadač nepodporuje Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "Pracujem…", "enable": "Povoliť", "reenable": "Znovu zaregistrovať", diff --git a/locales/tr/common.json b/locales/tr/common.json index 21442e56c..f6755de49 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Çalışıyor…", "status_inactive": "Bu cihazda etkinleştirilmedi", "status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Arka plan bildirimleri", "devices_title": "Kayıtlı cihazlar", "devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.", diff --git a/locales/uk/common.json b/locales/uk/common.json index ea6a0f82f..a896c863c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1185,6 +1185,7 @@ "status_busy": "Виконується…", "status_inactive": "Не ввімкнено на цьому пристрої", "status_unsupported": "Цей браузер не підтримує Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "Фонові сповіщення", "devices_title": "Зареєстровані пристрої", "devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index a81b0da65..289ce6d53 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1157,6 +1157,7 @@ "status_active": "已在此裝置上啟用", "status_inactive": "在此裝置上未啟用", "status_unsupported": "此瀏覽器不支援 Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "status_busy": "工作中…", "enable": "啟用", "reenable": "重新註冊", diff --git a/locales/zh/common.json b/locales/zh/common.json index 49ebc9727..2cf732396 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1185,6 +1185,7 @@ "status_busy": "工作中…", "status_inactive": "在此设备上未启用", "status_unsupported": "此浏览器不支持 Web Push", + "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", "title": "后台通知", "devices_title": "已注册的设备", "devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 3eb58abe7..904da71d6 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -53,6 +53,16 @@ export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings /** Locales written right-to-left; the root shim sets `dir` for them. */ export const RTL_LOCALES = ["ar", "fa", "he"]; +/** + * sessionStorage key the 404 shim parks a deep link under. Mirrors + * LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot + * import); lib/__tests__/lite-scripts.test.ts pins the two together. + */ +export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path"; + +/** zustand persist key of stores/locale-store.ts (the user's language choice). */ +export const LOCALE_STORAGE_KEY = "locale-storage"; + /** * `/api/` strings that legitimately survive in the Lite client chunks. Each * one is either gated at call time (`IS_LITE`, a policy flag, a config flag @@ -197,7 +207,11 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" return null; } var chosen = null; - try { chosen = pick(localStorage.getItem("NEXT_LOCALE")); } catch (e) {} + // The language picked in Settings (stores/locale-store.ts) wins over the browser's list. + try { + var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null"); + chosen = pick(stored && stored.state && stored.state.locale); + } catch (e) {} var langs = navigator.languages || [navigator.language]; for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]); location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash); @@ -209,6 +223,66 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" `; } +/** + * 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting). + * + * `next build` exports Next's default not-found page as 404.html: the + * `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server + * build is deleted for the export, and a route group's not-found.tsx only + * serves `notFound()` calls inside that group. So the park-and-replay logic + * lives in this dependency-free shim instead: a deep link below a known + * surface is parked in sessionStorage and the surface shell is loaded, which + * hands the link over (hooks/use-lite-link-segments.ts). Only `` and + * `` from the allowlists ever reach `location.replace`, so a crafted + * URL cannot turn this into an open redirect. + */ +export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES }) { + return ` + + + + + +404 + + + + +
+

404

+

This page could not be found.

+ Go home +
+ + +`; +} + /** Netlify / Cloudflare Pages rewrite rules. */ export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) { const lines = [ @@ -252,24 +326,46 @@ ${basePath || ""}/_next/static/* `; } +/** + * Where the unzipped folder has to live for the nginx/Caddy examples: every + * rule below is root-relative, so a sub-path mount means the files sit in a + * folder named after the mount path inside the web root (no `alias` needed, + * which does not work inside a regex `location`). + */ +export function exampleDocRoot(basePath = "") { + return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` }; +} + export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) { const location = basePath ? `${basePath}/` : "/"; + const { root, files } = exampleDocRoot(basePath); const surfaceAlternation = surfaces.join("|"); - return `# Bulwark Lite - nginx example. Serve the unzipped folder at ${location} -# and rewrite deep links to the owning surface's shell. + return `# Bulwark Lite - nginx example. Unzip the archive into ${files} +# so that ${basePath}/index.html is served at ${location}; deep links below a surface +# are rewritten to that surface's shell. server { listen 80; server_name webmail.example.com; - root /var/www/bulwark-lite; + root ${root}; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; # See _headers for a Content-Security-Policy that matches your JMAP server. + # Hashed assets never change. (add_header inside a location replaces the + # inherited set, so the security headers are repeated here.) location ${basePath}/_next/static/ { - ${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y; - add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + } + + # Older mime.types files do not know the PWA manifest extension. + location = ${basePath}/manifest.webmanifest { + types { } + default_type application/manifest+json; } # ///anything -> ///index.html @@ -278,7 +374,7 @@ server { } location ${location} { - ${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404; + try_files $uri $uri/ $uri/index.html =404; error_page 404 ${basePath}/404.html; } } @@ -286,10 +382,12 @@ server { } export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) { + const { root, files } = exampleDocRoot(basePath); const surfaceAlternation = surfaces.join("|"); - return `# Bulwark Lite - Caddy example. + return `# Bulwark Lite - Caddy example. Unzip the archive into ${files} +# so that ${basePath}/index.html is served at ${basePath || ""}/. webmail.example.com { - root * /var/www/bulwark-lite + root * ${root} encode gzip header { @@ -298,16 +396,27 @@ webmail.example.com { Referrer-Policy strict-origin-when-cross-origin } + @manifest path ${basePath}/manifest.webmanifest + header @manifest Content-Type application/manifest+json + + # ///anything -> ///index.html @surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$ handle @surface { try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html } handle { - try_files {path} {path}/ {path}/index.html ${basePath}/404.html + try_files {path} {path}/ {path}/index.html } file_server + + # Anything else is a real 404, answered by the shim that replays parked deep links. + handle_errors { + @notfound expression {http.error.status_code} == 404 + rewrite @notfound ${basePath}/404.html + file_server + } } `; } @@ -351,7 +460,7 @@ Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at - Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this. - nginx: see \`nginx.conf.example\`. - Caddy: see \`Caddyfile.example\`. -- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link +${basePath ? ` Both examples serve root-relative paths, so unzip into \`${basePath}\`\n (e.g. \`${exampleDocRoot(basePath).files}\`), not into the web root itself.\n` : ""}- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link in the browser. It works, with one extra page load. \`_headers\` also carries the recommended security headers. Adjust @@ -367,7 +476,8 @@ Not available in Lite (they need the Node.js server): the admin console and setup wizard, plugins and sidebar apps, settings sync across devices, OAuth / SSO login, the account security tab (app passwords, 2FA setup), ICS URL subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing, -web push notifications, the update banner, device pairing. +web push notifications, the update banner, device pairing, and the +"Default apps" page (registering Lite as the mailto:/webcal: handler). ## Security notes @@ -380,7 +490,10 @@ web push notifications, the update banner, device pairing. control and keep the Content-Security-Policy from \`_headers\`. - Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic auth. "Remember me" is then hidden; the credentials stay in sessionStorage - for the lifetime of the tab so a reload does not sign you out. + for the lifetime of the tab so a reload does not sign you out. The same + fallback applies when a reverse proxy in front of Stalwart answers + \`/api/auth\` without CORS headers: the login still works, but "remember me" + then only lasts for the tab, so add the CORS rule from step 3 there too. - Because the export contains inline scripts, \`script-src\` must allow \`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed frame, exactly as in the full build. diff --git a/scripts/lite/postbuild.mjs b/scripts/lite/postbuild.mjs index 99fdf0981..ea996ee13 100644 --- a/scripts/lite/postbuild.mjs +++ b/scripts/lite/postbuild.mjs @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { - buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot, + buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot, } from "./lib.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); @@ -35,6 +35,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console "policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n", "manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n", "index.html": buildRootRedirect({ basePath, locales, defaultLocale }), + // Replaces Next's default not-found page: only this shim replays deep links + // on hosts without rewrite rules (see buildNotFoundShim). + "404.html": buildNotFoundShim({ basePath, locales }), "_redirects": buildRedirects({ basePath, locales }), "_headers": buildHeaders({ basePath, connectSrc }), "nginx.conf.example": buildNginxExample({ basePath }), diff --git a/scripts/lite/serve.mjs b/scripts/lite/serve.mjs index 9e8704cee..352d4d535 100644 --- a/scripts/lite/serve.mjs +++ b/scripts/lite/serve.mjs @@ -3,15 +3,22 @@ // fallback the shipped nginx/Caddy snippets do. Used by the Playwright smoke // test and handy for a local look at out/: // -// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=] +// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=] [--plain] +// +// `--plain` switches the SPA fallback off and answers unknown paths with +// 404.html (status 404), the way GitHub Pages or an S3 bucket would; that is +// the path the 404 shim's park-and-replay exists for. import { createServer } from "node:http"; import { createReadStream, existsSync, statSync } from "node:fs"; import { extname, join, normalize, resolve } from "node:path"; import { LITE_SURFACES } from "./lib.mjs"; -const root = resolve(process.argv[2] || "out"); -const port = Number(process.argv[3] || 4173); -const basePath = (process.argv[4] || "").replace(/\/+$/, ""); +const flags = process.argv.slice(2).filter((a) => a.startsWith("--")); +const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); +const plain = flags.includes("--plain"); +const root = resolve(args[0] || "out"); +const port = Number(args[1] || 4173); +const basePath = (args[2] || "").replace(/\/+$/, ""); const TYPES = { ".html": "text/html; charset=utf-8", @@ -56,7 +63,7 @@ export function resolveRequest(pathname) { } const file = fileFor(pathname); if (file) return { status: 200, file }; - const match = pathname.match(surfacePattern); + const match = plain ? null : pathname.match(surfacePattern); if (match) { const shell = join(root, match[1], match[2], "index.html"); if (existsSync(shell)) return { status: 200, file: shell }; @@ -71,5 +78,5 @@ createServer((req, res) => { res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); }).listen(port, () => { - console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`); + console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`); }); diff --git a/scripts/lite/smoke/lite-demo.spec.ts b/scripts/lite/smoke/lite-demo.spec.ts index 45f28587a..2b0326f21 100644 --- a/scripts/lite/smoke/lite-demo.spec.ts +++ b/scripts/lite/smoke/lite-demo.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import { plainPort } from "./playwright.config"; /** * Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the @@ -10,16 +11,20 @@ import { test, expect, type Page } from "@playwright/test"; * - the demo account signs in and the mail list renders, * - top-level navigation between static shells works client-side, * - a deep link reload keeps its URL and the shell still hydrates, - * - a deep link parked by the 404 shim is replayed by the surface, + * - on a host without rewrites, 404.html parks the deep link and the + * surface replays it (the exported 404.html must be the Lite shim, not + * Next's default not-found page), * - no request ever targets a server endpoint on the Lite origin. */ -async function loginAsDemo(page: Page) { - await page.goto("/en/login/"); +const PLAIN_ORIGIN = `http://localhost:${plainPort}`; + +async function loginAsDemo(page: Page, origin = "") { + await page.goto(`${origin}/en/login/`); const demoButton = page.getByRole("button", { name: /demo/i }).first(); await expect(demoButton).toBeVisible({ timeout: 30_000 }); await demoButton.click(); - await page.waitForURL(/\/en\/?(mail\/?)?(\?.*)?$/, { timeout: 30_000 }); + await page.waitForURL(/\/en\/?(mail\/?.*)?(\?.*)?$/, { timeout: 30_000 }); } test.describe("Bulwark Lite demo export", () => { @@ -45,6 +50,15 @@ test.describe("Bulwark Lite demo export", () => { expect(apiRequests).toEqual([]); }); + test("the root shim honours the language chosen in Settings", async ({ page }) => { + await page.goto("/en/login/"); + await page.evaluate(() => { + localStorage.setItem("locale-storage", JSON.stringify({ state: { locale: "de" }, version: 0 })); + }); + await page.goto("/"); + await page.waitForURL(/\/de\/$/); + }); + test("demo login, surface switch and deep-link reload", async ({ page }) => { await loginAsDemo(page); @@ -68,16 +82,31 @@ test.describe("Bulwark Lite demo export", () => { expect(apiRequests).toEqual([]); }); - test("a deep link parked by the 404 shim is replayed on the surface", async ({ page }) => { - await loginAsDemo(page); - // What app/(main)/not-found.tsx does on a host without rewrite rules: - // park the link, load the surface root. - await page.evaluate(() => { - sessionStorage.setItem("bulwark-lite:pending-path", "/en/mail/folder/inbox"); - }); - await page.goto("/en/mail/"); + test("404.html is the Lite shim, not Next's default not-found page", async ({ page }) => { + const res = await page.request.get("/404.html"); + expect(res.ok()).toBe(true); + const html = await res.text(); + expect(html).toContain("bulwark-lite:pending-path"); + expect(html).not.toContain("__next_f"); + }); + + test("on a host without rewrites a deep link is parked by 404.html and replayed", async ({ page }) => { + // The plain server answers /en/mail/folder/inbox with 404.html (status + // 404), exactly like GitHub Pages. The shim parks the link, loads + // /en/mail/, and the surface restores the URL. + await loginAsDemo(page, PLAIN_ORIGIN); + const response = await page.goto(`${PLAIN_ORIGIN}/en/mail/folder/inbox`); + expect(response?.status()).toBe(404); await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 }); + await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 }); expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull(); + + // A path outside the known surfaces stays a plain 404 page. + const dead = await page.goto(`${PLAIN_ORIGIN}/en/nope`); + expect(dead?.status()).toBe(404); + await page.waitForTimeout(500); + expect(new URL(page.url()).pathname).toBe("/en/nope"); + await expect(page.locator("body")).toContainText(/404/); expect(apiRequests).toEqual([]); }); }); diff --git a/scripts/lite/smoke/playwright.config.ts b/scripts/lite/smoke/playwright.config.ts index 0b06dc572..0cabae363 100644 --- a/scripts/lite/smoke/playwright.config.ts +++ b/scripts/lite/smoke/playwright.config.ts @@ -1,9 +1,12 @@ import { defineConfig } from "@playwright/test"; // Smoke test for a finished Lite export (`npm run build:lite` with -// LITE_DEMO_MODE=true). Serves out/ with the dependency-free static server -// that applies the same SPA fallback the shipped host snippets do. +// LITE_DEMO_MODE=true). Serves out/ twice with the dependency-free static +// server: once with the SPA fallback the shipped host snippets apply, and +// once "plain" (unknown paths answer 404.html with status 404, like GitHub +// Pages or an S3 bucket) to exercise the 404 shim's park-and-replay path. const port = Number(process.env.LITE_SMOKE_PORT || 4173); +export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1); export default defineConfig({ testDir: ".", @@ -17,10 +20,18 @@ export default defineConfig({ trace: "retain-on-failure", }, projects: [{ name: "chromium", use: { browserName: "chromium" } }], - webServer: { - command: `node scripts/lite/serve.mjs out ${port}`, - port, - reuseExistingServer: !process.env.CI, - cwd: process.cwd(), - }, + webServer: [ + { + command: `node scripts/lite/serve.mjs out ${port}`, + port, + reuseExistingServer: !process.env.CI, + cwd: process.cwd(), + }, + { + command: `node scripts/lite/serve.mjs out ${plainPort} "" --plain`, + port: plainPort, + reuseExistingServer: !process.env.CI, + cwd: process.cwd(), + }, + ], }); diff --git a/scripts/lite/verify.mjs b/scripts/lite/verify.mjs index fdd4ff517..4465dc495 100644 --- a/scripts/lite/verify.mjs +++ b/scripts/lite/verify.mjs @@ -2,10 +2,10 @@ // Guards for a finished Lite export: the files a deployer relies on exist, // and the client chunks reference no server endpoint outside the documented // allowlist (lib.mjs). Exit 1 with a readable list otherwise. -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { - collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings, + LITE_PENDING_PATH_KEY, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings, } from "./lib.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); @@ -31,6 +31,13 @@ export function verifyExport({ root = repoRoot } = {}) { if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`); } + // Next writes its own default not-found page as 404.html; postbuild must + // have replaced it with the shim that parks and replays deep links. + const notFound = join(outDir, "404.html"); + if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) { + problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild"); + } + const apiStrings = collectApiStrings(join(outDir, "_next", "static")); const unexpected = unexpectedApiStrings(apiStrings); for (const s of unexpected) problems.push(`client chunk references undocumented server endpoint ${s} (add an IS_LITE gate or document it in scripts/lite/lib.mjs)`); diff --git a/stores/__tests__/auth-store-lite.test.ts b/stores/__tests__/auth-store-lite.test.ts index 9ae00550b..650167a6f 100644 --- a/stores/__tests__/auth-store-lite.test.ts +++ b/stores/__tests__/auth-store-lite.test.ts @@ -146,17 +146,47 @@ describe('auth-store in the static Lite build', () => { expect(JSON.parse(sessionStorage.getItem('bulwark-lite:refresh:0')!).refreshToken).toBe('RT-1'); }); - it('a plain password login without "remember me" connects with Basic auth and stores nothing', async () => { + it('a plain password login without "remember me" still uses token login and keeps the refresh token with the tab', async () => { const { calls } = stalwartFetch(); const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw'); expect(ok).toBe(true); - expect(calls).toEqual([]); + expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]); expect(connectSpy).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState().authMode).toBe('oauth'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-1'); + // Tab-scoped: sessionStorage only, so a reload keeps the session and closing the tab ends it. + expect(sessionStorage.getItem('bulwark-lite:refresh:0')).toContain('"refreshToken":"RT-1"'); + expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull(); + expect(readLiteBasicSession(0)).toBeNull(); + }); + + it('without token login and without "remember me" the Basic session is still kept for the tab', async () => { + const { calls } = stalwartFetch({ loginStatus: 404 }); + + const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw'); + + expect(ok).toBe(true); + expect(calls).toEqual([`POST ${SERVER}/api/auth`]); expect(useAuthStore.getState().authMode).toBe('basic'); - expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`); - expect(liteStorageKeys()).toEqual([]); + expect(readLiteBasicSession(0)).toEqual({ serverUrl: SERVER, username: 'alice', password: 'pw' }); + expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull(); + expect(liteStorageKeys().filter((k) => k.startsWith('bulwark-lite:refresh:'))).toEqual([]); + }); + + it('a Basic account without "remember me" is restored from the tab session instead of being evicted', async () => { + saveLiteBasicSession(0, { serverUrl: SERVER, username: 'alice', password: 'pw' }); + const id = registerAccount('basic'); + useAccountStore.getState().updateAccount(id, { rememberMe: false }); + stalwartFetch(); + + await useAuthStore.getState().checkAuth(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([id]); }); it('surfaces a missing MFA code as totp_required', async () => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9ea1ab5d0..4b67857e8 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -848,10 +848,12 @@ export const useAuthStore = create()( let oauthAccessToken: string | null = null; let oauthExpiresIn = 0; - // Lite has no session cookie to remember a password in, so "remember - // me" also goes through the token flow and only a refresh token is - // kept (lib/auth/lite-tokens.ts). - const useTokenLogin = !!totp || (IS_LITE && !!rememberMe); + // Lite has no session cookie to remember a password in, so every + // password login goes through Stalwart's token flow and only a + // refresh token is kept: localStorage with "remember me", the tab's + // sessionStorage without it (lib/auth/lite-tokens.ts). Servers + // without the endpoint fall back to Basic auth below. + const useTokenLogin = !!totp || IS_LITE; if (useTokenLogin) { // Stalwart 0.16+ dropped the `password$totp` basic-auth convention; // the MFA code must be exchanged for tokens via the structured login @@ -904,8 +906,9 @@ export const useAuthStore = create()( client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp()); debug.log('auth', 'TOTP re-auth enabled (legacy basic-auth path)'); } else { - // Lite "remember me" on a server without token login: plain Basic - // auth; the session is then kept with the tab only. + // Lite on a server without token login (or one that blocks the + // browser's /api/auth call): plain Basic auth; the session is + // then kept with the tab only, see persistBasicSession. client = new JMAPClient(serverUrl, username, password); await client.connect(); } @@ -934,7 +937,10 @@ export const useAuthStore = create()( // write and stalwart-context write are best-effort persistence; the // outer login still succeeds even if they log a warning. Errors are // caught locally so Promise.all doesn't reject on either. - const sessionWrite: Promise = (rememberMe && !upgradedToOAuth) + // In Lite a Basic session is always kept for the tab (sessionStorage), + // so a reload does not sign the user out; the regular build only + // writes the cookie when the user asked to be remembered. + const sessionWrite: Promise = ((rememberMe || IS_LITE) && !upgradedToOAuth) ? persistBasicSession(cookieSlot, serverUrl, username, password) : Promise.resolve(); @@ -1936,7 +1942,9 @@ export const useAuthStore = create()( // Basic auth without rememberMe leaves nothing to restore - the // user logged in without persisting credentials. Evict silently // so the login screen is shown without flagging a fake error. - if (account.authMode === 'basic' && !account.rememberMe) { + // (Lite keeps a tab-scoped Basic session either way; a missing one + // surfaces as a 401 from fetchSlotSession below and evicts too.) + if (account.authMode === 'basic' && !account.rememberMe && !IS_LITE) { evictAccount(account.id); accountStore.removeAccount(account.id); return;