feat: complete Phase 2 tech debt remediation

- Extract shared RSS/Atom fetch logic into feed-fetcher utility (P1-3)
- Split email-processor into validateEmail/storeEmail functions (P1-6)
- Add stateless HMAC-SHA256 CSRF protection to admin forms (P2-8)
- Fix Hono<{ Bindings: Env }> type safety across all routes (P3-13)
- Add entries.test.ts and files.test.ts with full coverage (P1-7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-22 09:46:55 +02:00
parent f2981eec31
commit 7d375693b9
15 changed files with 485 additions and 152 deletions
+16 -3
View File
@@ -3,17 +3,20 @@ import { Hono } from "hono";
import app from "./admin";
import { createMockEnv } from "../test/setup";
import { Env } from "../types";
import { generateCsrfToken } from "../utils/csrf";
describe("Admin Routes", () => {
let testApp: Hono;
let mockEnv: Env;
let csrfToken: string;
let request: (path: string, init?: RequestInit) => Promise<Response>;
let loginAndGetCookie: () => Promise<string>;
beforeEach(() => {
beforeEach(async () => {
mockEnv = createMockEnv() as unknown as Env;
testApp = new Hono();
testApp.route("/admin", app);
csrfToken = await generateCsrfToken("test-password");
request = (path, init = {}) =>
Promise.resolve(testApp.request(path, init, mockEnv));
loginAndGetCookie = async () => {
@@ -94,6 +97,7 @@ describe("Admin Routes", () => {
const res = await request("/admin", {
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
});
expect(res.status).toBe(200);
@@ -139,6 +143,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
body: formData,
});
@@ -175,6 +180,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
body: formData,
});
@@ -195,6 +201,7 @@ describe("Admin Routes", () => {
headers: {
Cookie: authCookie,
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ title: "", description: "desc" }),
});
@@ -241,6 +248,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
body: formData,
});
@@ -259,6 +267,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
});
@@ -291,6 +300,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
body: formData,
});
@@ -310,6 +320,7 @@ describe("Admin Routes", () => {
headers: {
Cookie: authCookie,
Accept: "application/json",
"X-CSRF-Token": csrfToken,
},
},
);
@@ -329,7 +340,7 @@ describe("Admin Routes", () => {
formData.append("description", "Test");
const createRes = await request("/admin/feeds/create", {
method: "POST",
headers: { Cookie: authCookie },
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
body: formData,
});
expect(createRes.status).toBe(302);
@@ -350,7 +361,7 @@ describe("Admin Routes", () => {
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
method: "POST",
headers: { Cookie: authCookie },
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
body: bulkForm,
});
@@ -479,6 +490,7 @@ describe("Admin Routes", () => {
method: "POST",
headers: {
Cookie: authCookie,
"X-CSRF-Token": csrfToken,
},
body: formData,
});
@@ -528,6 +540,7 @@ describe("Admin Routes", () => {
headers: {
Cookie: authCookie,
Accept: "application/json",
"X-CSRF-Token": csrfToken,
},
},
);
+83 -18
View File
@@ -15,6 +15,9 @@ import {
import { generateFeedId } from "../utils/id-generator";
import { designSystem } from "../styles/index";
import { interactiveScripts } from "../scripts/index";
import { generateCsrfToken, verifyCsrfToken } from "../utils/csrf";
type AppEnv = { Bindings: Env };
/**
* Admin routes handler for Email-to-RSS
@@ -25,7 +28,7 @@ import { interactiveScripts } from "../scripts/index";
* - Uses HttpOnly cookies to prevent XSS attacks
* - Implements SameSite=Strict to prevent CSRF attacks
*/
const app = new Hono();
const app = new Hono<AppEnv>();
// Export for testing
export default app;
@@ -33,7 +36,7 @@ export default app;
const ADMIN_COOKIE_NAME = "admin_auth";
const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) {
// Hono throws when ExecutionContext isn't present (ex: Node unit tests).
try {
c.executionCtx.waitUntil(promise);
@@ -90,7 +93,7 @@ function timingSafeEqual(a: string, b: string): boolean {
// Authentication middleware for admin routes
async function authMiddleware(c: Context, next: () => Promise<void>) {
const env = c.env as unknown as Env;
const env = c.env;
const path = new URL(c.req.url).pathname;
// Skip auth check for login page - note that path includes /admin prefix
@@ -101,7 +104,7 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
// Proxy auth: only active when both env vars are present
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
.map((s) => s.trim())
.map((s: string) => s.trim())
.filter(Boolean);
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
@@ -133,6 +136,48 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
// Apply auth middleware to all admin routes
app.use("*", authMiddleware);
// CSRF middleware: generates token for GET requests and validates on mutating requests
app.use("*", async (c, next) => {
const path = new URL(c.req.url).pathname;
// Login route is pre-auth, so CSRF doesn't apply there
if (path === "/admin/login") {
return next();
}
const token = await generateCsrfToken(c.env.ADMIN_PASSWORD);
c.set("csrfToken", token);
if (
c.req.method === "POST" ||
c.req.method === "PUT" ||
c.req.method === "DELETE"
) {
// Accept token from X-CSRF-Token header (JS fetch calls)
const headerToken = c.req.header("X-CSRF-Token") ?? "";
if (headerToken) {
if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, headerToken))) {
return c.text("Invalid CSRF token", 403);
}
return next();
}
// For HTML form submissions: clone the request body to read _csrf without consuming the stream
const contentType = c.req.header("Content-Type") ?? "";
if (
contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data")
) {
const form = await c.req.raw.clone().formData();
const formToken = form.get("_csrf")?.toString() ?? "";
if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, formToken))) {
return c.text("Invalid CSRF token", 403);
}
}
}
return next();
});
// Schema for feed creation
const createFeedSchema = z.object({
title: z.string().min(1, "Title is required"),
@@ -156,7 +201,7 @@ const authSchema = z.object({
// Base HTML layout with design system
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const layout = (title: string, content: any) => {
const layout = (title: string, content: any, csrfToken = "") => {
return html`<!DOCTYPE html>
<html>
<head>
@@ -164,6 +209,7 @@ const layout = (title: string, content: any) => {
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="csrf-token" content="${csrfToken}" />
<style>
${raw(designSystem)}
</style>
@@ -243,7 +289,7 @@ app.get("/login", (c) => {
// Handle login
app.post("/login", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
try {
const formData = await c.req.formData();
@@ -281,7 +327,7 @@ app.get("/logout", (c) => {
// Admin dashboard route
app.get("/", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const url = new URL(c.req.url);
const view = url.searchParams.get("view") === "table" ? "table" : "list";
@@ -345,6 +391,11 @@ app.get("/", async (c) => {
<div class="card">
<h2>Create New Feed</h2>
<form action="/admin/feeds/create" method="post">
<input
type="hidden"
name="_csrf"
value="${c.var.csrfToken ?? ""}"
/>
<div class="form-group">
<label for="title">Feed Title</label>
<input type="text" id="title" name="title" required />
@@ -1211,6 +1262,7 @@ app.get("/", async (c) => {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
credentials: 'same-origin',
});
@@ -1467,6 +1519,7 @@ app.get("/", async (c) => {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
},
credentials: 'same-origin',
body: JSON.stringify({ feedIds: batch }),
@@ -1516,6 +1569,7 @@ app.get("/", async (c) => {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
},
credentials: 'same-origin',
body: JSON.stringify({ feedIds: [feedId] }),
@@ -1598,6 +1652,7 @@ app.get("/", async (c) => {
`)};
</script>
`,
c.var.csrfToken ?? "",
),
);
});
@@ -1605,7 +1660,7 @@ app.get("/", async (c) => {
// Create a new feed
app.post("/feeds/create", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const isJson =
c.req.header("Content-Type")?.includes("application/json") ?? false;
@@ -1702,7 +1757,7 @@ app.post("/feeds/create", async (c) => {
// Edit feed page
app.get("/feeds/:feedId/edit", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
@@ -1734,6 +1789,11 @@ app.get("/feeds/:feedId/edit", async (c) => {
<div class="card">
<form action="/admin/feeds/${feedId}/edit" method="post">
<input
type="hidden"
name="_csrf"
value="${c.var.csrfToken ?? ""}"
/>
<div class="form-group">
<label for="title">Feed Title</label>
<input
@@ -1778,6 +1838,7 @@ ${(feedConfig.allowed_senders || []).join("\n")}</textarea
</div>
</div>
`,
c.var.csrfToken ?? "",
),
);
});
@@ -1785,7 +1846,7 @@ ${(feedConfig.allowed_senders || []).join("\n")}</textarea
// Update feed
app.post("/feeds/:feedId/edit", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
@@ -1978,7 +2039,7 @@ async function purgeFeedKeysStep(
// Delete feed
app.post("/feeds/:feedId/delete", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const view = c.req.query("view") === "table" ? "table" : "list";
@@ -2014,7 +2075,7 @@ app.post("/feeds/:feedId/delete", async (c) => {
// Purge all keys for a feed in small steps (used by the admin UI after deleting feeds).
app.post("/feeds/:feedId/purge", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
@@ -2051,7 +2112,7 @@ app.post("/feeds/:feedId/purge", async (c) => {
// Bulk delete feeds selected in the dashboard
app.post("/feeds/bulk-delete", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
@@ -2188,7 +2249,7 @@ app.post("/feeds/bulk-delete", async (c) => {
// View all emails for a feed
app.get("/feeds/:feedId/emails", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const message = c.req.query("message");
@@ -2772,6 +2833,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
method: 'POST',
headers: {
'Accept': 'application/json',
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
},
credentials: 'same-origin',
});
@@ -3023,6 +3085,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
credentials: 'same-origin',
body: JSON.stringify({ emailKeys: batch }),
@@ -3084,6 +3147,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
`)};
</script>
`,
c.var.csrfToken ?? "",
),
);
});
@@ -3091,7 +3155,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
// View email content
app.get("/emails/:emailKey", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const emailKey = c.req.param("emailKey");
@@ -3444,6 +3508,7 @@ ${emailData.content.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre
`)};
</script>
`,
c.var.csrfToken ?? "",
),
);
});
@@ -3451,7 +3516,7 @@ ${emailData.content.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</pre
// Delete email
app.post("/emails/:emailKey/delete", async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const emailKey = c.req.param("emailKey");
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
@@ -3515,7 +3580,7 @@ app.post("/emails/:emailKey/delete", async (c) => {
// Bulk delete selected emails from a feed
app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const contentType = c.req.header("Content-Type") || "";
@@ -3769,7 +3834,7 @@ app.post(
),
async (c) => {
// Type assertion for environment variables
const env = c.env as unknown as Env;
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
+12 -41
View File
@@ -1,56 +1,27 @@
import { Context } from "hono";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
import { Env } from "../types";
import { generateAtomFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
export async function handle(c: Context): Promise<Response> {
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
try {
const env = c.env as unknown as Env;
const feedId = c.req.param("feedId");
if (!feedId) {
return new Response("Feed ID is required", { status: 400 });
}
const emailStorage = env.EMAIL_STORAGE;
const feedMetadata = (await emailStorage.get(
`feed:${feedId}:metadata`,
"json",
)) as FeedMetadata | null;
if (!feedMetadata) {
const feedData = await fetchFeedData(feedId, c.env, "atom");
if (!feedData) {
return new Response("Feed not found", { status: 404 });
}
const feedConfig = ((await emailStorage.get(
`feed:${feedId}:config`,
"json",
)) as FeedConfig | null) || {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
site_url: `https://${env.DOMAIN}/atom/${feedId}`,
feed_url: `https://${env.DOMAIN}/atom/${feedId}`,
language: "en",
created_at: Date.now(),
};
const emails = feedMetadata.emails.slice(0, 20);
const emailsData: EmailData[] = [];
for (const email of emails) {
const emailData = (await emailStorage.get(
email.key,
"json",
)) as EmailData | null;
if (emailData) {
emailsData.push(emailData);
}
}
const baseUrl = `https://${env.DOMAIN}`;
const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId);
const baseUrl = `https://${c.env.DOMAIN}`;
const atomXml = generateAtomFeed(
feedData.feedConfig,
feedData.emails,
baseUrl,
feedId,
);
const linkHeader = [
`<${baseUrl}/hub>; rel="hub"`,
`<${baseUrl}/atom/${feedId}>; rel="self"`,
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from "hono";
import { handle } from "./entries";
import { createMockEnv } from "../test/setup";
const FEED_ID = "test-feed";
const RECEIVED_AT = 1700000001000;
const EMAIL_KEY = `feed:${FEED_ID}:${RECEIVED_AT}`;
function makeApp() {
const app = new Hono();
app.get("/:feedId/:entryId", handle);
return app;
}
async function seedFeed(env: ReturnType<typeof createMockEnv>) {
await env.EMAIL_STORAGE.put(
EMAIL_KEY,
JSON.stringify({
subject: "Test Subject",
from: "sender@example.com",
content: "<p>Email body</p>",
receivedAt: RECEIVED_AT,
headers: {},
}),
);
await env.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({
emails: [
{ key: EMAIL_KEY, subject: "Test Subject", receivedAt: RECEIVED_AT },
],
}),
);
}
describe("GET /entries/:feedId/:entryId", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("returns 404 when feed does not exist", async () => {
const app = makeApp();
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
expect(res.status).toBe(404);
expect(await res.text()).toContain("Feed not found");
});
it("returns 404 when entry does not exist in metadata", async () => {
const app = makeApp();
await env.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({ emails: [] }),
);
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
expect(res.status).toBe(404);
expect(await res.text()).toContain("Entry not found");
});
it("returns 404 when entryId is not a number", async () => {
const app = makeApp();
const res = await app.request(`/${FEED_ID}/not-a-number`, {}, env as any);
expect(res.status).toBe(404);
});
it("returns 200 with HTML for valid entry", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/html");
});
it("includes email subject in HTML title", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("Test Subject");
});
it("includes email content in HTML body", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("<p>Email body</p>");
});
it("includes sender in HTML", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("sender@example.com");
});
it("sets Content-Security-Policy header", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
expect(res.headers.get("Content-Security-Policy")).toContain(
"default-src 'none'",
);
});
});
+2 -3
View File
@@ -2,8 +2,7 @@ import { Context } from "hono";
import { html, raw } from "hono/html";
import { Env, FeedMetadata, EmailData } from "../types";
export async function handle(c: Context): Promise<Response> {
const env = c.env as unknown as Env;
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const feedId = c.req.param("feedId");
const receivedAt = parseInt(c.req.param("entryId"), 10);
@@ -11,7 +10,7 @@ export async function handle(c: Context): Promise<Response> {
return new Response("Not Found", { status: 404 });
}
const emailStorage = env.EMAIL_STORAGE;
const emailStorage = c.env.EMAIL_STORAGE;
const feedMetadata = (await emailStorage.get(
`feed:${feedId}:metadata`,
+3 -5
View File
@@ -1,17 +1,15 @@
import { Context } from "hono";
import { Env } from "../types";
export async function handle(c: Context): Promise<Response> {
const env = c.env as unknown as Env;
if (!env.ATTACHMENT_BUCKET) {
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
if (!c.env.ATTACHMENT_BUCKET) {
return new Response("Attachment storage not configured", { status: 404 });
}
const attachmentId = c.req.param("attachmentId");
const filename = c.req.param("filename");
const object = await env.ATTACHMENT_BUCKET.get(attachmentId);
const object = await c.env.ATTACHMENT_BUCKET.get(attachmentId);
if (!object) {
return new Response("Not found", { status: 404 });
+5 -3
View File
@@ -1,11 +1,13 @@
import { Hono, type Context } from "hono";
import { Env } from "../types";
type AppEnv = { Bindings: Env };
import {
verifyAndStoreSubscription,
verifyAndDeleteSubscription,
} from "../utils/websub";
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) {
// Hono throws when ExecutionContext isn't present (e.g. Node unit tests).
try {
c.executionCtx.waitUntil(promise);
@@ -17,10 +19,10 @@ function waitUntilSafe(c: Context, promise: Promise<unknown>) {
const DEFAULT_LEASE_SECONDS = 86400;
const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
export const hubRouter = new Hono();
export const hubRouter = new Hono<AppEnv>();
hubRouter.post("/", async (c) => {
const env = c.env as unknown as Env;
const env = c.env;
let form: FormData;
try {
form = await c.req.formData();
+2 -3
View File
@@ -2,9 +2,8 @@ import { Context } from "hono";
import { Env } from "../types";
import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
export async function handle(c: Context): Promise<Response> {
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
try {
const env = c.env as unknown as Env;
const payload: ForwardEmailPayload = await c.req.json();
console.log("Received email:", {
@@ -20,7 +19,7 @@ export async function handle(c: Context): Promise<Response> {
} catch {
// No ExecutionContext in this environment (e.g. tests); WebSub notifications will be skipped
}
return handleForwardEmail(payload, env, ctx);
return handleForwardEmail(payload, c.env, ctx);
} catch (error) {
console.error("Error processing email:", error);
return new Response("Error processing email", { status: 500 });
+15 -58
View File
@@ -1,80 +1,37 @@
import { Context } from "hono";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
import { Env } from "../types";
import { generateRssFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
/**
* Generates an RSS feed for a specific feed ID
*/
export async function handle(c: Context): Promise<Response> {
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
try {
// Type assertion for environment variables
const env = c.env as unknown as Env;
// Extract the feed ID from the route params
const feedId = c.req.param("feedId");
if (!feedId) {
return new Response("Feed ID is required", { status: 400 });
}
// Get the KV namespace
const emailStorage = env.EMAIL_STORAGE;
// Check if the feed exists
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await emailStorage.get(
feedMetadataKey,
"json",
)) as FeedMetadata | null;
if (!feedMetadata) {
const feedData = await fetchFeedData(feedId, c.env, "rss");
if (!feedData) {
return new Response("Feed not found", { status: 404 });
}
// Get feed configuration (title, description, etc.)
const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = ((await emailStorage.get(
feedConfigKey,
"json",
)) as FeedConfig | null) || {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
site_url: `https://${env.DOMAIN}/rss/${feedId}`,
feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
language: "en",
created_at: Date.now(),
};
// Get the emails for this feed (up to the last 20)
const emails = feedMetadata.emails.slice(0, 20);
const emailsData: EmailData[] = [];
// Fetch all email content
for (const email of emails) {
const emailData = (await emailStorage.get(
email.key,
"json",
)) as EmailData | null;
if (emailData) {
emailsData.push(emailData);
}
}
// Generate the RSS feed XML
const baseUrl = `https://${env.DOMAIN}`;
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl, feedId);
// Return the RSS feed with appropriate content type
const baseUrl = `https://${c.env.DOMAIN}`;
const rssXml = generateRssFeed(
feedData.feedConfig,
feedData.emails,
baseUrl,
feedId,
);
const linkHeader = [
`<https://${env.DOMAIN}/hub>; rel="hub"`,
`<https://${env.DOMAIN}/rss/${feedId}>; rel="self"`,
`<${baseUrl}/hub>; rel="hub"`,
`<${baseUrl}/rss/${feedId}>; rel="self"`,
].join(", ");
return new Response(rssXml, {
status: 200,
headers: {
"Content-Type": "application/rss+xml",
"Cache-Control": "max-age=1800", // 30 minutes cache
"Cache-Control": "max-age=1800",
Link: linkHeader,
},
});