From 7aa5961b6a21e9803a83b3f5f96b5d1d17621def Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 15:05:03 +0800 Subject: [PATCH 1/4] docs(spec): require cloudflare access before listing shared links Co-Authored-By: Claude Opus 5.5 --- .../shared-link-log-page-behind-access.md | 22 +++++++++++++++++++ ...shared-links-logged-to-kv-at-conversion.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 docs/spec/shared-link-log-page-behind-access.md diff --git a/docs/spec/shared-link-log-page-behind-access.md b/docs/spec/shared-link-log-page-behind-access.md new file mode 100644 index 0000000..c974f08 --- /dev/null +++ b/docs/spec/shared-link-log-page-behind-access.md @@ -0,0 +1,22 @@ +--- +id: shared-link-log-page-behind-access +status: accepted +scope: + - "src/index.ts" + - "src/shared-link-log-page.ts" +verify: check:bun test ./test/shared-link-log-page-behind-access.test.ts +related: [shared-links-logged-to-kv-at-conversion, free-plan-bindings-only, cpu-budget-10ms] +source: null +adr: null +--- +`GET /log` 只在請求經過 Cloudflare Access 驗證(`ctx.access` 有值)時才讀取 `SHARED_LINKS`,否則回 403 且不呼叫任何 KV 方法;沒有綁定時 `/log` 照原本的分享連結路由處理(404)。 + +登入由部署的人在 Zero Trust 建立的 self-hosted application 負責,應用程式網域只填 `<網域>/log` 這個路徑。保護整個 Worker 會連分享連結和 `/media/` 一起擋住,爬蟲拿不到預覽。Worker 不自己驗 JWT,只看執行環境填入的 `ctx.access`;這一層檢查是為了 Access 沒設好時(例如只保護自訂網域,卻從 `workers.dev/log` 連進來)仍然不會吐出紀錄。 + +一次瀏覽只呼叫一次 `list`,`prefix` 是一個 UTC 日期(`YYYY-MM-DD`,由 `?day=` 指定,預設今天),因為 key 以 ISO 時間戳開頭。免費方案每天最多寫 1,000 筆、一次 `list` 最多回 1,000 筆,所以一天的紀錄一次拿完(https://developers.cloudflare.com/kv/platform/limits/ 、https://developers.cloudflare.com/kv/api/list-keys/ ,2026-10-16 查閱);list 請求在免費方案每天 1,000 次(https://developers.cloudflare.com/kv/platform/pricing/ ),所以不做跨日翻頁或搜尋。資料只讀 key 的 metadata,不逐筆 `get`;metadata 沒有原網址的紀錄只顯示提示。原網址來自使用者輸入,輸出到 HTML 前一律跳脫。回應帶 `Cache-Control: no-store`。`list` 失敗回 503,不影響其他路由。 + +`ctx.access.aud` 不比對:帳號裡任何涵蓋這個 Worker 的 Access 應用程式都是部署的人自己授權的。以本地時區分日也不在範圍內。 + +違反時不會被察覺:紀錄含有所有使用這個部署的人的連結,頁面若在沒登入時也能讀,外人打開 `/log` 不會留下任何讓部署的人注意到的痕跡;網址沒跳脫時,惡意的轉換請求會把腳本存進紀錄,等部署的人登入後打開頁面才執行。 + +由 `test/shared-link-log-page-behind-access.test.ts` 檢查(即 `verify` 指令):沒有 `ctx.access` 時回 403 且不呼叫 `list`;沒有綁定時 `/log` 回 404;`list` 的 `prefix` 等於指定或今天的 UTC 日期,且只呼叫一次;新的紀錄排在前面;網址經過 HTML 跳脫;回應帶 `no-store`;`list` 失敗時回 503;不合法的 `day` 回 400 且不呼叫 `list`。 diff --git a/docs/spec/shared-links-logged-to-kv-at-conversion.md b/docs/spec/shared-links-logged-to-kv-at-conversion.md index 86ed68a..619a694 100644 --- a/docs/spec/shared-links-logged-to-kv-at-conversion.md +++ b/docs/spec/shared-links-logged-to-kv-at-conversion.md @@ -6,7 +6,7 @@ scope: - "src/index.ts" - "src/shared-link-log.ts" verify: check:bun test ./test/shared-links-logged-to-kv-at-conversion.test.ts -related: [free-plan-bindings-only, fail-open-to-original, tracking-params-cleaned-at-conversion] +related: [free-plan-bindings-only, fail-open-to-original, tracking-params-cleaned-at-conversion, shared-link-log-page-behind-access] source: null adr: null --- From 672ece923892751bdfc3daa9626028edd25c1ab0 Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 15:05:03 +0800 Subject: [PATCH 2/4] feat(worker): show a day of shared links at /log behind cloudflare access Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 2 + src/shared-link-log-page.ts | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/shared-link-log-page.ts diff --git a/src/index.ts b/src/index.ts index d66c080..f80dfc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { fixServiceUrl } from "./fix-services"; import { instagramPost, isShortcode, shortcodeOfMediaId, type Post, type PostCache } from "./instagram"; import { renderOgPage } from "./og"; import { logSharedLink } from "./shared-link-log"; +import { hasSharedLinkLog, sharedLinkLogPage } from "./shared-link-log-page"; const CONVERT_HINT = "pass a percent-encoded http(s) URL as ?url="; const POST_PATH = /^\/(p|reel)\/([^/]+)(?:\/([1-9]\d?))?\/?$/; @@ -206,6 +207,7 @@ export function createWorker(deps: { cache: () => PostCache }): { const url = new URL(request.url); if (url.pathname === "/") return convert(url, env, ctx); if (url.pathname.startsWith("/media/")) return mediaRedirect(url, deps); + if (url.pathname === "/log" && hasSharedLinkLog(env)) return sharedLinkLogPage(url, env, ctx); return shareRedirect(url, request.headers.get("User-Agent"), deps); }, }; diff --git a/src/shared-link-log-page.ts b/src/shared-link-log-page.ts new file mode 100644 index 0000000..f884ee2 --- /dev/null +++ b/src/shared-link-log-page.ts @@ -0,0 +1,108 @@ +const DAY = /^\d{4}-\d{2}-\d{2}$/; + +type LoggedKey = { name: string; metadata?: unknown }; + +type SharedLinkList = { + list(options: { prefix: string }): Promise<{ keys: LoggedKey[]; list_complete: boolean }>; +}; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function text(status: number, body: string): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + +function parseDay(value: string | null): Date | null { + if (value === null) return new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`); + if (!DAY.test(value)) return null; + const day = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== value ? null : day; +} + +function shiftDay(day: Date, days: number): string { + return new Date(day.getTime() + days * 86_400_000).toISOString().slice(0, 10); +} + +function row(key: LoggedKey, origin: string): string { + const metadata = (key.metadata ?? {}) as { url?: unknown; raw?: unknown }; + const time = escapeHtml(key.name.slice(11, 19)); + if (typeof metadata.url !== "string") { + return `${time}(URL exceeded the metadata limit)`; + } + const url = escapeHtml(metadata.url); + const share = escapeHtml(metadata.raw === true ? metadata.url : `${origin}/${metadata.url.replace(/^https:\/\//, "")}`); + const badge = metadata.raw === true ? " raw" : ""; + return `${time}${url}${share}${badge}`; +} + +function render(day: Date, keys: LoggedKey[], complete: boolean, origin: string): string { + const label = day.toISOString().slice(0, 10); + const rows = keys.map((key) => row(key, origin)).reverse().join("\n"); + const truncated = complete ? "" : "

Only the first 1,000 records of this day are shown.

"; + return ` + + + + + +Shared links + + + + +

${keys.length} records. Records expire after 90 days.

+${truncated} + + + +${rows} + +
Time (UTC)Original URLShare link
+ + +`; +} + +export function hasSharedLinkLog(env: Env): boolean { + return (env as { SHARED_LINKS?: SharedLinkList }).SHARED_LINKS !== undefined; +} + +export async function sharedLinkLogPage(requestUrl: URL, env: Env, ctx: ExecutionContext): Promise { + if (ctx.access === undefined) return text(403, "forbidden"); + const day = parseDay(requestUrl.searchParams.get("day")); + if (day === null) return text(400, "pass ?day=YYYY-MM-DD"); + const store = (env as { SHARED_LINKS?: SharedLinkList }).SHARED_LINKS; + if (store === undefined) return text(404, "not found"); + let listed: { keys: LoggedKey[]; list_complete: boolean }; + try { + listed = await store.list({ prefix: day.toISOString().slice(0, 10) }); + } catch { + return text(503, "shared link log unavailable"); + } + return new Response(render(day, listed.keys, listed.list_complete, requestUrl.origin), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Robots-Tag": "noindex", + "Referrer-Policy": "no-referrer", + }, + }); +} From 8d77c2a9d7da5b826897fa748afe6340478092cc Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 15:05:03 +0800 Subject: [PATCH 3/4] test(worker): keep the shared link page closed without access Co-Authored-By: Claude Opus 5.5 --- ...shared-link-log-page-behind-access.test.ts | 102 ++++++++++++++++++ test/support/fake-kv.ts | 24 ++++- 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 test/shared-link-log-page-behind-access.test.ts diff --git a/test/shared-link-log-page-behind-access.test.ts b/test/shared-link-log-page-behind-access.test.ts new file mode 100644 index 0000000..cd18113 --- /dev/null +++ b/test/shared-link-log-page-behind-access.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, setSystemTime, test } from "bun:test"; +import { createWorker } from "../src/index"; +import { fakeCache } from "./support/fake-cache"; +import { type KvKey, type KvMode, fakeKv } from "./support/fake-kv"; + +const ORIGIN = "https://link.example"; + +afterEach(() => setSystemTime()); + +function ctxWith(access: boolean): ExecutionContext { + const ctx = { + waitUntil() {}, + passThroughOnException() {}, + ...(access ? { access: { aud: "aud-tag", getIdentity: () => Promise.resolve(undefined) } } : {}), + }; + return ctx as unknown as ExecutionContext; +} + +function key(at: string, metadata: Record): KvKey { + return { name: `${at}-00000000-0000-4000-8000-000000000000`, metadata: { at, ...metadata } }; +} + +async function page( + path: string, + options: { access?: boolean; binding?: boolean; mode?: KvMode; keys?: KvKey[] } = {}, +): Promise<{ response: Response; body: string; lists: { prefix: string }[] }> { + const kv = fakeKv(options.mode ?? "ok", options.keys ?? []); + const env = (options.binding ?? true) ? { SHARED_LINKS: kv.binding } : {}; + const worker = createWorker({ cache: () => fakeCache().cache }); + const response = await worker.fetch(new Request(`${ORIGIN}${path}`), env as unknown as Env, ctxWith(options.access ?? true)); + return { response, body: await response.text(), lists: kv.lists }; +} + +test("without Access the page is forbidden and the store is not listed", async () => { + const { response, lists } = await page("/log", { access: false }); + expect(response.status).toBe(403); + expect(lists).toHaveLength(0); +}); + +test("without the binding /log is routed like any other unknown path", async () => { + const { response, lists } = await page("/log", { binding: false }); + expect(response.status).toBe(404); + expect(lists).toHaveLength(0); +}); + +test("the default day is today in UTC and the store is listed once", async () => { + setSystemTime(new Date("2026-10-16T23:59:59.000Z")); + const { response, lists } = await page("/log"); + expect(response.status).toBe(200); + expect(lists).toEqual([{ prefix: "2026-10-16" }]); +}); + +test("?day= picks the listed day", async () => { + const { lists } = await page("/log?day=2026-01-02"); + expect(lists).toEqual([{ prefix: "2026-01-02" }]); +}); + +test("an invalid day is rejected before listing", async () => { + for (const day of ["2026-1-2", "2026-13-01", "2026-02-30", "x"]) { + const { response, lists } = await page(`/log?day=${day}`); + expect(response.status).toBe(400); + expect(lists).toHaveLength(0); + } +}); + +test("records are shown newest first with their share links", async () => { + const keys = [ + key("2026-10-16T01:00:00.000Z", { url: "https://www.instagram.com/p/OLDER/" }), + key("2026-10-16T02:00:00.000Z", { url: "https://www.youtube.com/watch?v=newer", raw: true }), + ]; + const { body } = await page("/log?day=2026-10-16", { keys }); + expect(body.indexOf("youtube")).toBeLessThan(body.indexOf("OLDER")); + expect(body).toContain(`${ORIGIN}/www.instagram.com/p/OLDER/`); + expect(body).not.toContain(`${ORIGIN}/www.youtube.com`); + expect(body).toContain("02:00:00"); +}); + +test("a record without a url in its metadata still renders", async () => { + const { response, body } = await page("/log?day=2026-10-16", { keys: [key("2026-10-16T01:00:00.000Z", {})] }); + expect(response.status).toBe(200); + expect(body).toContain("01:00:00"); +}); + +test("urls are HTML-escaped", async () => { + const keys = [key("2026-10-16T01:00:00.000Z", { url: `https://www.youtube.com/watch?v=">` })]; + const { body } = await page("/log?day=2026-10-16", { keys }); + expect(body).not.toContain("