diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 17d027ac6..841ea08d0 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -4,7 +4,6 @@ import { globSync } from "glob"; import path from "path"; import { type AssetManifest, - buildAssetUrl, encodeAssetPath, normalizeAssetPath, } from "../core/AssetUrls"; @@ -97,6 +96,12 @@ function getEmittedAssetRelativePath( return path.posix.relative(emittedFromDir, emittedTargetPath); } +function isExternalAssetReference(referencePath: string): boolean { + return ( + /^[a-z][a-z0-9+.-]*:/i.test(referencePath) || referencePath.startsWith("//") + ); +} + function renderWebManifestAsset({ resourcesDir, assetManifest, @@ -105,10 +110,38 @@ function renderWebManifestAsset({ const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as { icons?: Array<{ src?: string }>; }; - manifest.icons = manifest.icons?.map((icon) => ({ - ...icon, - src: buildAssetUrl(icon.src ?? "", assetManifest), - })); + manifest.icons = manifest.icons?.map((icon) => { + const src = icon.src; + if (src === undefined) { + return icon; + } + + if (src.trim().length === 0) { + throw new Error( + "Derived asset manifest.json contains an icon with a blank src", + ); + } + + if (isExternalAssetReference(src)) { + return icon; + } + + const referencedAssetPath = resolveDerivedAssetReference( + "manifest.json", + src, + ); + const referencedHashedUrl = assetManifest[referencedAssetPath]; + if (!referencedHashedUrl) { + throw new Error( + `Derived asset manifest.json references ${referencedAssetPath}, but it is missing from the asset manifest`, + ); + } + + return { + ...icon, + src: referencedHashedUrl, + }; + }); return `${JSON.stringify(manifest, null, 2)}\n`; } diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts index 83b9de105..9649a26f9 100644 --- a/tests/server/PublicAssetManifest.test.ts +++ b/tests/server/PublicAssetManifest.test.ts @@ -69,6 +69,23 @@ describe("PublicAssetManifest", () => { ); } + async function writeWebManifestFixture( + resourcesDir: string, + icons: Array<{ src?: string }>, + ): Promise { + await fs.writeFile( + path.join(resourcesDir, "manifest.json"), + JSON.stringify( + { + name: "OpenFront", + icons, + }, + null, + 2, + ), + ); + } + afterEach(async () => { clearPublicAssetManifestCache(); if (tempDir) { @@ -81,17 +98,9 @@ describe("PublicAssetManifest", () => { const { resourcesDir, outDir } = await createTempResources(); await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true }); - await fs.writeFile( - path.join(resourcesDir, "manifest.json"), - JSON.stringify( - { - name: "OpenFront", - icons: [{ src: "icons/app-icon.png" }], - }, - null, - 2, - ), - ); + await writeWebManifestFixture(resourcesDir, [ + { src: "icons/app-icon.png" }, + ]); await fs.writeFile( path.join(resourcesDir, "icons", "app-icon.png"), "icon-v1", @@ -125,6 +134,61 @@ describe("PublicAssetManifest", () => { expect(firstOutput).not.toContain(secondIconHref); }); + test("rewrites root-relative web manifest icon paths to hashed URLs", async () => { + const { resourcesDir, outDir } = await createTempResources(); + + await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true }); + await writeWebManifestFixture(resourcesDir, [ + { src: "/icons/app-icon.png" }, + ]); + await fs.writeFile( + path.join(resourcesDir, "icons", "app-icon.png"), + "icon-v1", + "utf8", + ); + + const assetManifest = buildPublicAssetManifest(resourcesDir); + createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + + const emittedManifest = await emitHashedAsset( + outDir, + assetManifest["manifest.json"], + ); + + expect(emittedManifest).toContain(assetManifest["icons/app-icon.png"]); + expect(emittedManifest).not.toContain('"/icons/app-icon.png"'); + }); + + test("fails when web manifest references a missing local icon", async () => { + const { resourcesDir } = await createTempResources(); + + await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]); + + expect(() => buildPublicAssetManifest(resourcesDir)).toThrow( + /manifest\.json references icons\/missing\.png/i, + ); + }); + + test("leaves external and data web manifest icon refs unchanged", async () => { + const { resourcesDir, outDir } = await createTempResources(); + + await writeWebManifestFixture(resourcesDir, [ + { src: "https://cdn.example.com/app-icon.png" }, + { src: "data:image/png;base64,AAA" }, + ]); + + const assetManifest = buildPublicAssetManifest(resourcesDir); + createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + + const emittedManifest = await emitHashedAsset( + outDir, + assetManifest["manifest.json"], + ); + + expect(emittedManifest).toContain("https://cdn.example.com/app-icon.png"); + expect(emittedManifest).toContain("data:image/png;base64,AAA"); + }); + test("rewrites BMFont XML page filenames to hashed relative paths", async () => { const { resourcesDir, outDir } = await createTempResources();