mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -116,6 +116,30 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
||||
<CopyField label="RSS Feed:" value={rssUrl} />
|
||||
<CopyField label="Atom Feed:" value={atomUrl} />
|
||||
</div>
|
||||
<div class="feed-validate">
|
||||
<a
|
||||
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(atomUrl)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://validator.w3.org/feed/images/valid-atom.png"
|
||||
alt="[Valid Atom 1.0]"
|
||||
title="Validate my Atom 1.0 feed"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(rssUrl)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://validator.w3.org/feed/images/valid-rss-rogers.png"
|
||||
alt="[Valid RSS]"
|
||||
title="Validate my RSS feed"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { html, raw } from "hono/html";
|
||||
import { Env, FeedMetadata, EmailData } from "../types";
|
||||
import { processEmailContent } from "../utils/html-processor";
|
||||
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
const feedId = c.req.param("feedId");
|
||||
@@ -82,7 +83,9 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
<dt>Date:</dt>
|
||||
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||
</dl>
|
||||
<div class="content">${raw(emailData.content)}</div>
|
||||
<div class="content">
|
||||
${raw(processEmailContent(emailData.content))}
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
|
||||
+83
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-4
@@ -59,18 +59,19 @@ hubRouter.post("/", async (c) => {
|
||||
return c.text("Bad Request: hub.callback must use HTTPS", 400);
|
||||
}
|
||||
|
||||
// Validate that topic matches a known RSS feed on this hub
|
||||
// Validate that topic matches a known RSS or Atom feed on this hub
|
||||
const topicPattern = new RegExp(
|
||||
`^https://${env.DOMAIN.replaceAll(".", "\\.")}/rss/([^/]+)$`,
|
||||
`^https://${env.DOMAIN.replaceAll(".", "\\.")}/(rss|atom)/([^/]+)$`,
|
||||
);
|
||||
const match = topic.match(topicPattern);
|
||||
if (!match) {
|
||||
return c.text(
|
||||
"Bad Request: hub.topic must be an RSS feed URL on this hub",
|
||||
"Bad Request: hub.topic must be an RSS or Atom feed URL on this hub",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const feedId = match[1];
|
||||
const format = match[1] as "rss" | "atom";
|
||||
const feedId = match[2];
|
||||
|
||||
// Verify the feed exists before accepting any subscription
|
||||
const feedConfig = await env.EMAIL_STORAGE.get(
|
||||
@@ -99,6 +100,7 @@ hubRouter.post("/", async (c) => {
|
||||
callbackUrl as string,
|
||||
secret as string | undefined,
|
||||
leaseSeconds,
|
||||
format,
|
||||
env,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user