From ee4b9d8fdc0c64c09edf575f3830840f4deb94cd Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Thu, 21 May 2026 22:58:16 +0200 Subject: [PATCH] feat(websub): add hub route (subscribe/unsubscribe) --- src/routes/hub.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/routes/hub.ts diff --git a/src/routes/hub.ts b/src/routes/hub.ts new file mode 100644 index 0000000..b4bab38 --- /dev/null +++ b/src/routes/hub.ts @@ -0,0 +1,80 @@ +import { Hono } from "hono"; +import { Env } from "../types"; +import { + verifyAndStoreSubscription, + verifyAndDeleteSubscription, +} from "../utils/websub"; + +const DEFAULT_LEASE_SECONDS = 86400; +const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days + +export const hubRouter = new Hono(); + +hubRouter.post("/", async (c) => { + const env = c.env as unknown as Env; + let form: FormData; + try { + form = await c.req.formData(); + } catch { + return c.text( + "Bad Request: expected application/x-www-form-urlencoded", + 400, + ); + } + + const mode = form.get("hub.mode"); + const topic = form.get("hub.topic"); + const callbackUrl = form.get("hub.callback"); + + if (!mode || !topic || !callbackUrl) { + return c.text( + "Bad Request: hub.mode, hub.topic and hub.callback are required", + 400, + ); + } + + if (mode !== "subscribe" && mode !== "unsubscribe") { + return c.text( + "Bad Request: hub.mode must be subscribe or unsubscribe", + 400, + ); + } + + // Validate that topic matches a known RSS feed on this hub + const topicPattern = new RegExp( + `^https://${env.DOMAIN.replace(".", "\\.")}/rss/([^/]+)$`, + ); + const match = topic.match(topicPattern); + if (!match) { + return c.text( + "Bad Request: hub.topic must be an RSS feed URL on this hub", + 400, + ); + } + const feedId = match[1]; + + const secret = form.get("hub.secret") ?? undefined; + const rawLease = parseInt(form.get("hub.lease_seconds") ?? "", 10); + const leaseSeconds = isNaN(rawLease) + ? DEFAULT_LEASE_SECONDS + : Math.min(Math.max(rawLease, 1), MAX_LEASE_SECONDS); + + // Return 202 immediately; verification is async + if (mode === "subscribe") { + c.executionCtx.waitUntil( + verifyAndStoreSubscription( + feedId, + callbackUrl as string, + secret as string | undefined, + leaseSeconds, + env, + ), + ); + } else { + c.executionCtx.waitUntil( + verifyAndDeleteSubscription(feedId, callbackUrl as string, env), + ); + } + + return c.text("Accepted", 202); +});