Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/file-storage/upload-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
* browsers do NOT execute scripts when SVG is loaded as a pure image.
* - Top-level navigation, `<object>`, `<iframe>` (e.g. opening the
* asset URL in a new tab) DOES execute scripts, but in the CDN
* origin (e.g. `decoims.com`), not the app's. As long as the CDN
* origin (e.g. `assets.decocms.com`), not the app's. As long as the CDN
* domain doesn't share cookies/auth with the app, the blast radius
* is limited to "the SVG can phone home as the visitor."
* If you ever serve assets from the same eTLD+1 as the app, remove SVG
Expand Down
169 changes: 169 additions & 0 deletions apps/api/src/hosted/delivery-purge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, it } from "bun:test";
import { createDeliveryPurge } from "./delivery-purge";
import { CACHE_LATEST, CACHE_REVISION, CACHE_DRAFT } from "./delivery-store";

const KEY = "sites/acme/latest.json";

function fakeFetch(responses: Array<{ status: number; success: boolean }>) {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fn = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
const r = responses[Math.min(calls.length - 1, responses.length - 1)]!;
return new Response(JSON.stringify({ success: r.success }), {
status: r.status,
});
}) as unknown as typeof fetch;
return { fn, calls };
}

describe("cache headers", () => {
it("lets the edge hold latest.json 1 h while every reader revalidates", () => {
expect(CACHE_LATEST).toBe(
"public, max-age=0, s-maxage=3600, must-revalidate",
);
expect(CACHE_REVISION).toBe("public, max-age=31536000, immutable");
expect(CACHE_DRAFT).toBe("no-cache, max-age=0, must-revalidate");
});
});

describe("createDeliveryPurge", () => {
it("purges exactly the object's public URL on the zone", async () => {
const f = fakeFetch([{ status: 200, success: true }]);
await createDeliveryPurge({
zoneId: "zone1",
apiToken: "tok",
fetch: f.fn,
}).purge(KEY);
expect(f.calls).toHaveLength(1);
expect(f.calls[0]!.url).toBe(
"https://api.cloudflare.com/client/v4/zones/zone1/purge_cache",
);
expect(f.calls[0]!.init.method).toBe("POST");
expect(
(f.calls[0]!.init.headers as Record<string, string>).authorization,
).toBe("Bearer tok");
expect(JSON.parse(f.calls[0]!.init.body as string)).toEqual({
files: ["https://delivery.decocms.com/sites/acme/latest.json"],
});
});

it("uses the configured public delivery origin", async () => {
const f = fakeFetch([{ status: 200, success: true }]);
await createDeliveryPurge({
zoneId: "zone1",
apiToken: "tok",
publicOrigin: "http://localhost:9999/",
fetch: f.fn,
}).purge(KEY);
expect(JSON.parse(f.calls[0]!.init.body as string)).toEqual({
files: ["http://localhost:9999/sites/acme/latest.json"],
});
});

it("retries a failed attempt once and succeeds when the retry does", async () => {
const f = fakeFetch([
{ status: 503, success: false },
{ status: 200, success: true },
]);
await createDeliveryPurge({
zoneId: "z",
apiToken: "t",
fetch: f.fn,
}).purge(KEY);
expect(f.calls).toHaveLength(2);
});

it("fails when both attempts fail: exactly two calls", async () => {
const f = fakeFetch([
{ status: 503, success: false },
{ status: 502, success: false },
{ status: 200, success: true },
]);
await expect(
createDeliveryPurge({ zoneId: "z", apiToken: "t", fetch: f.fn }).purge(
KEY,
),
).rejects.toThrow("HTTP 502");
expect(f.calls).toHaveLength(2);
});

it("fails when Cloudflare answers 200 without success (twice)", async () => {
const f = fakeFetch([{ status: 200, success: false }]);
await expect(
createDeliveryPurge({ zoneId: "z", apiToken: "t", fetch: f.fn }).purge(
KEY,
),
).rejects.toThrow("HTTP 200");
expect(f.calls).toHaveLength(2);
});

it("counts a timed-out attempt as failed and retries it once", async () => {
let calls = 0;
const hangOnce = ((_url: string, init: RequestInit) => {
calls++;
if (calls > 1) {
return Promise.resolve(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
}
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
});
}) as unknown as typeof fetch;
await createDeliveryPurge({
zoneId: "z",
apiToken: "t",
fetch: hangOnce,
timeoutMs: 20,
}).purge(KEY);
expect(calls).toBe(2);
});

it("fails when both attempts time out: exactly two calls", async () => {
let calls = 0;
const hanging = ((_url: string, init: RequestInit) => {
calls++;
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
});
}) as unknown as typeof fetch;
await expect(
createDeliveryPurge({
zoneId: "z",
apiToken: "t",
fetch: hanging,
timeoutMs: 20,
}).purge(KEY),
).rejects.toThrow("timed out after 20 ms");
expect(calls).toBe(2);
});

it("bounds each attempt with a timeout signal by default", async () => {
const f = fakeFetch([{ status: 200, success: true }]);
await createDeliveryPurge({
zoneId: "z",
apiToken: "t",
fetch: f.fn,
}).purge(KEY);
expect(f.calls[0]!.init.signal).toBeInstanceOf(AbortSignal);
});

it("skips with a warning when unconfigured", async () => {
const warnings: string[] = [];
const f = fakeFetch([{ status: 200, success: true }]);
await createDeliveryPurge({
zoneId: undefined,
apiToken: "t",
fetch: f.fn,
warn: (m) => warnings.push(m),
}).purge(KEY);
expect(f.calls).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("not configured");
expect(warnings[0]).toContain(KEY);
});
});
98 changes: 98 additions & 0 deletions apps/api/src/hosted/delivery-purge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Purging latest.json from Cloudflare's edge after Studio rewrites it.
*
* latest.json is served `s-maxage=3600`: the edge may hold it up to an hour.
* Every write (Publish, Make current) is followed by a purge of that
* URL through the zone's purge_cache API, so the new pointer is seen at once.
* Each attempt has a 5 s timeout and a failed attempt is retried exactly once
* (two attempts at most). If both fail, the operation fails: Studio shows it
* and the user retries from there. The hour bounds staleness.
*/

import { getSettings } from "@/settings";
import { DELIVERY_ORIGIN } from "./delivery-store";

export interface DeliveryPurge {
/** Purges the public URL of the delivery object `key`; throws on failure. */
purge(key: string): Promise<void>;
}

/** How long one purge attempt may take before it counts as failed. */
const PURGE_TIMEOUT_MS = 5000;

/** One attempt plus exactly one retry. */
const PURGE_ATTEMPTS = 2;

export function createDeliveryPurge(config: {
zoneId: string | undefined;
apiToken: string | undefined;
publicOrigin?: string;
fetch?: typeof fetch;
timeoutMs?: number;
warn?: (message: string) => void;
}): DeliveryPurge {
const { zoneId, apiToken } = config;
if (!zoneId || !apiToken) {
const warn = config.warn ?? console.warn;
return {
async purge(key) {
warn(
`hosted delivery: Cloudflare purge not configured; skipped purging ${key} (the edge may serve the previous copy for up to 1 h)`,
);
},
};
}
const doFetch = config.fetch ?? fetch;
const timeoutMs = config.timeoutMs ?? PURGE_TIMEOUT_MS;
const origin = new URL(config.publicOrigin ?? DELIVERY_ORIGIN).origin;
const endpoint = `https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(zoneId)}/purge_cache`;
return {
async purge(key) {
const url = `${origin}/${key}`;
const attempt = async () => {
let res: Response;
try {
res = await doFetch(endpoint, {
method: "POST",
headers: {
authorization: `Bearer ${apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ files: [url] }),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
const name = (error as { name?: string } | null)?.name;
throw new Error(
name === "TimeoutError"
? `purge ${url}: timed out after ${timeoutMs} ms`
: `purge ${url}: ${error instanceof Error ? error.message : String(error)}`,
);
}
const body = (await res.json().catch(() => null)) as {
success?: unknown;
} | null;
if (!res.ok || body?.success !== true) {
throw new Error(`purge ${url}: HTTP ${res.status}`);
}
};
for (let i = 1; ; i++) {
try {
return await attempt();
} catch (error) {
if (i >= PURGE_ATTEMPTS) throw error;
}
}
},
};
}

/** The configured purge; unconfigured (local/dev/tests) it skips with a warning. */
export function deliveryPurge(): DeliveryPurge {
const s = getSettings();
return createDeliveryPurge({
zoneId: s.cfDeliveryZoneId,
apiToken: s.cfPurgeApiToken,
publicOrigin: s.deliveryPublicOrigin,
});
}
61 changes: 61 additions & 0 deletions apps/api/src/hosted/delivery-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "bun:test";
import {
bareEtag,
deliveryKeys,
listRevisionShas,
newDraftSlug,
} from "./delivery-store";
import { memoryDeliveryStore } from "./hosted-test-helpers";

const SHA = "a".repeat(40);

describe("deliveryKeys", () => {
it("builds the three object keys under the site's prefix", () => {
const slug = newDraftSlug();
expect(deliveryKeys.latest("acme")).toBe("sites/acme/latest.json");
expect(deliveryKeys.revision("acme", SHA)).toBe(
`sites/acme/revisions/${SHA}.json`,
);
expect(deliveryKeys.draft("acme", slug)).toBe(
`sites/acme/drafts/${slug}.json`,
);
});

it("refuses a site, revision or slug that could leave the prefix", () => {
expect(() => deliveryKeys.latest("../acme")).toThrow();
expect(() => deliveryKeys.latest("Acme")).toThrow();
expect(() => deliveryKeys.revision("acme", "main")).toThrow();
expect(() => deliveryKeys.revision("acme", "A".repeat(40))).toThrow();
expect(() => deliveryKeys.draft("acme", "x/../y")).toThrow();
});
});

describe("newDraftSlug", () => {
it("is 22 base64url characters and unique", () => {
const a = newDraftSlug();
expect(a).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(newDraftSlug()).not.toBe(a);
});
});

describe("bareEtag", () => {
it("drops the quotes and the weak prefix", () => {
expect(bareEtag('"abc"')).toBe("abc");
expect(bareEtag('W/"abc"')).toBe("abc");
expect(bareEtag(null)).toBeNull();
});
});

describe("listRevisionShas", () => {
it("lists only the site's revision objects", async () => {
const { store } = memoryDeliveryStore();
await store.putJson(deliveryKeys.revision("acme", SHA), {}, "x");
await store.putJson(deliveryKeys.latest("acme"), {}, "x");
await store.putJson(
deliveryKeys.revision("acme-2", "b".repeat(40)),
{},
"x",
);
expect([...(await listRevisionShas(store, "acme"))]).toEqual([SHA]);
});
});
Loading
Loading