refactor: extract url helpers, add EMAIL_DOMAIN support

- Add src/utils/urls.ts with baseUrl, feedRssUrl, feedAtomUrl, feedUrl,
  feedEmailAddress, feedTopicPattern
- Add optional EMAIL_DOMAIN env var so web domain and email domain can
  differ (e.g. demo.kill-the.news serves feeds, @kill-the.news receives mail)
- Replace all inline domain template literals with the new helpers
- Remove unused site_url/feed_url fields from FeedConfig
- Remove unused feedPath param from fetchFeedData
- Extract verifyCallback() to deduplicate verifyAndStoreSubscription /
  verifyAndDeleteSubscription

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Julien HerrandClaude Sonnet 4.6 committed 2026-05-22 22:38:29 +02:00
1 parent c64ceb4ee7
commit 7b2b98d693
15 files changed
+104 -92

No files matched your search

-3
View File
@@ -9,7 +9,6 @@ export interface FeedData {
export async function fetchFeedData(
feedId: string,
env: Env,
feedPath: "rss" | "atom",
): Promise<FeedData | null> {
const storage = env.EMAIL_STORAGE;
@@ -26,8 +25,6 @@ export async function fetchFeedData(
)) as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
site_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
feed_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
language: "en",
created_at: Date.now(),
};
-2
View File
@@ -9,8 +9,6 @@ import { FeedConfig, EmailData } from "../types";
const mockFeedConfig: FeedConfig = {
title: "Test Newsletter",
description: "A test feed",
site_url: "https://test.getmynews.app/rss/abc123",
feed_url: "https://test.getmynews.app/rss/abc123",
language: "en",
created_at: 1700000000000,
};
+30
View File
@@ -0,0 +1,30 @@
import { Env } from "../types";
export function baseUrl(env: Env): string {
return `https://${env.DOMAIN}`;
}
export function feedRssUrl(feedId: string, env: Env): string {
return `${baseUrl(env)}/rss/${feedId}`;
}
export function feedAtomUrl(feedId: string, env: Env): string {
return `${baseUrl(env)}/atom/${feedId}`;
}
export function feedUrl(
format: "rss" | "atom",
feedId: string,
env: Env,
): string {
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
}
export function feedEmailAddress(feedId: string, env: Env): string {
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
}
export function feedTopicPattern(env: Env): RegExp {
const escaped = env.DOMAIN.replaceAll(".", "\\.");
return new RegExp(`^https://${escaped}/(rss|atom)/([^/]+)$`);
}
-10
View File
@@ -101,8 +101,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
site_url: "https://example.com",
feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -141,8 +139,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
site_url: "https://example.com",
feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -181,8 +177,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
site_url: "https://example.com",
feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -222,8 +216,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
site_url: "https://example.com",
feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
@@ -269,8 +261,6 @@ describe("notifySubscribers", () => {
JSON.stringify({
title: "Test Feed",
language: "en",
site_url: "https://example.com",
feed_url: "https://example.com/rss/feed1",
created_at: Date.now(),
}),
);
+38 -43
View File
@@ -6,6 +6,7 @@ import {
WebSubSubscription,
} from "../types";
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
const KV_PREFIX = "websub:subs:";
@@ -67,12 +68,10 @@ async function buildFeedXml(
const feedMetadata = rawMetadata as FeedMetadata | null;
if (!feedMetadata) return null;
const baseUrl = `https://${env.DOMAIN}`;
const base = baseUrl(env);
const feedConfig = (rawConfig as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
site_url: `${baseUrl}/rss/${feedId}`,
feed_url: `${baseUrl}/rss/${feedId}`,
language: "en",
created_at: Date.now(),
};
@@ -91,12 +90,12 @@ async function buildFeedXml(
return generateAtomFeed(
feedConfig,
emailsData,
baseUrl,
base,
feedId,
`${baseUrl}/atom/${feedId}`,
feedAtomUrl(feedId, env),
);
}
return generateRssFeed(feedConfig, emailsData, baseUrl, feedId);
return generateRssFeed(feedConfig, emailsData, base, feedId);
}
export async function notifySubscribers(
@@ -124,7 +123,7 @@ export async function notifySubscribers(
if (!rssFeed && !atomFeed) return;
const baseUrl = `https://${env.DOMAIN}`;
const base = baseUrl(env);
const deliver = async (
sub: WebSubSubscription,
@@ -132,7 +131,7 @@ export async function notifySubscribers(
contentType: string,
selfPath: string,
) => {
const linkHeader = `<${baseUrl}/hub>; rel="hub", <${baseUrl}${selfPath}>; rel="self"`;
const linkHeader = `<${base}/hub>; rel="hub", <${base}${selfPath}>; rel="self"`;
const headers: Record<string, string> = {
"Content-Type": contentType,
Link: linkHeader,
@@ -173,6 +172,26 @@ export async function notifySubscribers(
}
}
async function verifyCallback(
callbackUrl: string,
params: Record<string, string>,
): Promise<boolean> {
const challenge = crypto.randomUUID().replace(/-/g, "");
const url = new URL(callbackUrl);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("hub.challenge", challenge);
let res: Response;
try {
res = await fetch(url.toString());
} catch {
return false;
}
if (!res.ok) return false;
return (await res.text()).trim() === challenge;
}
export async function verifyAndStoreSubscription(
feedId: string,
callbackUrl: string,
@@ -181,24 +200,12 @@ export async function verifyAndStoreSubscription(
format: "rss" | "atom",
env: Env,
): Promise<boolean> {
const challenge = crypto.randomUUID().replace(/-/g, "");
const topicUrl = `https://${env.DOMAIN}/${format}/${feedId}`;
const verifyUrl = new URL(callbackUrl);
verifyUrl.searchParams.set("hub.mode", "subscribe");
verifyUrl.searchParams.set("hub.topic", topicUrl);
verifyUrl.searchParams.set("hub.challenge", challenge);
verifyUrl.searchParams.set("hub.lease_seconds", String(leaseSeconds));
let res: Response;
try {
res = await fetch(verifyUrl.toString());
} catch {
return false;
}
if (!res.ok) return false;
const body = await res.text();
if (body.trim() !== challenge) return false;
const verified = await verifyCallback(callbackUrl, {
"hub.mode": "subscribe",
"hub.topic": feedUrl(format, feedId, env),
"hub.lease_seconds": String(leaseSeconds),
});
if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
const idx = subs.findIndex((s) => s.callbackUrl === callbackUrl);
@@ -222,23 +229,11 @@ export async function verifyAndDeleteSubscription(
callbackUrl: string,
env: Env,
): Promise<boolean> {
const challenge = crypto.randomUUID().replace(/-/g, "");
const topicUrl = `https://${env.DOMAIN}/rss/${feedId}`;
const verifyUrl = new URL(callbackUrl);
verifyUrl.searchParams.set("hub.mode", "unsubscribe");
verifyUrl.searchParams.set("hub.topic", topicUrl);
verifyUrl.searchParams.set("hub.challenge", challenge);
let res: Response;
try {
res = await fetch(verifyUrl.toString());
} catch {
return false;
}
if (!res.ok) return false;
const body = await res.text();
if (body.trim() !== challenge) return false;
const verified = await verifyCallback(callbackUrl, {
"hub.mode": "unsubscribe",
"hub.topic": feedRssUrl(feedId, env),
});
if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
await saveSubscriptions(