From 73df38aeb408b917d12d01eadbe237764845d4c1 Mon Sep 17 00:00:00 2001 From: Yuna-Celisse <29951517@qq.com> Date: Thu, 24 Sep 2026 16:13:55 +0800 Subject: [PATCH] feat: publish the project calendar at /project.ics Proxy the public project schedule separately from the events feed, and keep a distinct last-good copy so one source cannot replace the other. --- README.md | 11 +++- src/index.js | 137 +++++++++++++++++++++++-------------------- test/worker.test.mjs | 49 ++++++++++++++++ wrangler.jsonc | 3 +- 4 files changed, 133 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 0cc036d..f16f789 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,15 @@ Calendar feeds for NBTCA, served from one Cloudflare Worker at ## Routes -- `/` preserves the existing combined Google Calendar feed during migration. -- `/events.ics` proxies the configured Google Calendar. Until the source - calendar is separated, this contains the same combined data as `/`. +- `/` preserves the existing daily-affairs subscription during migration. + Its content matches `/events.ics`. +- `/events.ics` proxies the association Google Calendar, `NBTCA 计算机协会日历`. + Subscribe with `webcal://ical.nbtca.space/events.ics`. - `/school.ics` is generated from the reviewed files in `data/school/`. + Subscribe with `webcal://ical.nbtca.space/school.ics`. +- `/project.ics` proxies the public project-schedule Google Calendar. + Subscribe with `webcal://ical.nbtca.space/project.ics`. Each proxied feed + keeps its own last-good copy, so one source failing cannot replace another. Birthday publication and access control are intentionally out of scope until a privacy policy is agreed. diff --git a/src/index.js b/src/index.js index 7a72b19..fa37a79 100644 --- a/src/index.js +++ b/src/index.js @@ -4,9 +4,23 @@ const CORS_HEADERS = { "Access-Control-Allow-Headers": "Content-Type", }; -const PROXY_PATHS = new Set(["/", "/events.ics"]); - -const LAST_GOOD = "https://calendar.nbtca.invalid/last-good"; +const FEEDS = { + "/": { + source: "EVENTS_CALENDAR_URL", + filename: "nbtca.ics", + cacheKey: "https://calendar.nbtca.invalid/last-good-events", + }, + "/events.ics": { + source: "EVENTS_CALENDAR_URL", + filename: "nbtca-events.ics", + cacheKey: "https://calendar.nbtca.invalid/last-good-events", + }, + "/project.ics": { + source: "PROJECT_CALENDAR_URL", + filename: "nbtca-project.ics", + cacheKey: "https://calendar.nbtca.invalid/last-good-project", + }, +}; function edgeCache() { try { @@ -16,18 +30,18 @@ function edgeCache() { } } -function remember(body, headers, ctx) { +function remember(cacheKey, body, headers, ctx) { const cache = edgeCache(); if (!cache) return; const keep = new Headers(headers); keep.set("Cache-Control", "public, max-age=86400"); - const stored = cache.put(LAST_GOOD, new Response(body, { headers: keep })).catch(() => {}); + const stored = cache.put(cacheKey, new Response(body, { headers: keep })).catch(() => {}); ctx?.waitUntil?.(stored); } -async function serveLastGood(method) { +async function serveLastGood(cacheKey, method) { const cache = edgeCache(); - const cached = cache ? await cache.match(LAST_GOOD).catch(() => null) : null; + const cached = cache ? await cache.match(cacheKey).catch(() => null) : null; if (!cached) return plainText("Calendar source unavailable", 502); const headers = new Headers(cached.headers); @@ -47,6 +61,56 @@ function plainText(message, status, extraHeaders = {}) { }); } +async function proxyCalendar(request, sourceUrl, filename, cacheKey, ctx) { + if (!sourceUrl) return plainText("Calendar source is not configured", 503); + + try { + const upstream = await fetch(sourceUrl, { + method: "GET", + signal: AbortSignal.timeout(10_000), + cf: { + cacheEverything: true, + cacheTtlByStatus: { "200-299": 900, "429": 900, "500-599": 0 }, + }, + }); + + if (!upstream.ok) return serveLastGood(cacheKey, request.method); + + const headers = new Headers(); + for (const name of ["ETag", "Last-Modified"]) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + + const contentType = upstream.headers.get("Content-Type") || ""; + if (!contentType.toLowerCase().includes("text/calendar")) { + return serveLastGood(cacheKey, request.method); + } + + headers.set("Content-Type", "text/calendar; charset=utf-8"); + headers.set("Content-Disposition", `inline; filename=${filename}`); + headers.set("Cache-Control", "public, max-age=300, s-maxage=300"); + for (const [name, value] of Object.entries(CORS_HEADERS)) { + headers.set(name, value); + } + + const calendar = await upstream.text(); + remember(cacheKey, calendar, headers, ctx); + + const etag = headers.get("ETag"); + if (etag && request.headers.get("If-None-Match") === etag) { + return new Response(null, { status: 304, headers }); + } + + return new Response(request.method === "HEAD" ? null : calendar, { + status: 200, + headers, + }); + } catch { + return serveLastGood(cacheKey, request.method); + } +} + export default { async fetch(request, env, ctx) { if (request.method === "OPTIONS") { @@ -86,61 +150,8 @@ export default { }); } - if (!PROXY_PATHS.has(pathname)) { - return plainText("Not Found", 404); - } - - if (!env.EVENTS_CALENDAR_URL) { - return plainText("Calendar source is not configured", 503); - } - - try { - const upstream = await fetch(env.EVENTS_CALENDAR_URL, { - method: "GET", - signal: AbortSignal.timeout(10_000), - cf: { - cacheEverything: true, - cacheTtlByStatus: { "200-299": 900, "429": 900, "500-599": 0 }, - }, - }); - - if (!upstream.ok) { - return serveLastGood(request.method); - } - - const headers = new Headers(); - for (const name of ["ETag", "Last-Modified"]) { - const value = upstream.headers.get(name); - if (value) headers.set(name, value); - } - - const contentType = upstream.headers.get("Content-Type") || ""; - if (!contentType.toLowerCase().includes("text/calendar")) { - return serveLastGood(request.method); - } - - const filename = pathname === "/" ? "nbtca.ics" : "nbtca-events.ics"; - headers.set("Content-Type", "text/calendar; charset=utf-8"); - headers.set("Content-Disposition", `inline; filename=${filename}`); - headers.set("Cache-Control", "public, max-age=300, s-maxage=300"); - for (const [name, value] of Object.entries(CORS_HEADERS)) { - headers.set(name, value); - } - - const calendar = await upstream.text(); - remember(calendar, headers, ctx); - - const etag = headers.get("ETag"); - if (etag && request.headers.get("If-None-Match") === etag) { - return new Response(null, { status: 304, headers }); - } - - return new Response(request.method === "HEAD" ? null : calendar, { - status: 200, - headers, - }); - } catch { - return serveLastGood(request.method); - } + const feed = FEEDS[pathname]; + if (!feed) return plainText("Not Found", 404); + return proxyCalendar(request, env[feed.source], feed.filename, feed.cacheKey, ctx); }, }; diff --git a/test/worker.test.mjs b/test/worker.test.mjs index 9534f46..451df4f 100644 --- a/test/worker.test.mjs +++ b/test/worker.test.mjs @@ -158,3 +158,52 @@ test("still reports a gateway error when nothing was ever cached", async (contex const response = await worker.fetch(new Request("https://ical.example/events.ics"), env); assert.equal(response.status, 502); }); + +test("serves the project calendar from its own source", async (context) => { + let requested; + context.mock.method(globalThis, "fetch", async (url) => { + requested = String(url); + return new Response("BEGIN:VCALENDAR\r\nPROJECT\r\nEND:VCALENDAR\r\n", { + headers: { ETag: '"project-v1"', "Content-Type": "text/calendar" }, + }); + }); + const response = await worker.fetch(new Request("https://ical.example/project.ics"), { + ...env, + PROJECT_CALENDAR_URL: "https://calendar.example/project.ics", + }); + assert.equal(requested, "https://calendar.example/project.ics"); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Content-Disposition"), + "inline; filename=nbtca-project.ics", + ); + assert.match(await response.text(), /PROJECT/); +}); + +test("does not reuse the events calendar when the project source fails", async (context) => { + const store = stubCache(); + context.after(() => delete globalThis.caches); + context.mock.method(globalThis, "fetch", async (url) => { + if (String(url).includes("project")) return new Response("no", { status: 503 }); + return new Response("BEGIN:VCALENDAR\r\nEVENTS\r\nEND:VCALENDAR\r\n", { + headers: { "Content-Type": "text/calendar" }, + }); + }); + const projectEnv = { + ...env, + PROJECT_CALENDAR_URL: "https://calendar.example/project.ics", + }; + + const events = await worker.fetch(new Request("https://ical.example/events.ics"), projectEnv); + assert.equal(events.status, 200); + assert.match(await events.text(), /EVENTS/); + + const project = await worker.fetch(new Request("https://ical.example/project.ics"), projectEnv); + assert.equal(project.status, 502); + assert.equal(store.size, 1); +}); + +test("reports a configuration error when the project source is absent", async () => { + const response = await worker.fetch(new Request("https://ical.example/project.ics"), env); + assert.equal(response.status, 503); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index dbf8618..a467606 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -15,6 +15,7 @@ "run_worker_first": ["/school.ics"] }, "vars": { - "EVENTS_CALENDAR_URL": "https://calendar.google.com/calendar/ical/1b387d6005a1155845d61c56b518d1d4d4e8881e2937d7daac03bb8541cc1500%40group.calendar.google.com/public/basic.ics" + "EVENTS_CALENDAR_URL": "https://calendar.google.com/calendar/ical/1b387d6005a1155845d61c56b518d1d4d4e8881e2937d7daac03bb8541cc1500%40group.calendar.google.com/public/basic.ics", + "PROJECT_CALENDAR_URL": "https://calendar.google.com/calendar/ical/9ec422cb4f3939a82ae3dd68bedfb281485190ab45061884e69d1095624d6683%40group.calendar.google.com/public/basic.ics" } }