mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -220,8 +220,6 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test",
|
title: "Test",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: `https://example.com/rss/${VALID_FEED_ID}`,
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
|||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
import { Layout, clampText } from "./admin/ui";
|
import { Layout, clampText } from "./admin/ui";
|
||||||
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
|
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
|
||||||
|
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
|
||||||
import { feedsRouter } from "./admin/feeds";
|
import { feedsRouter } from "./admin/feeds";
|
||||||
import { emailsRouter } from "./admin/emails";
|
import { emailsRouter } from "./admin/emails";
|
||||||
import { dashboardScript } from "../scripts/generated/dashboard";
|
import { dashboardScript } from "../scripts/generated/dashboard";
|
||||||
@@ -601,9 +602,9 @@ app.get("/", async (c) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="feed-table-body">
|
<tbody id="feed-table-body">
|
||||||
{feedsWithConfig.map((feed) => {
|
{feedsWithConfig.map((feed) => {
|
||||||
const emailAddress = `${feed.id}@${env.DOMAIN}`;
|
const emailAddress = feedEmailAddress(feed.id, env);
|
||||||
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
|
const rssUrl = feedRssUrl(feed.id, env);
|
||||||
const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`;
|
const atomUrl = feedAtomUrl(feed.id, env);
|
||||||
const titleDisplay = clampText(feed.title, 160);
|
const titleDisplay = clampText(feed.title, 160);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const sortTitle = titleHover.toLowerCase();
|
const sortTitle = titleHover.toLowerCase();
|
||||||
@@ -712,9 +713,9 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
<ul class="feed-list">
|
<ul class="feed-list">
|
||||||
{feedsWithConfig.map((feed) => {
|
{feedsWithConfig.map((feed) => {
|
||||||
const emailAddress = `${feed.id}@${env.DOMAIN}`;
|
const emailAddress = feedEmailAddress(feed.id, env);
|
||||||
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
|
const rssUrl = feedRssUrl(feed.id, env);
|
||||||
const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`;
|
const atomUrl = feedAtomUrl(feed.id, env);
|
||||||
const titleDisplay = clampText(feed.title, 140);
|
const titleDisplay = clampText(feed.title, 140);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const descDisplay = clampText(feed.description || "", 240);
|
const descDisplay = clampText(feed.description || "", 240);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
import { Layout, clampText } from "./ui";
|
import { Layout, clampText } from "./ui";
|
||||||
import { deleteKeysWithConcurrency } from "./helpers";
|
import { deleteKeysWithConcurrency } from "./helpers";
|
||||||
|
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
@@ -91,9 +92,9 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailAddress = `${feedId}@${env.DOMAIN}`;
|
const emailAddress = feedEmailAddress(feedId, env);
|
||||||
const rssUrl = `https://${env.DOMAIN}/rss/${feedId}`;
|
const rssUrl = feedRssUrl(feedId, env);
|
||||||
const atomUrl = `https://${env.DOMAIN}/atom/${feedId}`;
|
const atomUrl = feedAtomUrl(feedId, env);
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${feedConfig.title} - Emails`}>
|
<Layout title={`${feedConfig.title} - Emails`}>
|
||||||
@@ -426,7 +427,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
<CopyField label="From:" value={emailData.from} />
|
<CopyField label="From:" value={emailData.from} />
|
||||||
<CopyField
|
<CopyField
|
||||||
label="To:"
|
label="To:"
|
||||||
value={`${feedId}@${env.DOMAIN}`}
|
value={feedEmailAddress(feedId, env)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
|
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
|
||||||
import { generateFeedId } from "../../utils/id-generator";
|
import { generateFeedId } from "../../utils/id-generator";
|
||||||
import { waitUntilSafe } from "../../utils/worker";
|
import { waitUntilSafe } from "../../utils/worker";
|
||||||
|
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
import { Layout } from "./ui";
|
import { Layout } from "./ui";
|
||||||
import {
|
import {
|
||||||
@@ -192,8 +193,6 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
site_url: `https://${env.DOMAIN}/rss/${feedId}`,
|
|
||||||
feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
|
|
||||||
allowed_senders: parsedData.allowedSenders,
|
allowed_senders: parsedData.allowedSenders,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
@@ -216,8 +215,8 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
if (isJson) {
|
if (isJson) {
|
||||||
return c.json({
|
return c.json({
|
||||||
feedId,
|
feedId,
|
||||||
email: `${feedId}@${env.DOMAIN}`,
|
email: feedEmailAddress(feedId, env),
|
||||||
feedUrl: feedConfig.feed_url,
|
feedUrl: feedRssUrl(feedId, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ describe("Atom Feed Route", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Atom Test Feed",
|
title: "Atom Test Feed",
|
||||||
description: "Integration test",
|
description: "Integration test",
|
||||||
site_url: "https://test.getmynews.app/rss/test-feed-atom",
|
|
||||||
feed_url: "https://test.getmynews.app/rss/test-feed-atom",
|
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: 1700000000000,
|
created_at: 1700000000000,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+6
-5
@@ -2,6 +2,7 @@ import { Context } from "hono";
|
|||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { generateAtomFeed } from "../utils/feed-generator";
|
import { generateAtomFeed } from "../utils/feed-generator";
|
||||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
import { fetchFeedData } from "../utils/feed-fetcher";
|
||||||
|
import { baseUrl, feedAtomUrl } from "../utils/urls";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -10,23 +11,23 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed ID is required", { status: 400 });
|
return new Response("Feed ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = await fetchFeedData(feedId, c.env, "atom");
|
const feedData = await fetchFeedData(feedId, c.env);
|
||||||
if (!feedData) {
|
if (!feedData) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `https://${c.env.DOMAIN}`;
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
|
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
|
||||||
const atomXml = generateAtomFeed(
|
const atomXml = generateAtomFeed(
|
||||||
feedData.feedConfig,
|
feedData.feedConfig,
|
||||||
feedData.emails,
|
feedData.emails,
|
||||||
baseUrl,
|
base,
|
||||||
feedId,
|
feedId,
|
||||||
selfUrl,
|
selfUrl,
|
||||||
);
|
);
|
||||||
const linkHeader = [
|
const linkHeader = [
|
||||||
`<${baseUrl}/hub>; rel="hub"`,
|
`<${base}/hub>; rel="hub"`,
|
||||||
`<${baseUrl}/atom/${feedId}>; rel="self"`,
|
`<${feedAtomUrl(feedId, c.env)}>; rel="self"`,
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
||||||
return new Response(atomXml, {
|
return new Response(atomXml, {
|
||||||
|
|||||||
+2
-3
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../utils/websub";
|
} from "../utils/websub";
|
||||||
import { waitUntilSafe } from "../utils/worker";
|
import { waitUntilSafe } from "../utils/worker";
|
||||||
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
||||||
|
import { feedTopicPattern } from "../utils/urls";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -60,9 +61,7 @@ hubRouter.post("/", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate that topic matches a known RSS or Atom feed on this hub
|
// Validate that topic matches a known RSS or Atom feed on this hub
|
||||||
const topicPattern = new RegExp(
|
const topicPattern = feedTopicPattern(env);
|
||||||
`^https://${env.DOMAIN.replaceAll(".", "\\.")}/(rss|atom)/([^/]+)$`,
|
|
||||||
);
|
|
||||||
const match = topic.match(topicPattern);
|
const match = topic.match(topicPattern);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return c.text(
|
return c.text(
|
||||||
|
|||||||
+6
-5
@@ -2,6 +2,7 @@ import { Context } from "hono";
|
|||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { generateRssFeed } from "../utils/feed-generator";
|
import { generateRssFeed } from "../utils/feed-generator";
|
||||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
import { fetchFeedData } from "../utils/feed-fetcher";
|
||||||
|
import { baseUrl, feedRssUrl } from "../utils/urls";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -10,23 +11,23 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed ID is required", { status: 400 });
|
return new Response("Feed ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = await fetchFeedData(feedId, c.env, "rss");
|
const feedData = await fetchFeedData(feedId, c.env);
|
||||||
if (!feedData) {
|
if (!feedData) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `https://${c.env.DOMAIN}`;
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
|
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
|
||||||
const rssXml = generateRssFeed(
|
const rssXml = generateRssFeed(
|
||||||
feedData.feedConfig,
|
feedData.feedConfig,
|
||||||
feedData.emails,
|
feedData.emails,
|
||||||
baseUrl,
|
base,
|
||||||
feedId,
|
feedId,
|
||||||
selfUrl,
|
selfUrl,
|
||||||
);
|
);
|
||||||
const linkHeader = [
|
const linkHeader = [
|
||||||
`<${baseUrl}/hub>; rel="hub"`,
|
`<${base}/hub>; rel="hub"`,
|
||||||
`<${baseUrl}/rss/${feedId}>; rel="self"`,
|
`<${feedRssUrl(feedId, c.env)}>; rel="self"`,
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
||||||
return new Response(rssXml, {
|
return new Response(rssXml, {
|
||||||
|
|||||||
+1
-2
@@ -3,6 +3,7 @@ export interface Env {
|
|||||||
EMAIL_STORAGE: KVNamespace;
|
EMAIL_STORAGE: KVNamespace;
|
||||||
ADMIN_PASSWORD: string;
|
ADMIN_PASSWORD: string;
|
||||||
DOMAIN: string;
|
DOMAIN: string;
|
||||||
|
EMAIL_DOMAIN?: string;
|
||||||
ATTACHMENT_BUCKET?: R2Bucket;
|
ATTACHMENT_BUCKET?: R2Bucket;
|
||||||
FEED_MAX_SIZE_BYTES?: string;
|
FEED_MAX_SIZE_BYTES?: string;
|
||||||
PROXY_TRUSTED_IPS?: string;
|
PROXY_TRUSTED_IPS?: string;
|
||||||
@@ -33,8 +34,6 @@ export interface FeedConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
allowed_senders?: string[];
|
allowed_senders?: string[];
|
||||||
language: string;
|
language: string;
|
||||||
site_url: string;
|
|
||||||
feed_url: string;
|
|
||||||
author?: string;
|
author?: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at?: number;
|
updated_at?: number;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface FeedData {
|
|||||||
export async function fetchFeedData(
|
export async function fetchFeedData(
|
||||||
feedId: string,
|
feedId: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
feedPath: "rss" | "atom",
|
|
||||||
): Promise<FeedData | null> {
|
): Promise<FeedData | null> {
|
||||||
const storage = env.EMAIL_STORAGE;
|
const storage = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
@@ -26,8 +25,6 @@ export async function fetchFeedData(
|
|||||||
)) as FeedConfig | null) ?? {
|
)) as FeedConfig | null) ?? {
|
||||||
title: `Newsletter Feed ${feedId}`,
|
title: `Newsletter Feed ${feedId}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
site_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
|
|
||||||
feed_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
|
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { FeedConfig, EmailData } from "../types";
|
|||||||
const mockFeedConfig: FeedConfig = {
|
const mockFeedConfig: FeedConfig = {
|
||||||
title: "Test Newsletter",
|
title: "Test Newsletter",
|
||||||
description: "A test feed",
|
description: "A test feed",
|
||||||
site_url: "https://test.getmynews.app/rss/abc123",
|
|
||||||
feed_url: "https://test.getmynews.app/rss/abc123",
|
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: 1700000000000,
|
created_at: 1700000000000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)/([^/]+)$`);
|
||||||
|
}
|
||||||
@@ -101,8 +101,6 @@ describe("notifySubscribers", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: "https://example.com/rss/feed1",
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -141,8 +139,6 @@ describe("notifySubscribers", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: "https://example.com/rss/feed1",
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -181,8 +177,6 @@ describe("notifySubscribers", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: "https://example.com/rss/feed1",
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -222,8 +216,6 @@ describe("notifySubscribers", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: "https://example.com/rss/feed1",
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -269,8 +261,6 @@ describe("notifySubscribers", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
site_url: "https://example.com",
|
|
||||||
feed_url: "https://example.com/rss/feed1",
|
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-43
@@ -6,6 +6,7 @@ import {
|
|||||||
WebSubSubscription,
|
WebSubSubscription,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
|
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
|
||||||
|
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
|
||||||
|
|
||||||
const KV_PREFIX = "websub:subs:";
|
const KV_PREFIX = "websub:subs:";
|
||||||
|
|
||||||
@@ -67,12 +68,10 @@ async function buildFeedXml(
|
|||||||
const feedMetadata = rawMetadata as FeedMetadata | null;
|
const feedMetadata = rawMetadata as FeedMetadata | null;
|
||||||
if (!feedMetadata) return null;
|
if (!feedMetadata) return null;
|
||||||
|
|
||||||
const baseUrl = `https://${env.DOMAIN}`;
|
const base = baseUrl(env);
|
||||||
const feedConfig = (rawConfig as FeedConfig | null) ?? {
|
const feedConfig = (rawConfig as FeedConfig | null) ?? {
|
||||||
title: `Newsletter Feed ${feedId}`,
|
title: `Newsletter Feed ${feedId}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
site_url: `${baseUrl}/rss/${feedId}`,
|
|
||||||
feed_url: `${baseUrl}/rss/${feedId}`,
|
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -91,12 +90,12 @@ async function buildFeedXml(
|
|||||||
return generateAtomFeed(
|
return generateAtomFeed(
|
||||||
feedConfig,
|
feedConfig,
|
||||||
emailsData,
|
emailsData,
|
||||||
baseUrl,
|
base,
|
||||||
feedId,
|
feedId,
|
||||||
`${baseUrl}/atom/${feedId}`,
|
feedAtomUrl(feedId, env),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return generateRssFeed(feedConfig, emailsData, baseUrl, feedId);
|
return generateRssFeed(feedConfig, emailsData, base, feedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifySubscribers(
|
export async function notifySubscribers(
|
||||||
@@ -124,7 +123,7 @@ export async function notifySubscribers(
|
|||||||
|
|
||||||
if (!rssFeed && !atomFeed) return;
|
if (!rssFeed && !atomFeed) return;
|
||||||
|
|
||||||
const baseUrl = `https://${env.DOMAIN}`;
|
const base = baseUrl(env);
|
||||||
|
|
||||||
const deliver = async (
|
const deliver = async (
|
||||||
sub: WebSubSubscription,
|
sub: WebSubSubscription,
|
||||||
@@ -132,7 +131,7 @@ export async function notifySubscribers(
|
|||||||
contentType: string,
|
contentType: string,
|
||||||
selfPath: 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> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
Link: linkHeader,
|
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(
|
export async function verifyAndStoreSubscription(
|
||||||
feedId: string,
|
feedId: string,
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
@@ -181,24 +200,12 @@ export async function verifyAndStoreSubscription(
|
|||||||
format: "rss" | "atom",
|
format: "rss" | "atom",
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const challenge = crypto.randomUUID().replace(/-/g, "");
|
const verified = await verifyCallback(callbackUrl, {
|
||||||
const topicUrl = `https://${env.DOMAIN}/${format}/${feedId}`;
|
"hub.mode": "subscribe",
|
||||||
const verifyUrl = new URL(callbackUrl);
|
"hub.topic": feedUrl(format, feedId, env),
|
||||||
verifyUrl.searchParams.set("hub.mode", "subscribe");
|
"hub.lease_seconds": String(leaseSeconds),
|
||||||
verifyUrl.searchParams.set("hub.topic", topicUrl);
|
});
|
||||||
verifyUrl.searchParams.set("hub.challenge", challenge);
|
if (!verified) return false;
|
||||||
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 subs = await getSubscriptions(feedId, env);
|
const subs = await getSubscriptions(feedId, env);
|
||||||
const idx = subs.findIndex((s) => s.callbackUrl === callbackUrl);
|
const idx = subs.findIndex((s) => s.callbackUrl === callbackUrl);
|
||||||
@@ -222,23 +229,11 @@ export async function verifyAndDeleteSubscription(
|
|||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const challenge = crypto.randomUUID().replace(/-/g, "");
|
const verified = await verifyCallback(callbackUrl, {
|
||||||
const topicUrl = `https://${env.DOMAIN}/rss/${feedId}`;
|
"hub.mode": "unsubscribe",
|
||||||
const verifyUrl = new URL(callbackUrl);
|
"hub.topic": feedRssUrl(feedId, env),
|
||||||
verifyUrl.searchParams.set("hub.mode", "unsubscribe");
|
});
|
||||||
verifyUrl.searchParams.set("hub.topic", topicUrl);
|
if (!verified) return false;
|
||||||
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 subs = await getSubscriptions(feedId, env);
|
const subs = await getSubscriptions(feedId, env);
|
||||||
await saveSubscriptions(
|
await saveSubscriptions(
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ invocation_logs = true
|
|||||||
|
|
||||||
# Global Environment variables
|
# Global Environment variables
|
||||||
[vars]
|
[vars]
|
||||||
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Your custom domain for emails
|
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin UI)
|
||||||
|
|
||||||
|
# Optional: email domain when it differs from the web domain
|
||||||
|
# Example: DOMAIN = "demo.example.com" but emails are @example.com
|
||||||
|
# EMAIL_DOMAIN = "REPLACE_WITH_YOUR_EMAIL_DOMAIN"
|
||||||
|
|
||||||
# Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB)
|
# Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB)
|
||||||
# FEED_MAX_SIZE_BYTES = "524288"
|
# FEED_MAX_SIZE_BYTES = "524288"
|
||||||
@@ -83,6 +87,7 @@ routes = [
|
|||||||
|
|
||||||
[env.demo.vars]
|
[env.demo.vars]
|
||||||
DOMAIN = "demo.kill-the.news"
|
DOMAIN = "demo.kill-the.news"
|
||||||
|
EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain
|
||||||
|
|
||||||
# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean
|
# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean
|
||||||
[env.demo.triggers]
|
[env.demo.triggers]
|
||||||
|
|||||||
Reference in New Issue
Block a user