feat: WebSub Atom support, HTML processing via linkedom, W3C badges

WebSub / PubSubHubbub:
- Hub now accepts both /rss/:id and /atom/:id topic URLs
- WebSubSubscription stores format ("rss" | "atom")
- notifySubscribers sends RSS or Atom XML with correct Content-Type
- verifyAndStoreSubscription sends correct topic URL per format
- CI paths-ignore docs/** to skip deploy on docs-only changes

HTML processing (linkedom + escape-html):
- New html-processor.ts: body extraction, script/iframe/object removal,
  event handler + javascript: URL stripping, mso-* style cleanup,
  plain text → <pre> with HTML escaping via escape-html
- feed-generator.ts and entries.ts use processEmailContent

Admin UI:
- W3C validation badges (Atom + RSS) on feed detail page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-22 21:12:10 +02:00
parent 1789870f27
commit a29e9ab372
13 changed files with 719 additions and 69 deletions
+83 -1
View File
@@ -124,6 +124,21 @@ describe("POST /hub — input validation", () => {
expect(res.status).toBe(400);
});
it("returns 400 when hub.topic uses an unsupported path (not rss or atom)", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/feed/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.secret exceeds 200 bytes", async () => {
const app = makeApp();
const env = createMockEnv();
@@ -213,10 +228,51 @@ describe("POST /hub — subscribe", () => {
);
expect(res.status).toBe(404);
});
it("returns 202 for valid Atom subscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/atom/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
it("returns 404 for Atom topic when feed does not exist", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/atom/nonexistent`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(404);
});
});
describe("POST /hub — unsubscribe", () => {
it("returns 202 for valid unsubscribe request", async () => {
it("returns 202 for valid RSS unsubscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
@@ -241,4 +297,30 @@ describe("POST /hub — unsubscribe", () => {
);
expect(res.status).toBe(202);
});
it("returns 202 for valid Atom unsubscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "unsubscribe",
"hub.topic": `https://${env.DOMAIN}/atom/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
});