From 69511a409c8a0c7e39459548b7a146ab7cd5f397 Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 01:47:38 +0800 Subject: [PATCH 1/8] docs(spec): allow an optional kv namespace for logging shared links Co-Authored-By: Claude Opus 5.5 --- .coderabbit.yaml | 2 ++ docs/spec/fail-open-to-original.md | 2 +- docs/spec/free-plan-bindings-only.md | 6 +++--- ...shared-links-logged-to-kv-at-conversion.md | 20 +++++++++++++++++++ .../tracking-params-cleaned-at-conversion.md | 2 +- 5 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 docs/spec/shared-links-logged-to-kv-at-conversion.md diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c588117..996b89a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -30,3 +30,5 @@ knowledge_base: applyTo: "src/**/*.ts" - files: "docs/spec/free-plan-bindings-only.md" applyTo: "cloudflare.config.ts" + - files: "docs/spec/shared-links-logged-to-kv-at-conversion.md" + applyTo: "cloudflare.config.ts" diff --git a/docs/spec/fail-open-to-original.md b/docs/spec/fail-open-to-original.md index f3e29d3..86e309a 100644 --- a/docs/spec/fail-open-to-original.md +++ b/docs/spec/fail-open-to-original.md @@ -4,7 +4,7 @@ status: accepted scope: - "src/**" verify: check:bun test ./test/fail-open-to-original.test.ts -related: [cpu-budget-10ms, tracking-params-cleaned-at-conversion] +related: [cpu-budget-10ms, tracking-params-cleaned-at-conversion, shared-links-logged-to-kv-at-conversion] source: better-link-design-spec adr: null --- diff --git a/docs/spec/free-plan-bindings-only.md b/docs/spec/free-plan-bindings-only.md index b86a4c9..00b57e1 100644 --- a/docs/spec/free-plan-bindings-only.md +++ b/docs/spec/free-plan-bindings-only.md @@ -5,13 +5,13 @@ scope: - "cloudflare.config.ts" - "src/**" verify: check:bun test ./test/config.test.ts -t "storage.binding" -related: [cpu-budget-10ms, signed-cdn-urls-never-outlive-signature] +related: [cpu-budget-10ms, signed-cdn-urls-never-outlive-signature, shared-links-logged-to-kv-at-conversion] source: better-link-design-spec adr: null --- -只使用 Workers 免費方案內含的功能,不宣告 KV、R2、D1、Durable Objects、Queues 或任何付費 binding。 +只使用 Workers 免費方案內含的功能,不宣告 KV、R2、D1、Durable Objects、Queues 或任何付費 binding;唯一的例外是部署的人用建置變數啟用、用來紀錄分享連結的一個 KV 命名空間,它是可選功能,寫法由 `shared-links-logged-to-kv-at-conversion` 規定。 -需要暫存的資料一律用 Cache API(`caches.default` 的 `put` 和 `match`)。`cloudflare.config.ts` 裡不出現任何儲存類 binding。 +需要暫存的資料一律用 Cache API(`caches.default` 的 `put` 和 `match`)。預設的 `cloudflare.config.ts` 裡不出現任何儲存類 binding。 環境變數和 secrets 不算 binding,可以使用。Cache API 只存在寫入它的資料中心、不會同步到其他地方,這是接受的代價,不是要修的問題。 diff --git a/docs/spec/shared-links-logged-to-kv-at-conversion.md b/docs/spec/shared-links-logged-to-kv-at-conversion.md new file mode 100644 index 0000000..5042154 --- /dev/null +++ b/docs/spec/shared-links-logged-to-kv-at-conversion.md @@ -0,0 +1,20 @@ +--- +id: shared-links-logged-to-kv-at-conversion +status: accepted +scope: + - "cloudflare.config.ts" + - "src/index.ts" +verify: null +related: [free-plan-bindings-only, fail-open-to-original, tracking-params-cleaned-at-conversion] +source: null +adr: null +--- +紀錄分享連結是可選功能:預設不啟用,啟用後只在 `GET /?url=` 成功產生分享連結時寫進一個 Workers KV 命名空間,每一筆都帶 `expirationTtl`,而且寫入成功與否都不能改變回應。 + +`cloudflare.config.ts` 只在建置時的環境變數有設定時才宣告 `bindings.kv()`,部署的人在 Workers Builds 的建置變數裡設定它來啟用;在 Cloudflare 後台手動加的綁定會在下次部署時被設定檔覆蓋,所以不能當作啟用方式。預設匯出必須維持一般物件,因為 `test/config.test.ts` 直接讀它。Worker 在執行時看 `env` 有沒有這個綁定來決定要不要寫。寫入放在 `ctx.waitUntil` 裡,所以 `src/index.ts` 的 `createWorker` 要把 `env` 和 `ctx` 傳下去。key 以時間戳開頭,因為 KV 列出 key 時依 UTF-8 位元組排序,這樣一次 list 就是時間順序。清過追蹤碼的原網址和時間放在 key 的 metadata(上限 1,024 bytes),list 回來就能直接讀,不用逐筆 get。舊資料靠 TTL 自動淘汰,不另寫清理機制。 + +分享連結轉址 `GET /<原網域>/<路徑>` 和 `/media/` 不寫紀錄,所以手動把 `https://` 換成 Worker 網址產生的連結不會被記到,這是接受的代價。沒有綁定 KV 時 Worker 完全不寫入,行為和沒有這個功能時相同,所以用 Deploy 按鈕產生的新部署仍然不需要任何設定。紀錄包含所有使用這個部署的人的連結;怎麼讀取紀錄、誰能讀取,不在這條規格裡。 + +違反時不會被察覺:KV 免費方案每天只能寫 1,000 次,若在轉址端點記錄,爬蟲抓預覽和收件人點開會在流量高的某天吃光額度,之後的寫入默默失敗;沒帶 TTL 的紀錄會一直累積到 1 GB 才開始寫不進去;寫入若在回應路徑上被等待而拋錯,爬蟲拿到錯誤只會默默不顯示預覽。 + +目前沒有 check,因為這個介面還不存在。實作落地後,在 worker 測試裡用假的 KV 斷言三件事即可綁定:只有 `GET /?url=` 會呼叫 `put` 且帶 `expirationTtl`、沒有綁定時回應不變、`put` 拋錯時回應不變。 diff --git a/docs/spec/tracking-params-cleaned-at-conversion.md b/docs/spec/tracking-params-cleaned-at-conversion.md index f3ab829..dc79702 100644 --- a/docs/spec/tracking-params-cleaned-at-conversion.md +++ b/docs/spec/tracking-params-cleaned-at-conversion.md @@ -4,7 +4,7 @@ status: accepted scope: - "src/**" verify: check:bun test ./test/clean.test.ts ./test/expand.test.ts -related: [fail-open-to-original] +related: [fail-open-to-original, shared-links-logged-to-kv-at-conversion] source: better-link-design-spec adr: null --- From d691e99f750f0e7f164b7d4f821794e26074251c Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:06:42 +0800 Subject: [PATCH 2/8] feat(shared-link-log): write one expiring record per shared link to a bound kv Co-Authored-By: Claude Haiku 5.5 --- src/shared-link-log.ts | 25 +++ ...d-links-logged-to-kv-at-conversion.test.ts | 164 ++++++++++++++++++ test/support/fake-kv.ts | 24 +++ 3 files changed, 213 insertions(+) create mode 100644 src/shared-link-log.ts create mode 100644 test/shared-links-logged-to-kv-at-conversion.test.ts create mode 100644 test/support/fake-kv.ts diff --git a/src/shared-link-log.ts b/src/shared-link-log.ts new file mode 100644 index 0000000..4874c4a --- /dev/null +++ b/src/shared-link-log.ts @@ -0,0 +1,25 @@ +export const SHARED_LINK_TTL_SECONDS = 7_776_000; + +type SharedLinkStore = { + put(key: string, value: string, options: { expirationTtl: number; metadata: object }): Promise; +}; + +export function logSharedLink(env: Env, ctx: ExecutionContext, cleaned: URL, raw: boolean): void { + const store = (env as { SHARED_LINKS?: SharedLinkStore }).SHARED_LINKS; + if (store === undefined) return; + const at = new Date().toISOString(); + const url = `https://${cleaned.host}${cleaned.pathname}${cleaned.search}`; + const metadata = raw ? { url, at, raw: true } : { url, at }; + const write = async () => { + try { + await store.put(`${at}-${crypto.randomUUID()}`, url, { expirationTtl: SHARED_LINK_TTL_SECONDS, metadata }); + } catch { + // Logging must never change the conversion response. + } + }; + try { + ctx.waitUntil(write()); + } catch { + // Same reason as above: a failing waitUntil is not the caller's problem. + } +} diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts new file mode 100644 index 0000000..817a20b --- /dev/null +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -0,0 +1,164 @@ +import { afterEach, expect, setSystemTime, spyOn, test } from "bun:test"; +import { logSharedLink } from "../src/shared-link-log"; +import { fakeKv } from "./support/fake-kv"; + +const KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z-[0-9a-f-]{36}$/; +const CLEANED = new URL("https://www.youtube.com/watch?v=abc"); +const YOUTUBE = "https://www.youtube.com/watch?v=abc"; + +afterEach(() => setSystemTime()); + +function fakeCtx() { + const pending: Promise[] = []; + const ctx = { + waitUntil(this: unknown, promise: Promise) { + if (this !== ctx) throw new TypeError("waitUntil must be called as a method"); + pending.push(promise); + }, + }; + return { ctx: ctx as unknown as ExecutionContext, pending }; +} + +function envWith(binding: unknown): Env { + return { SHARED_LINKS: binding } as unknown as Env; +} + +test("one conversion writes one expiring, time-keyed record", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + expect(pending).toHaveLength(1); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); + const [put] = kv.puts; + expect(put?.key).toMatch(KEY); + const at = put?.key.slice(0, 24); + expect(put?.options.expirationTtl).toBe(7_776_000); + expect(put?.value).toBe(YOUTUBE); + expect(put?.options.metadata).toEqual({ url: YOUTUBE, at }); +}); + +test("raw conversions record raw: true in metadata", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, CLEANED, true); + await Promise.all(pending); + const [put] = kv.puts; + expect(put?.options.metadata).toEqual({ url: YOUTUBE, at: put?.key.slice(0, 24), raw: true }); +}); + +test("keys from later calls sort after keys from earlier calls", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + await Promise.all(pending); + const keys = kv.puts.map((put) => put.key); + expect(keys).toEqual([...keys].sort()); +}); + +test("calls under one frozen time share the timestamp prefix and differ in key", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + setSystemTime(new Date("2026-03-03T03:03:03.003Z")); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + await Promise.all(pending); + const [first, second] = kv.puts; + expect(first?.key).not.toBe(second?.key); + expect(first?.key.slice(0, 24)).toBe("2026-03-03T03:03:03.003Z"); + expect(second?.key.slice(0, 24)).toBe("2026-03-03T03:03:03.003Z"); +}); + +test("waitUntil is invoked as a method on ctx", () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); + expect(pending).toHaveLength(1); +}); + +test("the collected promise is the write itself", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + expect(kv.puts).toHaveLength(1); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); +}); + +test("without the binding nothing is scheduled", () => { + for (const env of [{}, { SHARED_LINKS: undefined }]) { + const { ctx, pending } = fakeCtx(); + expect(logSharedLink(env as unknown as Env, ctx, CLEANED, false)).toBeUndefined(); + expect(pending).toHaveLength(0); + } +}); + +test("a put that throws synchronously is swallowed", async () => { + const kv = fakeKv("throw"); + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); +}); + +test("a put that rejects is swallowed without an unhandled rejection", async () => { + const kv = fakeKv("reject"); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + await expect(Promise.all(pending)).resolves.toBeDefined(); +}); + +test("a binding without put is swallowed", async () => { + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith({}), ctx, CLEANED, false)).not.toThrow(); + await expect(Promise.all(pending)).resolves.toBeDefined(); +}); + +test("a waitUntil that throws does not reach the caller", () => { + const kv = fakeKv(); + const ctx = { + waitUntil() { + throw new Error("boom"); + }, + } as unknown as ExecutionContext; + expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); +}); + +test("the logged URL drops userinfo and fragment", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, new URL("https://user:s3cret@www.youtube.com/watch?v=abc#t=5"), false); + await Promise.all(pending); + const [put] = kv.puts; + expect(put?.value).toBe(YOUTUBE); + expect(put?.options.metadata.url).toBe(YOUTUBE); + for (const text of [put?.value, JSON.stringify(put?.options.metadata)]) { + expect(text).not.toContain("s3cret"); + expect(text).not.toContain("user"); + expect(text).not.toContain("#"); + } +}); + +test("an http URL is logged in https form", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, new URL("http://example.com/a?b=1"), false); + await Promise.all(pending); + expect(kv.puts[0]?.value).toBe("https://example.com/a?b=1"); +}); + +test("a failed write logs nothing to the console", async () => { + const spies = [spyOn(console, "log"), spyOn(console, "error"), spyOn(console, "warn")]; + try { + const kv = fakeKv("reject"); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + await Promise.all(pending); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } +}); diff --git a/test/support/fake-kv.ts b/test/support/fake-kv.ts new file mode 100644 index 0000000..641b6fa --- /dev/null +++ b/test/support/fake-kv.ts @@ -0,0 +1,24 @@ +export type KvPut = { + key: string; + value: string; + options: { expirationTtl: number; metadata: Record }; +}; + +export type KvMode = "ok" | "throw" | "reject" | "hang"; + +export function fakeKv(mode: KvMode = "ok"): { + binding: { put(key: string, value: string, options: KvPut["options"]): Promise }; + puts: KvPut[]; +} { + const puts: KvPut[] = []; + const binding = { + put(key: string, value: string, options: KvPut["options"]): Promise { + puts.push({ key, value, options }); + if (mode === "throw") throw new Error("boom s3cret"); + if (mode === "reject") return Promise.reject(new Error("boom s3cret")); + if (mode === "hang") return new Promise(() => {}); + return Promise.resolve(); + }, + }; + return { binding, puts }; +} From 3bef2e789be1597b64517565e563fd75d057155e Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:08:02 +0800 Subject: [PATCH 3/8] feat(shared-link-log): drop the url from metadata when it exceeds 1,024 bytes Co-Authored-By: Claude Haiku 5.5 --- src/shared-link-log.ts | 10 +++- ...d-links-logged-to-kv-at-conversion.test.ts | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/shared-link-log.ts b/src/shared-link-log.ts index 4874c4a..2a5e5cb 100644 --- a/src/shared-link-log.ts +++ b/src/shared-link-log.ts @@ -1,15 +1,23 @@ export const SHARED_LINK_TTL_SECONDS = 7_776_000; +const METADATA_LIMIT_BYTES = 1_024; type SharedLinkStore = { put(key: string, value: string, options: { expirationTtl: number; metadata: object }): Promise; }; +function metadataFor(url: string, at: string, raw: boolean): object { + const full = raw ? { url, at, raw: true } : { url, at }; + const size = new TextEncoder().encode(JSON.stringify(full)).byteLength; + if (size <= METADATA_LIMIT_BYTES) return full; + return raw ? { at, raw: true } : { at }; +} + export function logSharedLink(env: Env, ctx: ExecutionContext, cleaned: URL, raw: boolean): void { const store = (env as { SHARED_LINKS?: SharedLinkStore }).SHARED_LINKS; if (store === undefined) return; const at = new Date().toISOString(); const url = `https://${cleaned.host}${cleaned.pathname}${cleaned.search}`; - const metadata = raw ? { url, at, raw: true } : { url, at }; + const metadata = metadataFor(url, at, raw); const write = async () => { try { await store.put(`${at}-${crypto.randomUUID()}`, url, { expirationTtl: SHARED_LINK_TTL_SECONDS, metadata }); diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts index 817a20b..da30654 100644 --- a/test/shared-links-logged-to-kv-at-conversion.test.ts +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -162,3 +162,55 @@ test("a failed write logs nothing to the console", async () => { for (const spy of spies) spy.mockRestore(); } }); + +function sizeOf(metadata: object): number { + return new TextEncoder().encode(JSON.stringify(metadata)).byteLength; +} + +function urlWithMetadataBytes(target: number): URL { + const at = "2026-04-04T04:04:04.004Z"; + const base = sizeOf({ url: "https://example.com/", at }); + return new URL(`https://example.com/${"a".repeat(target - base)}`); +} + +test("metadata keeps the url when it is exactly 1,024 bytes", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const cleaned = urlWithMetadataBytes(1_024); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, cleaned, false); + await Promise.all(pending); + expect(sizeOf(kv.puts[0]?.options.metadata ?? {})).toBe(1_024); + expect(kv.puts[0]?.options.metadata).toHaveProperty("url", cleaned.href); +}); + +test("metadata drops the url at 1,025 bytes but still logs it as the value", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const cleaned = urlWithMetadataBytes(1_025); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, cleaned, false); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z" }); + expect(kv.puts[0]?.value).toBe(`https://${cleaned.host}${cleaned.pathname}`); +}); + +test("the size limit counts bytes, not characters", async () => { + // 500 "é" are 1,000 bytes but only 500 characters; URL objects percent-encode non-ASCII, + // so this uses a plain object to reach the byte measure with multibyte text. + const cleaned = { host: "example.com", pathname: `/${"é".repeat(500)}`, search: "" } as unknown as URL; + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, cleaned, false); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: expect.any(String) }); +}); + +test("an oversize raw record keeps raw: true without the url", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, urlWithMetadataBytes(1_025), true); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z", raw: true }); +}); From 81a1395dd3f6c625334037a1f0b991cff2c392f7 Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:08:28 +0800 Subject: [PATCH 4/8] feat(worker): log each successful shared link conversion Co-Authored-By: Claude Haiku 5.5 --- src/index.ts | 11 ++- ...d-links-logged-to-kv-at-conversion.test.ts | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index b2dcbf0..b629ecc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { facebookPost, facebookVideo, isFacebookPostUrl, isFacebookVideoUrl } fr 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"; const CONVERT_HINT = "pass a percent-encoded http(s) URL as ?url="; const POST_PATH = /^\/(p|reel)\/([^/]+)(?:\/([1-9]\d?))?\/?$/; @@ -40,7 +41,7 @@ function text(status: number, body: string): Response { }); } -async function convert(requestUrl: URL): Promise { +async function convert(requestUrl: URL, env: Env, ctx: ExecutionContext): Promise { const raw = requestUrl.searchParams.get("url"); if (raw === null || raw === "") return text(400, CONVERT_HINT); let target: URL; @@ -51,7 +52,9 @@ async function convert(requestUrl: URL): Promise { } if (!isShareable(target)) return text(400, CONVERT_HINT); const cleaned = cleanUrl((await expandShareLink(target)) ?? target); - const prefix = requestUrl.searchParams.get("raw") === "1" ? "https://" : `${requestUrl.origin}/`; + const rawMode = requestUrl.searchParams.get("raw") === "1"; + const prefix = rawMode ? "https://" : `${requestUrl.origin}/`; + logSharedLink(env, ctx, cleaned, rawMode); return text(200, `${prefix}${cleaned.host}${cleaned.pathname}${cleaned.search}`); } @@ -198,9 +201,9 @@ export function createWorker(deps: { cache: () => PostCache }): { fetch(request: Request, env: Env, ctx: ExecutionContext): Promise; } { return { - async fetch(request, _env, _ctx) { + async fetch(request, env, ctx) { const url = new URL(request.url); - if (url.pathname === "/") return convert(url); + if (url.pathname === "/") return convert(url, env, ctx); if (url.pathname.startsWith("/media/")) return mediaRedirect(url, deps); return shareRedirect(url, request.headers.get("User-Agent"), deps); }, diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts index da30654..3431229 100644 --- a/test/shared-links-logged-to-kv-at-conversion.test.ts +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -1,5 +1,7 @@ import { afterEach, expect, setSystemTime, spyOn, test } from "bun:test"; +import { createWorker } from "../src/index"; import { logSharedLink } from "../src/shared-link-log"; +import { fakeCache } from "./support/fake-cache"; import { fakeKv } from "./support/fake-kv"; const KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z-[0-9a-f-]{36}$/; @@ -214,3 +216,77 @@ test("an oversize raw record keeps raw: true without the url", async () => { await Promise.all(pending); expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z", raw: true }); }); + +const YOUTUBE_REQUEST = "https://bl.example/?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dabc"; + +function appWithCache() { + return createWorker({ cache: () => fakeCache().cache }); +} + +function runConversion(env: Env, requestUrl = YOUTUBE_REQUEST) { + const { ctx, pending } = fakeCtx(); + const app = appWithCache(); + return { response: app.fetch(new Request(requestUrl), env, ctx), pending }; +} + +async function snapshot(res: Response) { + return { status: res.status, body: await res.text(), type: res.headers.get("content-type") }; +} + +test("a successful conversion writes one record and returns the same response", async () => { + const withKv = fakeKv(); + const { response, pending } = runConversion(envWith(withKv.binding)); + const body = await snapshot(await response); + await Promise.all(pending); + + const plain = await snapshot(await runConversion({} as Env).response); + expect(body).toEqual(plain); + expect(body.body).toBe("https://bl.example/www.youtube.com/watch?v=abc"); + expect(withKv.puts).toHaveLength(1); + expect(withKv.puts[0]?.options.metadata).toEqual({ url: YOUTUBE, at: withKv.puts[0]?.key.slice(0, 24) }); + expect(withKv.puts[0]?.options.expirationTtl).toBe(7_776_000); +}); + +test("raw=1 writes one record marked raw and returns the same response", async () => { + const kv = fakeKv(); + const { response, pending } = runConversion(envWith(kv.binding), `${YOUTUBE_REQUEST}&raw=1`); + const body = await snapshot(await response); + await Promise.all(pending); + expect(body.body).toBe(YOUTUBE); + expect(kv.puts).toHaveLength(1); + expect(kv.puts[0]?.options.metadata).toHaveProperty("raw", true); +}); + +test("without the binding the response is unchanged and nothing is scheduled", async () => { + const { response, pending } = runConversion({} as Env); + expect((await response).status).toBe(200); + expect(pending).toHaveLength(0); +}); + +test("a throwing or rejecting put does not change the response", async () => { + const plain = await snapshot(await runConversion({} as Env).response); + for (const mode of ["throw", "reject"] as const) { + const { response, pending } = runConversion(envWith(fakeKv(mode).binding)); + const body = await snapshot(await response); + await Promise.all(pending); + expect(body).toEqual(plain); + } +}); + +test("a put that never settles does not delay the response", async () => { + const { response } = runConversion(envWith(fakeKv("hang").binding)); + const res = await response; + expect(res.status).toBe(200); + expect(await res.text()).toBe("https://bl.example/www.youtube.com/watch?v=abc"); +}); + +test("the recorded url is the cleaned url the response points to", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FBsOGulcndj-%2F%3Figsh%3Dxyz"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://bl.example/www.instagram.com/p/BsOGulcndj-/"); + expect(kv.puts[0]?.value).toBe("https://www.instagram.com/p/BsOGulcndj-/"); + expect(kv.puts[0]?.value).not.toContain("igsh"); +}); From 97edceb6a327e347d92ed738715d55beade68923 Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:08:51 +0800 Subject: [PATCH 5/8] test(worker): keep share redirects and failed conversions out of the log Co-Authored-By: Claude Haiku 5.5 --- ...d-links-logged-to-kv-at-conversion.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts index 3431229..c27a26e 100644 --- a/test/shared-links-logged-to-kv-at-conversion.test.ts +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -290,3 +290,50 @@ test("the recorded url is the cleaned url the response points to", async () => { expect(kv.puts[0]?.value).toBe("https://www.instagram.com/p/BsOGulcndj-/"); expect(kv.puts[0]?.value).not.toContain("igsh"); }); + +const BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36"; +const DISCORD_UA = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"; + +function stubFetch(status: number) { + return spyOn(globalThis, "fetch").mockImplementation((() => + Promise.resolve(new Response("", { status }))) as unknown as typeof fetch); +} + +test("share redirects and media never touch kv", async () => { + const kv = fakeKv(); + const env = envWith(kv.binding); + const spy = stubFetch(404); + try { + const cases: [string, string | undefined][] = [ + ["https://bl.example/www.instagram.com/p/BsOGulcndj-/", BROWSER], + ["https://bl.example/www.instagram.com/p/BsOGulcndj-/", DISCORD_UA], + ["https://bl.example/media/BsOGulcndj-/1", undefined], + ]; + for (const [url, ua] of cases) { + const { ctx, pending } = fakeCtx(); + const headers = ua === undefined ? undefined : { "User-Agent": ua }; + const app = appWithCache(); + const plain = await app.fetch(new Request(url, { headers }), {} as Env, fakeCtx().ctx); + const withKv = await app.fetch(new Request(url, { headers }), env, ctx); + expect(withKv.status).toBe(plain.status); + expect(withKv.headers.get("location")).toBe(plain.headers.get("location")); + expect(pending).toHaveLength(0); + } + expect(kv.puts).toHaveLength(0); + } finally { + spy.mockRestore(); + } +}); + +test("failed conversions never touch kv and keep their 400 response", async () => { + const kv = fakeKv(); + const env = envWith(kv.binding); + for (const query of ["", "?url=", "?url=notaurl", "?url=http%3A%2F%2Flocalhost%2Fx"]) { + const { ctx, pending } = fakeCtx(); + const res = await appWithCache().fetch(new Request(`https://bl.example/${query}`), env, ctx); + expect(res.status).toBe(400); + expect(await res.text()).toBe("pass a percent-encoded http(s) URL as ?url="); + expect(pending).toHaveLength(0); + } + expect(kv.puts).toHaveLength(0); +}); From 8ef3432bb0e96193a46a6af848b10f9c8a091098 Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:09:22 +0800 Subject: [PATCH 6/8] feat(config): declare the shared link kv binding behind a build variable Co-Authored-By: Claude Haiku 5.5 --- ARCHITECTURE.md | 5 +- CLAUDE.md | 2 +- README.md | 6 ++- README.zh-TW.md | 6 ++- cloudflare.config.ts | 5 +- ...d-links-logged-to-kv-at-conversion.test.ts | 49 +++++++++++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8713149..ed79dfb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,7 +14,8 @@ better-link is a single Cloudflare Worker with three kinds of request. A convers - `src/instagram.ts` fetches the `/embed/captioned/` page and parses it into a `Post` (`instagramPost`, `parseEmbed`). The `Post` and `PostCache` types live here. - `src/facebook.ts` fetches `plugins/post.php` and `plugins/video.php` and parses them into a `Post` (`facebookPost`, `facebookVideo`). - `src/og.ts` renders a `Post` into og and twitter card HTML (`renderOgPage`). -- `cloudflare.config.ts` holds the Worker settings that `cf` reads. +- `cloudflare.config.ts` holds the Worker settings that `cf` reads. It declares the `SHARED_LINKS` KV binding only when the build variable `LOG_SHARED_LINKS` is `1`. +- `src/shared-link-log.ts` (`logSharedLink`) writes one record per successful `GET /?url=` to `env.SHARED_LINKS` through `ctx.waitUntil`. Without the binding it does nothing. ## Invariants @@ -23,4 +24,4 @@ The rules a change must not break are in `docs/spec/`. Each spec's `scope` names ## Cross-cutting concerns - Caching: `instagram.ts` and `facebook.ts` store parsed posts with the Cache API through `PostCache`. `createWorker({ cache })` takes the cache as a dependency. -- Testing: tests pass a fake cache from `test/support/fake-cache.ts` and replace `fetch` with `spyOn(globalThis, "fetch")`. Upstream pages are saved under `test/fixtures/`, and every parsing test runs against them without the network. +- Testing: tests pass a fake cache from `test/support/fake-cache.ts` and a fake KV binding from `test/support/fake-kv.ts`, and replace `fetch` with `spyOn(globalThis, "fetch")`. Upstream pages are saved under `test/fixtures/`, and every parsing test runs against them without the network. diff --git a/CLAUDE.md b/CLAUDE.md index d805bb5..20d36fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ bun run smoke # starts cf dev on port 8799 and sends end-to-end requests ``` - `.cloudflare/` holds generated types and build output and is gitignored. Until typecheck has run, types such as `Env` and `ExecutionContext` are missing. `wrangler.config.ts` sets `types.generate: false`, so `cf dev` and `cf build` do not regenerate them. -- The README's Deploy to Cloudflare button relies on `cloudflare.config.ts`, the `deploy` script and the `cloudflare` field in `package.json`. Do not add a Wrangler config file; `test/config.test.ts` fails if one appears. Keep it working without any bindings, variables or secrets, so a fresh copy deploys with no setup. +- The README's Deploy to Cloudflare button relies on `cloudflare.config.ts`, the `deploy` script and the `cloudflare` field in `package.json`. Do not add a Wrangler config file; `test/config.test.ts` fails if one appears. Keep it working without any bindings, variables or secrets by default, so a fresh copy deploys with no setup. The KV binding for shared-link logs is opt-in through the build variable `LOG_SHARED_LINKS=1` (see README); do not declare it unconditionally. - `bun.lock` stays at `lockfileVersion` 1 so Workers Builds' default Bun 1.2.15 can install it (checked by `test/config.test.ts`); regenerate it with Bun 1.3. - Pull requests are reviewed by CodeRabbit (GitHub App), configured in `.coderabbit.yaml`. Its `knowledge_base` applies `docs/spec/*.md` to the files in their `scope`; when a spec's `scope` gains a path, add it there. - GitHub Actions (`.github/workflows/ci.yml`) runs `bun install --frozen-lockfile` and `bun run check` with Bun 1.3.11 and Node 24 on pull requests and pushes to `main`. Keep its Bun version on 1.3 so the lock stays at version 1. diff --git a/README.md b/README.md index 04e935e..ba890a4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Click the button to deploy your own copy to Cloudflare: Cloudflare copies this repository into your GitHub or GitLab account, builds it and deploys the Worker, and connects Workers Builds so that every push to the copy redeploys it. You only need a Cloudflare account; the Workers free plan is enough, and there are no bindings, variables or secrets to fill in. You can rename the Worker on the setup page. +Optional: to record each share link that `GET /?url=` produces, set the build variable `LOG_SHARED_LINKS` to `1` in Workers Builds. The config then declares a KV binding named `SHARED_LINKS`, and each record expires after 90 days. Without the variable no binding is declared and the Worker behaves as before. Unset the variable and redeploy to remove the binding; the namespace and its records stay. + Once deployed, the Worker is reachable at `https://better-link..workers.dev` (or under the name you picked). Use that as `https://link.example` in the examples above, or attach a custom domain in the Cloudflare dashboard. To deploy manually instead: @@ -84,6 +86,8 @@ npx cf auth login # once; cf does not reuse a Wrangler login bun run deploy # cf deploy ``` +If you use the shared-link log, run `LOG_SHARED_LINKS=1 bun run deploy` instead. A manual deploy without the variable removes the binding. + ## Development Requires [Bun](https://bun.sh) and Node.js 22.18 or later. @@ -95,6 +99,6 @@ bun run check # typecheck + unit tests bun run smoke # end-to-end tests against real Instagram / Facebook, needs network ``` -Configuration lives in `cloudflare.config.ts`. The project only uses features of the Workers free plan and needs no bindings. +Configuration lives in `cloudflare.config.ts`. The project only uses features of the Workers free plan and needs no bindings by default; the optional shared-link log above is the only binding the config can declare. Design constraints are written in [`docs/spec/`](docs/spec/) (in Traditional Chinese), e.g. the 10ms CPU limit per request, falling back to the original URL on failure, and never putting expiring CDN URLs directly in og tags. Read the relevant specs before changing code. Instructions for AI agents are in [`CLAUDE.md`](CLAUDE.md) (`AGENTS.md` is a symlink to it). diff --git a/README.zh-TW.md b/README.zh-TW.md index 8971f67..ea13a59 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -74,6 +74,8 @@ curl 'https://link.example/?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FABC%2F%3Fi Cloudflare 會把這個 repo 複製到你的 GitHub 或 GitLab 帳號、建置並部署 Worker,並接上 Workers Builds,之後推到那份複本就會自動重新部署。只需要 Cloudflare 帳號,Workers 免費方案就夠,也沒有 binding、環境變數或 secret 要填。Worker 名稱可以在設定頁改。 +可選功能:要記錄 `GET /?url=` 產生的每一筆分享連結,在 Workers Builds 的建置變數設定 `LOG_SHARED_LINKS` 為 `1`。設定檔會宣告一個名為 `SHARED_LINKS` 的 KV binding,每筆紀錄 90 天後過期。沒有設定變數時不宣告任何 binding,Worker 的行為和以前相同。要取消時移除變數並重新部署即可移除 binding;命名空間和其中的紀錄會保留。 + 部署完成後網址是 `https://better-link.<你的子網域>.workers.dev`(或你取的名稱),把上面範例中的 `https://link.example` 換成它即可,也可以在 Cloudflare 後台綁自訂網域。 手動部署: @@ -84,6 +86,8 @@ npx cf auth login # 只需一次;cf 不沿用 Wrangler 的登入 bun run deploy # cf deploy ``` +有啟用分享連結紀錄時,改用 `LOG_SHARED_LINKS=1 bun run deploy`。手動部署時少了這個變數,binding 就會被移除。 + ## 開發 需要 [Bun](https://bun.sh) 和 Node.js 22.18 以上。 @@ -95,6 +99,6 @@ bun run check # 型別檢查 + 單元測試 bun run smoke # 對真實 Instagram / Facebook 的端到端測試,需要網路 ``` -設定在 `cloudflare.config.ts`。專案只用 Workers 免費方案的功能,不需要任何 binding。 +設定在 `cloudflare.config.ts`。專案只用 Workers 免費方案的功能,預設不需要任何 binding;上面的分享連結紀錄是設定檔唯一可以宣告的 binding。 設計約束寫在 [`docs/spec/`](docs/spec/),例如每請求 CPU 10ms 上限、失敗時退回原網址、og 標籤不直接放會過期的 CDN 網址。修改程式前先讀相關規格;給 AI 代理的說明在 [`CLAUDE.md`](CLAUDE.md)(`AGENTS.md` 是它的 symlink)。 diff --git a/cloudflare.config.ts b/cloudflare.config.ts index abf1034..e9dbd2b 100644 --- a/cloudflare.config.ts +++ b/cloudflare.config.ts @@ -1,4 +1,6 @@ -import { defineConfig } from "cf/config"; +import { bindings, defineConfig } from "cf/config"; + +const buildEnv = (globalThis as { process?: { env?: Record } }).process?.env; export default defineConfig({ worker: { @@ -8,5 +10,6 @@ export default defineConfig({ observability: { enabled: true, }, + ...(buildEnv?.LOG_SHARED_LINKS === "1" ? { env: { SHARED_LINKS: bindings.kv() } } : {}), }, }); diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts index c27a26e..ed04787 100644 --- a/test/shared-links-logged-to-kv-at-conversion.test.ts +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -337,3 +337,52 @@ test("failed conversions never touch kv and keep their 400 response", async () = } expect(kv.puts).toHaveLength(0); }); + +const CONFIG_FILE = "../cloudflare.config.ts"; +let variant = 0; + +async function configWithBuildVar(value: string | undefined) { + const previous = process.env.LOG_SHARED_LINKS; + try { + if (value === undefined) delete process.env.LOG_SHARED_LINKS; + else process.env.LOG_SHARED_LINKS = value; + variant += 1; + const module = (await import(`${CONFIG_FILE}?variant=${variant}`)) as { default: { worker: { env?: unknown } } }; + return module.default; + } finally { + if (previous === undefined) delete process.env.LOG_SHARED_LINKS; + else process.env.LOG_SHARED_LINKS = previous; + } +} + +test("the default config declares no kv binding", async () => { + const config = await configWithBuildVar(undefined); + expect(JSON.stringify(config)).not.toContain('"type":"kv"'); + expect(config.worker.env).toBeUndefined(); +}); + +test("LOG_SHARED_LINKS=1 declares the SHARED_LINKS kv binding", async () => { + const config = await configWithBuildVar("1"); + expect(JSON.stringify(config.worker.env)).toBe('{"SHARED_LINKS":{"type":"kv"}}'); +}); + +test("any other build value leaves the config without an env", async () => { + for (const value of ["true", "0", "", "11"]) { + const config = await configWithBuildVar(value); + expect(config.worker.env).toBeUndefined(); + } +}); + +test("the test restores LOG_SHARED_LINKS to its value before the test", async () => { + const before = process.env.LOG_SHARED_LINKS; + await configWithBuildVar("1"); + expect(process.env.LOG_SHARED_LINKS).toBe(before); +}); + +test("both READMEs document the opt-in variable and binding", async () => { + for (const name of ["README.md", "README.zh-TW.md"]) { + const text = await Bun.file(new URL(`../${name}`, import.meta.url)).text(); + expect(text).toContain("LOG_SHARED_LINKS"); + expect(text).toContain("SHARED_LINKS"); + } +}); From 943791341c4998a030120321c89a866ae17b1d2a Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:11:25 +0800 Subject: [PATCH 7/8] docs(spec): check shared link logging with its test file Verify runs test/shared-links-logged-to-kv-at-conversion.test.ts and the scope gains src/shared-link-log.ts. Red-first mutation check, each applied alone and restored: - delete expirationTtl: red (2 tests) - delete put try/catch: red (5) - await write on response path: red (4, including the never-settling put) - log from share redirect: red (1) - config condition always true: red (3) - config condition truthy: red (1) - log cleaned.href: red (2) - .length for byte size: red (1) Co-Authored-By: Claude Opus 5.5 --- docs/spec/shared-links-logged-to-kv-at-conversion.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 5042154..86ed68a 100644 --- a/docs/spec/shared-links-logged-to-kv-at-conversion.md +++ b/docs/spec/shared-links-logged-to-kv-at-conversion.md @@ -4,17 +4,18 @@ status: accepted scope: - "cloudflare.config.ts" - "src/index.ts" -verify: null + - "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] source: null adr: null --- -紀錄分享連結是可選功能:預設不啟用,啟用後只在 `GET /?url=` 成功產生分享連結時寫進一個 Workers KV 命名空間,每一筆都帶 `expirationTtl`,而且寫入成功與否都不能改變回應。 +紀錄分享連結是可選功能:預設不啟用,啟用後只在 `GET /?url=` 成功產生分享連結時寫進一個 Workers KV 命名空間,`raw=1` 的請求同樣會記錄(並在 metadata 標記 `raw`),每一筆都帶 `expirationTtl`,而且寫入成功與否都不能改變回應。 -`cloudflare.config.ts` 只在建置時的環境變數有設定時才宣告 `bindings.kv()`,部署的人在 Workers Builds 的建置變數裡設定它來啟用;在 Cloudflare 後台手動加的綁定會在下次部署時被設定檔覆蓋,所以不能當作啟用方式。預設匯出必須維持一般物件,因為 `test/config.test.ts` 直接讀它。Worker 在執行時看 `env` 有沒有這個綁定來決定要不要寫。寫入放在 `ctx.waitUntil` 裡,所以 `src/index.ts` 的 `createWorker` 要把 `env` 和 `ctx` 傳下去。key 以時間戳開頭,因為 KV 列出 key 時依 UTF-8 位元組排序,這樣一次 list 就是時間順序。清過追蹤碼的原網址和時間放在 key 的 metadata(上限 1,024 bytes),list 回來就能直接讀,不用逐筆 get。舊資料靠 TTL 自動淘汰,不另寫清理機制。 +`cloudflare.config.ts` 只在建置時的環境變數有設定時才宣告 `bindings.kv()`,部署的人在 Workers Builds 的建置變數裡設定它來啟用;在 Cloudflare 後台手動加的綁定會在下次部署時被設定檔覆蓋,所以不能當作啟用方式。預設匯出必須維持一般物件,因為 `test/config.test.ts` 直接讀它。Worker 在執行時看 `env` 有沒有這個綁定來決定要不要寫。寫入放在 `ctx.waitUntil` 裡,所以 `src/index.ts` 的 `createWorker` 要把 `env` 和 `ctx` 傳下去。key 以時間戳開頭,因為 KV 列出 key 時依 UTF-8 位元組排序,這樣一次 list 就是時間順序。清過追蹤碼的原網址和時間放在 key 的 metadata(上限 1,024 bytes,以 UTF-8 位元組計算),list 回來就能直接讀,不用逐筆 get;超過上限時 metadata 只留時間,原網址仍放在 value,`raw=1` 的紀錄則在 metadata 加上 `raw: true`。舊資料靠 TTL 自動淘汰,不另寫清理機制。 分享連結轉址 `GET /<原網域>/<路徑>` 和 `/media/` 不寫紀錄,所以手動把 `https://` 換成 Worker 網址產生的連結不會被記到,這是接受的代價。沒有綁定 KV 時 Worker 完全不寫入,行為和沒有這個功能時相同,所以用 Deploy 按鈕產生的新部署仍然不需要任何設定。紀錄包含所有使用這個部署的人的連結;怎麼讀取紀錄、誰能讀取,不在這條規格裡。 違反時不會被察覺:KV 免費方案每天只能寫 1,000 次,若在轉址端點記錄,爬蟲抓預覽和收件人點開會在流量高的某天吃光額度,之後的寫入默默失敗;沒帶 TTL 的紀錄會一直累積到 1 GB 才開始寫不進去;寫入若在回應路徑上被等待而拋錯,爬蟲拿到錯誤只會默默不顯示預覽。 -目前沒有 check,因為這個介面還不存在。實作落地後,在 worker 測試裡用假的 KV 斷言三件事即可綁定:只有 `GET /?url=` 會呼叫 `put` 且帶 `expirationTtl`、沒有綁定時回應不變、`put` 拋錯時回應不變。 +由 `test/shared-links-logged-to-kv-at-conversion.test.ts` 檢查(即 `verify` 指令):只有 `GET /?url=`(含 `raw=1`)會呼叫 `put` 且帶 `expirationTtl`;metadata 超過 1,024 bytes 時不含原網址,且以位元組而非字元計算;`cloudflare.config.ts` 只在 `LOG_SHARED_LINKS=1` 時宣告 `SHARED_LINKS` 綁定;沒有綁定時不排程任何寫入;`put` 同步拋錯、非同步拒絕或 `waitUntil` 拋錯時,回應與沒有綁定時相同;`put` 永不結束時回應也不延遲;分享連結轉址與失敗的轉換都不寫入。 From cb59ea10686b5eb110998ff820216d95e38d068c Mon Sep 17 00:00:00 2001 From: musingfox Date: Fri, 16 Oct 2026 11:27:20 +0800 Subject: [PATCH 8/8] refactor(worker): build the shared link string once for the response and the log convert serializes host, path and search once, and logSharedLink takes that string, so the logged URL cannot drift from the one the response shows. Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 5 +- src/shared-link-log.ts | 11 +-- ...d-links-logged-to-kv-at-conversion.test.ts | 96 +++++++++---------- 3 files changed, 54 insertions(+), 58 deletions(-) diff --git a/src/index.ts b/src/index.ts index b629ecc..d66c080 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,8 +54,9 @@ async function convert(requestUrl: URL, env: Env, ctx: ExecutionContext): Promis const cleaned = cleanUrl((await expandShareLink(target)) ?? target); const rawMode = requestUrl.searchParams.get("raw") === "1"; const prefix = rawMode ? "https://" : `${requestUrl.origin}/`; - logSharedLink(env, ctx, cleaned, rawMode); - return text(200, `${prefix}${cleaned.host}${cleaned.pathname}${cleaned.search}`); + const link = `${cleaned.host}${cleaned.pathname}${cleaned.search}`; + logSharedLink(env, ctx, `https://${link}`, rawMode); + return text(200, `${prefix}${link}`); } async function loadPost( diff --git a/src/shared-link-log.ts b/src/shared-link-log.ts index 2a5e5cb..8e6a728 100644 --- a/src/shared-link-log.ts +++ b/src/shared-link-log.ts @@ -1,4 +1,4 @@ -export const SHARED_LINK_TTL_SECONDS = 7_776_000; +const SHARED_LINK_TTL_SECONDS = 7_776_000; const METADATA_LIMIT_BYTES = 1_024; type SharedLinkStore = { @@ -6,17 +6,16 @@ type SharedLinkStore = { }; function metadataFor(url: string, at: string, raw: boolean): object { - const full = raw ? { url, at, raw: true } : { url, at }; + const base = raw ? { at, raw: true } : { at }; + const full = { url, ...base }; const size = new TextEncoder().encode(JSON.stringify(full)).byteLength; - if (size <= METADATA_LIMIT_BYTES) return full; - return raw ? { at, raw: true } : { at }; + return size <= METADATA_LIMIT_BYTES ? full : base; } -export function logSharedLink(env: Env, ctx: ExecutionContext, cleaned: URL, raw: boolean): void { +export function logSharedLink(env: Env, ctx: ExecutionContext, url: string, raw: boolean): void { const store = (env as { SHARED_LINKS?: SharedLinkStore }).SHARED_LINKS; if (store === undefined) return; const at = new Date().toISOString(); - const url = `https://${cleaned.host}${cleaned.pathname}${cleaned.search}`; const metadata = metadataFor(url, at, raw); const write = async () => { try { diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts index ed04787..855ddd4 100644 --- a/test/shared-links-logged-to-kv-at-conversion.test.ts +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -5,7 +5,6 @@ import { fakeCache } from "./support/fake-cache"; import { fakeKv } from "./support/fake-kv"; const KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z-[0-9a-f-]{36}$/; -const CLEANED = new URL("https://www.youtube.com/watch?v=abc"); const YOUTUBE = "https://www.youtube.com/watch?v=abc"; afterEach(() => setSystemTime()); @@ -28,7 +27,7 @@ function envWith(binding: unknown): Env { test("one conversion writes one expiring, time-keyed record", async () => { const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); expect(pending).toHaveLength(1); await Promise.all(pending); expect(kv.puts).toHaveLength(1); @@ -43,7 +42,7 @@ test("one conversion writes one expiring, time-keyed record", async () => { test("raw conversions record raw: true in metadata", async () => { const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, CLEANED, true); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, true); await Promise.all(pending); const [put] = kv.puts; expect(put?.options.metadata).toEqual({ url: YOUTUBE, at: put?.key.slice(0, 24), raw: true }); @@ -53,9 +52,9 @@ test("keys from later calls sort after keys from earlier calls", async () => { const kv = fakeKv(); const { ctx, pending } = fakeCtx(); setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); setSystemTime(new Date("2026-06-01T00:00:00.000Z")); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); await Promise.all(pending); const keys = kv.puts.map((put) => put.key); expect(keys).toEqual([...keys].sort()); @@ -65,8 +64,8 @@ test("calls under one frozen time share the timestamp prefix and differ in key", const kv = fakeKv(); const { ctx, pending } = fakeCtx(); setSystemTime(new Date("2026-03-03T03:03:03.003Z")); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); await Promise.all(pending); const [first, second] = kv.puts; expect(first?.key).not.toBe(second?.key); @@ -77,14 +76,14 @@ test("calls under one frozen time share the timestamp prefix and differ in key", test("waitUntil is invoked as a method on ctx", () => { const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); expect(pending).toHaveLength(1); }); test("the collected promise is the write itself", async () => { const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); expect(kv.puts).toHaveLength(1); await Promise.all(pending); expect(kv.puts).toHaveLength(1); @@ -93,7 +92,7 @@ test("the collected promise is the write itself", async () => { test("without the binding nothing is scheduled", () => { for (const env of [{}, { SHARED_LINKS: undefined }]) { const { ctx, pending } = fakeCtx(); - expect(logSharedLink(env as unknown as Env, ctx, CLEANED, false)).toBeUndefined(); + expect(logSharedLink(env as unknown as Env, ctx, YOUTUBE, false)).toBeUndefined(); expect(pending).toHaveLength(0); } }); @@ -101,7 +100,7 @@ test("without the binding nothing is scheduled", () => { test("a put that throws synchronously is swallowed", async () => { const kv = fakeKv("throw"); const { ctx, pending } = fakeCtx(); - expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); await Promise.all(pending); expect(kv.puts).toHaveLength(1); }); @@ -109,13 +108,13 @@ test("a put that throws synchronously is swallowed", async () => { test("a put that rejects is swallowed without an unhandled rejection", async () => { const kv = fakeKv("reject"); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); await expect(Promise.all(pending)).resolves.toBeDefined(); }); test("a binding without put is swallowed", async () => { const { ctx, pending } = fakeCtx(); - expect(() => logSharedLink(envWith({}), ctx, CLEANED, false)).not.toThrow(); + expect(() => logSharedLink(envWith({}), ctx, YOUTUBE, false)).not.toThrow(); await expect(Promise.all(pending)).resolves.toBeDefined(); }); @@ -126,30 +125,7 @@ test("a waitUntil that throws does not reach the caller", () => { throw new Error("boom"); }, } as unknown as ExecutionContext; - expect(() => logSharedLink(envWith(kv.binding), ctx, CLEANED, false)).not.toThrow(); -}); - -test("the logged URL drops userinfo and fragment", async () => { - const kv = fakeKv(); - const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, new URL("https://user:s3cret@www.youtube.com/watch?v=abc#t=5"), false); - await Promise.all(pending); - const [put] = kv.puts; - expect(put?.value).toBe(YOUTUBE); - expect(put?.options.metadata.url).toBe(YOUTUBE); - for (const text of [put?.value, JSON.stringify(put?.options.metadata)]) { - expect(text).not.toContain("s3cret"); - expect(text).not.toContain("user"); - expect(text).not.toContain("#"); - } -}); - -test("an http URL is logged in https form", async () => { - const kv = fakeKv(); - const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, new URL("http://example.com/a?b=1"), false); - await Promise.all(pending); - expect(kv.puts[0]?.value).toBe("https://example.com/a?b=1"); + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); }); test("a failed write logs nothing to the console", async () => { @@ -157,7 +133,7 @@ test("a failed write logs nothing to the console", async () => { try { const kv = fakeKv("reject"); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, CLEANED, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); await Promise.all(pending); for (const spy of spies) expect(spy).not.toHaveBeenCalled(); } finally { @@ -169,41 +145,40 @@ function sizeOf(metadata: object): number { return new TextEncoder().encode(JSON.stringify(metadata)).byteLength; } -function urlWithMetadataBytes(target: number): URL { +function urlWithMetadataBytes(target: number): string { const at = "2026-04-04T04:04:04.004Z"; const base = sizeOf({ url: "https://example.com/", at }); - return new URL(`https://example.com/${"a".repeat(target - base)}`); + return `https://example.com/${"a".repeat(target - base)}`; } test("metadata keeps the url when it is exactly 1,024 bytes", async () => { setSystemTime(new Date("2026-04-04T04:04:04.004Z")); - const cleaned = urlWithMetadataBytes(1_024); + const url = urlWithMetadataBytes(1_024); const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, cleaned, false); + logSharedLink(envWith(kv.binding), ctx, url, false); await Promise.all(pending); expect(sizeOf(kv.puts[0]?.options.metadata ?? {})).toBe(1_024); - expect(kv.puts[0]?.options.metadata).toHaveProperty("url", cleaned.href); + expect(kv.puts[0]?.options.metadata).toHaveProperty("url", url); }); test("metadata drops the url at 1,025 bytes but still logs it as the value", async () => { setSystemTime(new Date("2026-04-04T04:04:04.004Z")); - const cleaned = urlWithMetadataBytes(1_025); + const url = urlWithMetadataBytes(1_025); const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, cleaned, false); + logSharedLink(envWith(kv.binding), ctx, url, false); await Promise.all(pending); expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z" }); - expect(kv.puts[0]?.value).toBe(`https://${cleaned.host}${cleaned.pathname}`); + expect(kv.puts[0]?.value).toBe(url); }); test("the size limit counts bytes, not characters", async () => { - // 500 "é" are 1,000 bytes but only 500 characters; URL objects percent-encode non-ASCII, - // so this uses a plain object to reach the byte measure with multibyte text. - const cleaned = { host: "example.com", pathname: `/${"é".repeat(500)}`, search: "" } as unknown as URL; + // 500 "é" are 1,000 bytes but only 500 characters. + const url = `https://example.com/${"é".repeat(500)}`; const kv = fakeKv(); const { ctx, pending } = fakeCtx(); - logSharedLink(envWith(kv.binding), ctx, cleaned, false); + logSharedLink(envWith(kv.binding), ctx, url, false); await Promise.all(pending); expect(kv.puts[0]?.options.metadata).toEqual({ at: expect.any(String) }); }); @@ -291,6 +266,27 @@ test("the recorded url is the cleaned url the response points to", async () => { expect(kv.puts[0]?.value).not.toContain("igsh"); }); +test("the recorded url drops the fragment", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dabc%23t%3D5"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://bl.example/www.youtube.com/watch?v=abc"); + expect(kv.puts[0]?.value).toBe(YOUTUBE); + expect(JSON.stringify(kv.puts[0]?.options.metadata)).not.toContain("#"); +}); + +test("an http input is recorded in https form", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=http%3A%2F%2Fexample.com%2Fa%3Fb%3D1&raw=1"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://example.com/a?b=1"); + expect(kv.puts[0]?.value).toBe(body); +}); + const BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36"; const DISCORD_UA = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)";