Skip to content
Merged
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 74 additions & 63 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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") {
Expand Down Expand Up @@ -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);
},
};
49 changes: 49 additions & 0 deletions test/worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
3 changes: 2 additions & 1 deletion wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading