diff --git a/package-lock.json b/package-lock.json index 404ad91..cf736d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "kuromoji": "^0.1.2", diff --git a/package.json b/package.json index 5cc1651..6a13e77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "avatarscript", - "version": "0.3.0", + "version": "0.3.1", "description": "Turn an avatar and text into a lip-synced, acted video.", "keywords": [ "avatar", diff --git a/src/avatar.ts b/src/avatar.ts index 8445123..91f92ef 100644 --- a/src/avatar.ts +++ b/src/avatar.ts @@ -78,9 +78,16 @@ function diskStore(dir: string): Store { function urlStore(url: string, cacheDir: string): Store { const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`${url}: only http and https avatar URLs are supported`); - const folder = parsed.pathname.endsWith("/avatar.json") ? new URL(".", parsed).href : parsed.href.replace(/\/?$/, "/"); + // locations are kept without the source's query, which (a version, a signature) goes with every + // request for a file that has no query of its own + const folderUrl = new URL(parsed); + folderUrl.search = folderUrl.hash = ""; + folderUrl.pathname = parsed.pathname.endsWith("/avatar.json") ? parsed.pathname.slice(0, -"avatar.json".length) : parsed.pathname.replace(/\/?$/, "/"); + const folder = folderUrl.href; const download = async (location: string, kind: "json" | "png") => { - const response = await fetch(location, { signal: AbortSignal.timeout(120_000) }); + const request = new URL(location); + if (!request.search) request.search = parsed.search; + const response = await fetch(request, { signal: AbortSignal.timeout(120_000) }); if (response.status === 404) return undefined; if (!response.ok) throw new Error(`${location}: HTTP ${response.status}`); if (Number(response.headers.get("content-length") ?? 0) > MAX_DOWNLOAD) throw new Error(`${location}: larger than ${MAX_DOWNLOAD} bytes`); @@ -95,8 +102,9 @@ function urlStore(url: string, cacheDir: string): Store { contains: (root, location) => location.startsWith(root.replace(/\/?$/, "/")), async read(location, kind, cacheKey) { if (!cacheKey) return download(location, kind); - // cached per package and per listing that names the file, so a rebuilt avatar is fetched again - const file = join(cacheDir, hash(folder), cacheKey, hash(location) + "." + kind); + // cached per package (and its query: another version) and per listing that names the file, so + // a rebuilt avatar is fetched again + const file = join(cacheDir, hash(folder + parsed.search), cacheKey, hash(location) + "." + kind); const cached = await readFile(file).catch(() => undefined); if (cached) return cached; const body = await download(location, kind); diff --git a/tests/avatar.test.ts b/tests/avatar.test.ts index 45ae118..a9e5a71 100644 --- a/tests/avatar.test.ts +++ b/tests/avatar.test.ts @@ -9,6 +9,7 @@ import { loadAvatar } from "../src/avatar.ts"; // Serves avatars/ani over HTTP, with per-test overrides, and counts requests per path. const ANI = resolve(import.meta.dirname, "../avatars/ani"); const requests: string[] = []; +const queries: string[] = []; const overrides = new Map(); let server: Server; let base: string; @@ -19,6 +20,7 @@ beforeAll(async () => { server = createServer((req, res) => { const path = decodeURIComponent((req.url ?? "/").split("?")[0]); requests.push(path); + queries.push((req.url ?? "").split("?")[1] ?? ""); const override = overrides.get(path); if (override === null) return void res.writeHead(404).end(); if (override !== undefined) return void res.end(override); @@ -39,6 +41,7 @@ afterAll(async () => { const fresh = () => { requests.length = 0; + queries.length = 0; overrides.clear(); }; @@ -84,6 +87,30 @@ describe("loadAvatar", () => { expect(avatar.drawnMouths).toBe(true); }); + it("keeps the URL's query (a version, a signature) on every request", async () => { + fresh(); + const local = await loadAvatar(ANI); + const viaManifest = await loadAvatar(`${base}/ani/avatar.json?v=1`, { cacheDir: join(cacheDir, "query") }); + expect(viaManifest.assets).toEqual(local.assets); + expect(new Set(queries)).toEqual(new Set(["v=1"])); + fresh(); + await loadAvatar(`${base}/ani?v=2`, { cacheDir: join(cacheDir, "query") }); + expect(requests).toContain("/ani/avatar.json"); + // another version is another cache entry: its images are fetched, not taken from v=1 + expect(requests).toContain("/ani/built/base.png"); + expect(new Set(queries)).toEqual(new Set(["v=2"])); + }); + + it("keeps an asset's own query", async () => { + fresh(); + const manifest = JSON.parse(await readFile(join(ANI, "avatar.json"), "utf8")) as { assets: Record }; + overrides.set("/ani/avatar.json", JSON.stringify({ ...manifest, assets: { ...manifest.assets, rig: "rig.json?token=abc" } })); + overrides.set("/ani/rig.json", await readFile(join(ANI, "rig.json"), "utf8")); + await loadAvatar(`${base}/ani/?v=3`, { cacheDir }); + expect(queries[requests.indexOf("/ani/rig.json")]).toBe("token=abc"); + expect(queries[requests.indexOf("/ani/built/layers.json")]).toBe("v=3"); + }); + it("refuses files outside the avatar's folder", async () => { fresh(); const manifest = JSON.parse(await readFile(join(ANI, "avatar.json"), "utf8")) as Record;