diff --git a/cloudflare-workers/backblaze-proxy/index.test.ts b/cloudflare-workers/backblaze-proxy/index.test.ts index 08e36ad..2c5dc94 100644 --- a/cloudflare-workers/backblaze-proxy/index.test.ts +++ b/cloudflare-workers/backblaze-proxy/index.test.ts @@ -166,3 +166,121 @@ describe("backblaze-proxy health", () => { }); }); + +describe("backblaze-proxy clip views", () => { + const API_URL = "https://api.5stack.gg"; + const CLIP = "clips/76561198000000001/0b7c.mp4"; + const UA = "Mozilla/5.0 (Macintosh) Chrome/141"; + + let waited: Promise[]; + let cacheKeys: string[]; + + function ctx() { + return { + waitUntil: (promise: Promise) => waited.push(promise), + passThroughOnException: () => {}, + } as any; + } + + function viewCalls() { + return upstream.mock.calls.filter((call) => + String(call.arguments[0]).startsWith(`${API_URL}/clip-views/play`), + ); + } + + async function send( + method: string, + path: string, + headers: Record = {}, + ) { + const response = await worker.fetch( + new Request(`https://cf.5stack.gg/${path}`, { + method, + headers: { "user-agent": UA, "cf-connecting-ip": "203.0.113.9", ...headers }, + }), + { ...env, API_URL }, + ctx(), + ); + await Promise.all(waited); + return response; + } + + beforeEach(() => { + waited = []; + cacheKeys = []; + (globalThis as any).caches = { + default: { + match: async (request: Request) => { + cacheKeys.push(request.url); + return undefined; + }, + put: async (request: Request) => { + cacheKeys.push(request.url); + }, + }, + }; + }); + + it("counts a played clip", async () => { + await send("GET", `${CLIP}?name=ace.mp4&v=1`); + + assert.equal(viewCalls().length, 1); + }); + + it("does not count a request marked noview", async () => { + await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`); + + assert.equal(viewCalls().length, 0); + }); + + it("keeps one edge copy of a clip whether or not the request is marked noview", async () => { + await send("GET", `${CLIP}?name=ace.mp4&v=1&noview=1`); + const marked = [...cacheKeys]; + cacheKeys = []; + await send("GET", `${CLIP}?name=ace.mp4&v=1`); + + assert.deepEqual(marked, cacheKeys); + assert.ok(cacheKeys.every((key) => !key.includes("noview"))); + }); + + it("drops noview from the cache key without re-encoding the rest", async () => { + await send("GET", `${CLIP}?name=ace%20on%20B.mp4&noview=1&v=1`); + + assert.ok( + cacheKeys.every( + (key) => + key === + `https://cf.5stack.gg/${CLIP}?name=ace%20on%20B.mp4&v=1#origin=`, + ), + ); + }); + + it("still keeps a named download apart from the inline copy", async () => { + await send("GET", `${CLIP}?name=ace.mp4&v=1&dl=1`); + const download = [...cacheKeys]; + cacheKeys = []; + await send("GET", `${CLIP}?name=ace.mp4&v=1`); + + assert.notDeepEqual(download, cacheKeys); + }); + + it("refuses a POST to a clip without counting or fetching it", async () => { + const response = await send("POST", `${CLIP}?name=ace.mp4&v=1`, { + Origin: ORIGIN, + }); + + assert.equal(response.status, 405); + assert.equal(viewCalls().length, 0); + assert.equal(upstream.mock.callCount(), 0); + assert.deepEqual(cacheKeys, []); + }); + + it("does not offer POST to a browser", async () => { + const response = await send("OPTIONS", CLIP, { Origin: ORIGIN }); + + assert.doesNotMatch( + response.headers.get("Access-Control-Allow-Methods") ?? "", + /POST/, + ); + }); +}); diff --git a/cloudflare-workers/backblaze-proxy/index.ts b/cloudflare-workers/backblaze-proxy/index.ts index 4dc1cfd..b38bd6d 100644 --- a/cloudflare-workers/backblaze-proxy/index.ts +++ b/cloudflare-workers/backblaze-proxy/index.ts @@ -235,6 +235,23 @@ function registerView( ); } +// `noview` only decides whether the request is counted, so it stays out of +// the cache key: the clip player's marked warm-up of the next clip and the +// unmarked play share the single copy fetched from B2. Filtered on the raw +// query so the other parameters keep the exact encoding existing cache +// entries were stored under. +function cacheableUrl(url: URL): string { + if (!url.searchParams.has("noview")) { + return url.toString(); + } + const query = url.search + .slice(1) + .split("&") + .filter((part) => part.split("=")[0] !== "noview") + .join("&"); + return `${url.origin}${url.pathname}${query ? `?${query}` : ""}`; +} + // Decorates a response on its way to the client: mp4 Content-Type fix and the // view beacon. The body is never read or rewritten here. function clientResponse( @@ -344,7 +361,7 @@ export default { // Origin is in the cache key so each caller gets its own // Access-Control-Allow-Origin (credentialed fetches can't take `*`). const cache = caches.default; - const cacheUrl = `${request.url}#origin=${reqOrigin ?? ""}`; + const cacheUrl = `${cacheableUrl(url)}#origin=${reqOrigin ?? ""}`; const cacheKey = new Request(cacheUrl, { method: "GET" }); const isHead = request.method === "HEAD"; const lookupKey = rangeHeader