clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

Merge pull request #54 from FelineStateMachine/feat/route-base-safe-pwa

FelineStateMachine committed 360bf7e
fix(pwa): make resources route and base safe
commit
360bf7e6db079fe22397be863d3e34c285be3792
parent
d27ab0feae1cb1882e6c20feff8b5aad66dbe33c
parent
4ff244208d2df0f846ba8f3922a81cbd3b3180d9
author
FelineStateMachine <dami.etoile@gmail.com>
21 files changed, +268 -21
Mapps/reference/src/layouts/Shell.astro8+++--
Mdocs/deployment.md16+++++++++++
Mdocs/reference/configuration.md1+
Mpackage/astro/mod.ts3++
Mpackage/astro/mod_test.ts3++
Mpackage/commands/build.ts7++++-
Mpackage/commands/dev.ts2+-
Mpackage/commands/preview.ts15+++++++---
Mpackage/create_core.ts10++++++-
Mpackage/pwa_assets_test.ts6+++-
Mpackage/runtime/env.d.ts1+
Mpackage/runtime/pwa.ts25++++++++++++++++-
Mpackage/runtime/pwa_test.ts36+++++++++++++++++++++++-
Mpackage/runtime/sw.js15++++++++++
Mpackage/runtime/sw_test.ts2+
Mpackage/testdata/starter.snapshot.json6++--
Apackage/tooling/base-path.ts34+++++++++++++++++++++++
Apackage/tooling/base-path_test.ts44++++++++++++++++++++++++++++++
Mpackage/tooling/diagnostics.ts3+-
Mpackage/tooling/diagnostics_test.ts33+++++++++++++++++++++++
Mpackage/tooling/environment.ts19++++++++++++-
M apps/reference/src/layouts/Shell.astro +5 -3
M docs/deployment.md +16 -0
@@ -44,6 +44,18 @@ Before deploying, review:
4444 - page titles, descriptions, and starter copy;4545 - `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync.4646 47+The default deployment base is `/`. When a static host mounts the application below the origin root,48+set the path once in `.env` before running `dev` or `build`:49+50+```dotenv51+LOFI_BASE_PATH=/field-notes/52+```53+54+The value must be an absolute path, not an origin. Lofi feeds it into Astro's `base` setting and55+uses the resulting base for public asset links, the manifest, the service worker, its scope, build56+identity, and local preview. Upload the contents of `dist/` to that same mount point. A root build57+and a subpath build are different deployment artifacts; rebuild after changing `LOFI_BASE_PATH`.58+4759 Run `deno task doctor` and `deno task test` before the production build.4860 4961 ## Deno Deploy
@@ -72,6 +84,10 @@ Upload the contents of `dist/` to any host that can:
7284 - keep the service worker at the intended scope;7385 - fall back to the appropriate prerendered HTML for application routes.7486 87+Every prerendered route is included in the shell precache. While offline, a direct navigation such88+as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not89+emitted, the worker falls back to the cached application root.90+7591 Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or7692 `BACKEND_SECRET` as public environment variables.7793
M docs/reference/configuration.md +1 -0
M package/astro/mod.ts +3 -0
M package/astro/mod_test.ts +3 -0
M package/commands/build.ts +6 -1
M package/commands/dev.ts +1 -1
M package/commands/preview.ts +11 -4
@@ -8,6 +8,7 @@
88 */99 1010 import { extname, join, normalize } from "node:path";11+import { normalizeDeploymentBase, pathWithinDeploymentBase } from "../tooling/base-path.ts";1112 1213 const portFlag = Deno.args.findIndex((argument) => argument === "--port");1314 const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;
@@ -16,7 +17,7 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
1617 Deno.exit(2);1718 }1819 19-let identity: { lofiVersion?: string; sourceHash?: string };20+let identity: { lofiVersion?: string; sourceHash?: string; basePath?: string };2021 try {2122 identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));2223 } catch {
@@ -27,6 +28,7 @@ if (!identity.sourceHash) {
2728 console.error("error: production build identity is invalid; run `deno task build` again");2829 Deno.exit(1);2930 }31+const basePath = normalizeDeploymentBase(identity.basePath);3032 3133 const contentTypes: Record<string, string> = {3234 ".css": "text/css; charset=utf-8",
@@ -40,12 +42,17 @@ const contentTypes: Record<string, string> = {
4042 };4143 4244 console.log(43- `lofi preview: http://127.0.0.1:${port}/ (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,45+ `lofi preview: http://127.0.0.1:${port}${basePath} (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,4446 );4547 Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {4648 const url = new URL(request.url);47- let pathname = decodeURIComponent(url.pathname);48- if (pathname.endsWith("/")) pathname += "index.html";49+ if (basePath !== "/" && url.pathname === basePath.slice(0, -1)) {50+ return Response.redirect(new URL(basePath, url.origin), 308);51+ }52+ const scopedPath = pathWithinDeploymentBase(url.pathname, basePath);53+ if (scopedPath === null) return new Response("Not found", { status: 404 });54+ let pathname = decodeURIComponent(scopedPath);55+ if (pathname === "" || pathname.endsWith("/")) pathname += "index.html";4956 const relative = normalize(pathname).replace(/^[/\\]+/, "");5057 if (relative.startsWith("..")) return new Response("Not found", { status: 404 });5158 const path = join("dist", relative);
M package/create_core.ts +9 -1
M package/pwa_assets_test.ts +4 -2
M package/runtime/env.d.ts +1 -0
M package/runtime/pwa.ts +23 -2
@@ -70,6 +70,9 @@ export type PwaControllerDependencies = {
7070 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;7171 readonly installEnvironment?: () => InstallEnvironment;7272 readonly production?: () => boolean;73+ /** Absolute URL of the configured deployment base, independent of the current route. */74+ readonly deploymentBaseUrl?: () => string;75+ /** @deprecated Supply deploymentBaseUrl when injecting browser location in tests. */7376 readonly documentBaseURI?: () => string;7477 readonly reload?: () => void;7578 readonly exposeState?: (state: PwaState) => void;
@@ -84,6 +87,18 @@ export type PwaController = {
8487 initialize(): void;8588 };8689 90+/** Resolves the worker URL and scope from one configured application base. */91+export function resolvePwaResources(deploymentBaseUrl: string): {92+ workerUrl: URL;93+ scope: string;94+} {95+ const base = new URL(deploymentBaseUrl);96+ if (!base.pathname.endsWith("/")) base.pathname += "/";97+ base.search = "";98+ base.hash = "";99+ return { workerUrl: new URL("sw.js", base), scope: base.pathname };100+}101+87102 export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {88103 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";89104 const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||
@@ -204,10 +219,16 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
204219 if (dependencies.reload) dependencies.reload();205220 else location.reload();206221 });207- const workerUrl = new URL("./sw.js", dependencies.documentBaseURI?.() ?? document.baseURI);222+ const deploymentBaseUrl = dependencies.deploymentBaseUrl?.() ?? (() => {223+ const locationOrigin = dependencies.documentBaseURI224+ ? new URL(dependencies.documentBaseURI()).origin225+ : document.location.origin;226+ return new URL(import.meta.env.BASE_URL, locationOrigin).href;227+ })();228+ const { workerUrl, scope } = resolvePwaResources(deploymentBaseUrl);208229 void (async () => {209230 const registration = await container.register(workerUrl, {210- scope: new URL("./", dependencies.documentBaseURI?.() ?? document.baseURI).pathname,231+ scope,211232 });212233 watchForUpdate(registration, container);213234 if (registration.waiting && container.controller) return;
M package/runtime/pwa_test.ts +34 -2
@@ -5,6 +5,7 @@ import {
55 type PwaController,66 type PwaInstallState,77 type PwaState,8+ resolvePwaResources,89 waitForActivation,910 } from "./pwa.ts";1011
@@ -41,8 +42,12 @@ class FakeContainer extends EventTarget {
4142 controller: ServiceWorker | null = null;4243 registration = new FakeRegistration();4344 registerError: Error | undefined;45+ registeredUrl: string | undefined;46+ registeredScope: string | undefined;4447 45- register(): Promise<ServiceWorkerRegistration> {48+ register(url: string | URL, options?: RegistrationOptions): Promise<ServiceWorkerRegistration> {49+ this.registeredUrl = String(url);50+ this.registeredScope = options?.scope;4651 if (this.registerError) return Promise.reject(this.registerError);4752 return Promise.resolve(this.registration as unknown as ServiceWorkerRegistration);4853 }
@@ -58,6 +63,7 @@ function testController(options: {
5863 target?: EventTarget;5964 install?: PwaInstallState;6065 reload?: () => void;66+ deploymentBaseUrl?: string;6167 } = {}): PwaController {6268 const environment = options.install === "manual-ios"6369 ? { platform: "iPhone", maxTouchPoints: 5 }
@@ -71,11 +77,37 @@ function testController(options: {
7177 ...environment,7278 }),7379 production: () => options.production ?? false,74- documentBaseURI: () => "http://127.0.0.1:4321/",80+ deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",7581 reload: options.reload,7682 });7783 }7884 85+test("PWA resources resolve from the deployment base instead of the current route", () => {86+ const resources = resolvePwaResources("https://example.com/field-notes");87+ if (resources.workerUrl.href !== "https://example.com/field-notes/sw.js") {88+ throw new Error(`unexpected worker URL ${resources.workerUrl.href}`);89+ }90+ if (resources.scope !== "/field-notes/") throw new Error(`unexpected scope ${resources.scope}`);91+});92+93+test("nested direct visits register the worker at the configured non-root base", async () => {94+ const container = new FakeContainer();95+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));96+ const controller = testController({97+ container,98+ production: true,99+ deploymentBaseUrl: "https://example.com/field-notes/",100+ });101+ controller.initialize();102+ await waitForState(controller, (state) => state.worker === "ready");103+ if (container.registeredUrl !== "https://example.com/field-notes/sw.js") {104+ throw new Error(`registered the wrong worker URL: ${container.registeredUrl}`);105+ }106+ if (container.registeredScope !== "/field-notes/") {107+ throw new Error(`registered the wrong scope: ${container.registeredScope}`);108+ }109+});110+79111 function waitForState(controller: PwaController, predicate: (state: PwaState) => boolean) {80112 return new Promise<PwaState>((resolve) => {81113 const unsubscribe = controller.subscribe((state) => {
M package/runtime/sw.js +15 -0
M package/runtime/sw_test.ts +2 -0
M package/testdata/starter.snapshot.json +3 -3
@@ -1,5 +1,5 @@
11 {2- ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",2+ ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",44 "deno.json": "ee47812226531b9043efcaf8e511bb66a801257a48d995dbcaf4dbac2a52d87b",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",
@@ -8,13 +8,13 @@
88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",11- "README.md": "4fde6743e26c947f74fa028c8ecc991ab7f0cb3ee99b8b8b1da01411665911f6",11+ "README.md": "4d4f37ec8f4cfc7dc127d7c3b0c74fd22658dd6ba9f59229316367095ebcdfd7",1212 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1313 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1414 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",1515 "src/islands/TaskList.tsx": "b38d32ecc08c60e1bef3c70e3e96914df1a2037419253324c4ac48443531f7e7",1616 "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",17- "src/layouts/Shell.astro": "7bad8419d4ad8f89294c8c145fd2d710cde43f3bc6ebc40fd95793786bac5f1e",17+ "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1818 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",1919 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",2020 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",
A package/tooling/base-path.ts +34 -0
@@ -0,0 +1,34 @@
1+/** Normalizes the public URL path where a generated application is mounted. */2+export function normalizeDeploymentBase(value: string | undefined): string {3+ const input = value?.trim() || "/";4+ if (!input.startsWith("/") || input.startsWith("//")) {5+ throw new Error("LOFI_BASE_PATH must be an absolute URL path beginning with one '/'");6+ }7+ if (input.includes("?") || input.includes("#") || input.includes("\\")) {8+ throw new Error("LOFI_BASE_PATH must not contain a query, fragment, or backslash");9+ }10+ const segments = input.split("/").filter(Boolean);11+ const encodedSegments: string[] = [];12+ for (const segment of segments) {13+ let decoded: string;14+ try {15+ decoded = decodeURIComponent(segment);16+ } catch {17+ throw new Error("LOFI_BASE_PATH contains invalid URL encoding");18+ }19+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {20+ throw new Error("LOFI_BASE_PATH must not contain traversal or encoded path separators");21+ }22+ encodedSegments.push(encodeURIComponent(decoded));23+ }24+ return encodedSegments.length === 0 ? "/" : `/${encodedSegments.join("/")}/`;25+}26+27+/** Returns the request path relative to the configured deployment base. */28+export function pathWithinDeploymentBase(pathname: string, base: string): string | null {29+ const normalizedBase = normalizeDeploymentBase(base);30+ if (normalizedBase === "/") return pathname.replace(/^\/+/, "");31+ if (pathname === normalizedBase.slice(0, -1)) return "";32+ if (!pathname.startsWith(normalizedBase)) return null;33+ return pathname.slice(normalizedBase.length);34+}
A package/tooling/base-path_test.ts +44 -0
@@ -0,0 +1,44 @@
1+import { normalizeDeploymentBase, pathWithinDeploymentBase } from "./base-path.ts";2+3+function assertEquals(actual: unknown, expected: unknown): void {4+ if (actual !== expected) {5+ throw new Error(`expected ${String(expected)}, received ${String(actual)}`);6+ }7+}8+9+Deno.test("deployment bases are absolute and have one trailing slash", () => {10+ assertEquals(normalizeDeploymentBase(undefined), "/");11+ assertEquals(normalizeDeploymentBase("/"), "/");12+ assertEquals(normalizeDeploymentBase(" /field-notes "), "/field-notes/");13+ assertEquals(normalizeDeploymentBase("/teams/notes/"), "/teams/notes/");14+});15+16+Deno.test("deployment bases reject origins, traversal, and URL suffixes", () => {17+ for (18+ const input of [19+ "notes",20+ "//example.com/notes",21+ "/../notes",22+ "/%2e%2e/notes",23+ "/notes?x=1",24+ "/notes#top",25+ "/notes\\admin",26+ ]27+ ) {28+ let rejected = false;29+ try {30+ normalizeDeploymentBase(input);31+ } catch {32+ rejected = true;33+ }34+ if (!rejected) throw new Error(`expected ${JSON.stringify(input)} to be rejected`);35+ }36+});37+38+Deno.test("request paths resolve only inside the deployment base", () => {39+ assertEquals(pathWithinDeploymentBase("/field-notes/", "/field-notes/"), "");40+ assertEquals(pathWithinDeploymentBase("/field-notes/settings/", "/field-notes/"), "settings/");41+ assertEquals(pathWithinDeploymentBase("/field-notes", "/field-notes/"), "");42+ assertEquals(pathWithinDeploymentBase("/other/", "/field-notes/"), null);43+ assertEquals(pathWithinDeploymentBase("/settings/", "/"), "settings/");44+});
M package/tooling/diagnostics.ts +2 -1
M package/tooling/diagnostics_test.ts +33 -0
M package/tooling/environment.ts +18 -1
@@ -1,10 +1,18 @@
1+import { normalizeDeploymentBase } from "./base-path.ts";2+13 export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;24 export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3-export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;5+export const deploymentEnvironmentNames = ["LOFI_BASE_PATH"] as const;6+export const environmentNames = [7+ ...clientEnvironmentNames,8+ ...serverEnvironmentNames,9+ ...deploymentEnvironmentNames,10+] as const;411 512 interface EnvironmentResultBase {613 presentClientNames: string[];714 presentServerNames: string[];15+ basePath: string;816 warnings: string[];917 }1018
@@ -39,6 +47,13 @@ export function validateEnvironment(
3947 const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));4048 const errors: string[] = [];4149 const warnings: string[] = [];50+ let basePath = "/";51+52+ try {53+ basePath = normalizeDeploymentBase(env.LOFI_BASE_PATH);54+ } catch (error) {55+ errors.push(error instanceof Error ? error.message : String(error));56+ }4257 4358 if (4459 presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length
@@ -69,6 +84,7 @@ export function validateEnvironment(
6984 const base: EnvironmentResultBase = {7085 presentClientNames: [...presentClientNames],7186 presentServerNames: [...presentServerNames],87+ basePath,7288 warnings,7389 };7490 if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };
@@ -161,6 +177,7 @@ export function childEnvironment(
161177 environment.JAZZ_APP_ID = "";162178 environment.JAZZ_SERVER_URL = "";163179 }180+ environment.LOFI_BASE_PATH = validation.basePath;164181 environment.JAZZ_ADMIN_SECRET = "";165182 environment.BACKEND_SECRET = "";166183 return environment;