import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html, raw } from "hono/html"; import { z } from "zod"; import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem, } from "../types"; import { generateFeedId } from "../utils/id-generator"; import { designSystem } from "../styles/index"; import { interactiveScripts } from "../scripts/index"; /** * Admin routes handler for Email-to-RSS * Provides a secure interface for managing RSS feeds and viewing emails * * Security: * - All routes except /login are protected by server-side cookie authentication * - Uses HttpOnly cookies to prevent XSS attacks * - Implements SameSite=Strict to prevent CSRF attacks */ const app = new Hono(); // Export for testing export default app; const ADMIN_COOKIE_NAME = "admin_auth"; const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week function parseAllowedSenders(rawAllowedSenders: string): string[] { return rawAllowedSenders .split(/[\n,]+/) .map((value) => value.trim().toLowerCase()) .filter(Boolean); } function clampText(value: string, maxLen: number): string { const raw = `${value || ""}`; if (raw.length <= maxLen) { return raw.trim(); } if (maxLen <= 3) { return raw.slice(0, maxLen).trim(); } return `${raw.slice(0, maxLen - 3).trimEnd()}...`; } // Prevent accidental caching of admin pages and redirects. app.use("*", async (c, next) => { c.header("Cache-Control", "no-store, max-age=0"); await next(); }); // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.env as unknown as Env; const path = new URL(c.req.url).pathname; // Skip auth check for login page - note that path includes /admin prefix if (path === "/admin/login") { return next(); } const authCookie = await getSignedCookie( c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME, ); if (authCookie !== "1") { return c.redirect("/admin/login"); } await next(); } // Apply auth middleware to all admin routes app.use("*", authMiddleware); // Schema for feed creation const createFeedSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional(), language: z.string().optional().default("en"), allowedSenders: z.array(z.string()).optional().default([]), }); // Schema for feed updates const updateFeedSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional(), language: z.string().optional().default("en"), allowedSenders: z.array(z.string()).optional().default([]), }); // Authentication schema const authSchema = z.object({ password: z.string().min(1, "Password is required"), }); // Base HTML layout with design system const layout = (title: string, content: any) => { return html` ${title} - Email to RSS Admin ${content} `; }; // Login page app.get("/login", (c) => { const error = c.req.query("error"); const errorMessage = error === "invalid" ? "Invalid password. Please try again." : ""; return c.html( layout( "Login", html`

Email to RSS Admin

${errorMessage ? html`
${errorMessage}
` : ""}
`, ), ); }); // Handle login app.post("/login", async (c) => { const env = c.env as unknown as Env; try { const formData = await c.req.formData(); const password = formData.get("password")?.toString() || ""; // Validate password authSchema.parse({ password }); // Check password against environment variable if (password === env.ADMIN_PASSWORD) { await setSignedCookie(c, ADMIN_COOKIE_NAME, "1", env.ADMIN_PASSWORD, { path: "/", httpOnly: true, sameSite: "Strict", secure: true, maxAge: ADMIN_COOKIE_MAX_AGE, }); return c.redirect("/admin"); } // Incorrect password - redirect back to login with an error message return c.redirect("/admin/login?error=invalid"); } catch (error) { console.error("Login error:", error); return c.redirect("/admin/login?error=invalid"); } }); // Logout route app.get("/logout", (c) => { deleteCookie(c, ADMIN_COOKIE_NAME, { path: "/" }); return c.redirect("/admin/login"); }); // Admin dashboard route app.get("/", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const url = new URL(c.req.url); const view = url.searchParams.get("view") === "table" ? "table" : "list"; const message = url.searchParams.get("message"); const count = Number(url.searchParams.get("count") || "0"); // List all feeds const feedList = await listAllFeeds(emailStorage); // Keep the dashboard fast: avoid N KV reads for N feeds. // We store title/description in `feeds:list` (description is optional for older data). const feedsWithConfig = feedList.map((feed) => ({ ...feed, description: feed.description || "", })); const viewHref = (nextView: "list" | "table") => { const nextUrl = new URL(url); nextUrl.pathname = "/admin"; nextUrl.searchParams.set("view", nextView); const qs = nextUrl.searchParams.toString(); return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`; }; const viewToggle = html`
List Table
`; return c.html( layout( "Dashboard", html`

Email to RSS Admin

Manage your email newsletter feeds

Create New Feed

When set, inbound emails are only accepted from these senders/domains.
${message === "bulkDeleted" ? html`

Deleted ${Number.isFinite(count) ? count : 0} feed(s).

` : ""} ${message === "bulkDeleteNoop" ? html`

No feeds were selected.

` : ""}

Your Feeds

${feedsWithConfig.length}
${viewToggle}
${feedsWithConfig.length === 0 ? html`

You don't have any feeds yet. Create one above.

` : view === "table" ? html`
0 selected
${feedsWithConfig.map((feed) => { const emailAddress = `${feed.id}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const titleDisplay = clampText(feed.title, 160); const titleHover = clampText(feed.title, 1000); const descDisplay = clampText(feed.description || "", 220); const descHover = clampText(feed.description || "", 1000); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return html` `; })}
Title Feed ID Email RSS Actions
${titleDisplay} ${feed.description ? html`
${descDisplay}
` : ""}
${feed.id}
${emailAddress}
${rssUrl}
Edit Emails
` : html`
Tip: use Table view for bulk deletion.
`}
`, ), ); }); // Create a new feed app.post("/feeds/create", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; try { const formData = await c.req.formData(); const title = formData.get("title")?.toString() || ""; const description = formData.get("description")?.toString(); const language = formData.get("language")?.toString() || "en"; const view = formData.get("view")?.toString() === "table" ? "table" : "list"; const allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); // Validate inputs const parsedData = createFeedSchema.parse({ title, description, language, allowedSenders, }); // Generate a feed ID const feedId = generateFeedId(); // Create feed configuration const feedConfig: FeedConfig = { title: parsedData.title, description: parsedData.description, language: parsedData.language, site_url: `https://${env.DOMAIN}/rss/${feedId}`, feed_url: `https://${env.DOMAIN}/rss/${feedId}`, allowed_senders: parsedData.allowedSenders, created_at: Date.now(), updated_at: Date.now(), }; // Create feed metadata const feedMetadata: FeedMetadata = { emails: [], }; // Store feed configuration and metadata await emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)); await emailStorage.put( `feed:${feedId}:metadata`, JSON.stringify(feedMetadata), ); // Add feed to the list of all feeds await addFeedToList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Redirect back to admin page return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error creating feed:", error); return c.text("Error creating feed. Please try again.", 400); } }); // Edit feed page app.get("/feeds/:feedId/edit", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); // Get feed configuration const feedConfigKey = `feed:${feedId}:config`; const feedConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!feedConfig) { return c.text("Feed not found", 404); } return c.html( layout( "Edit Feed", html`

${feedConfig.title} - Edit Feed

When set, inbound emails are only accepted from these senders/domains.
`, ), ); }); // Update feed app.post("/feeds/:feedId/edit", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const formData = await c.req.formData(); const title = formData.get("title")?.toString() || ""; const description = formData.get("description")?.toString(); const language = formData.get("language")?.toString() || "en"; const allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language, allowedSenders, }); // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!existingConfig) { return c.text("Feed not found", 404); } // Update feed configuration await emailStorage.put( feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, language: parsedData.language, allowed_senders: parsedData.allowedSenders, updated_at: Date.now(), }), ); // Update feed in the list of all feeds await updateFeedInList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Redirect back to admin page return c.redirect("/admin"); } catch (error) { console.error("Error updating feed:", error); return c.text("Error updating feed. Please try again.", 400); } }); async function deleteFeedAndEmails( emailStorage: KVNamespace, feedId: string, ): Promise { const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (!feedMetadata) { return false; } for (const email of feedMetadata.emails) { await emailStorage.delete(email.key); } await emailStorage.delete(`feed:${feedId}:config`); await emailStorage.delete(feedMetadataKey); await removeFeedFromList(emailStorage, feedId); return true; } // Delete feed app.post("/feeds/:feedId/delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const view = c.req.query("view") === "table" ? "table" : "list"; try { const deleted = await deleteFeedAndEmails(emailStorage, feedId); if (!deleted) { return c.text("Feed not found", 404); } return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error deleting feed:", error); return c.text("Error deleting feed. Please try again.", 400); } }); // Bulk delete feeds selected in the dashboard app.post("/feeds/bulk-delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; try { const formData = await c.req.formData(); const view = formData.get("view")?.toString() === "table" ? "table" : "list"; const redirectBase = `/admin?view=${view}`; const rawIds = formData.getAll("feedIds").map((value) => value.toString()); const feedIds = Array.from(new Set(rawIds.filter(Boolean))); if (feedIds.length === 0) { return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); } let deletedCount = 0; for (const feedId of feedIds) { const deleted = await deleteFeedAndEmails(emailStorage, feedId); if (deleted) { deletedCount += 1; } } return c.redirect(`${redirectBase}&message=bulkDeleted&count=${deletedCount}`); } catch (error) { console.error("Error bulk deleting feeds:", error); return c.text("Error bulk deleting feeds. Please try again.", 400); } }); // 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 emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const message = c.req.query("message"); const count = Number(c.req.query("count") || "0"); // Get feed configuration and metadata const feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; const feedConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (!feedConfig || !feedMetadata) { return c.text("Feed not found", 404); } return c.html( layout( `${feedConfig.title} - Emails`, html`

${feedConfig.title} - Emails

Feed Details

Email Address:
${feedId}@${env.DOMAIN}
RSS Feed:
https://${env.DOMAIN}/rss/${feedId}

Emails (${feedMetadata.emails.length})

${message === "bulkDeleted" ? html`

Deleted ${Number.isFinite(count) ? count : 0} email(s).

` : ""} ${message === "bulkDeleteNoop" ? html`

No emails were selected.

` : ""} ${feedMetadata.emails.length > 0 ? html`
0 selected
${feedMetadata.emails.map((email: EmailMetadata) => { const subjectDisplay = clampText(email.subject, 180); const subjectHover = clampText(email.subject, 1000); const searchHaystack = clampText(email.subject, 320).toLowerCase(); return html` `; })}
Subject Received Actions
` : html`

No emails received yet. Subscribe to newsletters using the email address above.

`}
`, ), ); }); // View email content app.get("/emails/:emailKey", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param("emailKey"); // Get email data const emailData = (await emailStorage.get(emailKey, { type: "json", })) as EmailData | null; if (!emailData) { return c.text("Email not found", 404); } // Extract feed ID from the key format (feed:ID:emails:timestamp) const keyParts = emailKey.split(":"); const feedId = keyParts[1]; // Create a sanitized HTML content with CSS for the iframe const htmlContent = ` ${emailData.content} `; // Properly encode the HTML content to handle Unicode characters const encodedHtmlContent = (() => { // Convert the string to UTF-8 const encoder = new TextEncoder(); const bytes = encoder.encode(htmlContent); // Convert bytes to base64 return btoa(String.fromCharCode(...new Uint8Array(bytes))); })(); return c.html( layout( `Email: ${emailData.subject}`, html`

Email Content

`, ), ); }); // Delete email app.post("/emails/:emailKey/delete", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param("emailKey"); try { // Get feedId from query parameters instead of form data const feedId = c.req.query("feedId"); if (!feedId) { return c.text("Feed ID is required", 400); } // Delete the email await emailStorage.delete(emailKey); // Remove the email from the feed metadata const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (feedMetadata) { // Filter out the deleted email feedMetadata.emails = feedMetadata.emails.filter( (email) => email.key !== emailKey, ); // Update feed metadata await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); } // Redirect back to the feed emails page return c.redirect(`/admin/feeds/${feedId}/emails`); } catch (error) { console.error("Error deleting email:", error); return c.text("Error deleting email. Please try again.", 400); } }); // 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 emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const formData = await c.req.formData(); const rawEmailKeys = formData .getAll("emailKeys") .map((value) => value.toString()); const emailKeys = Array.from(new Set(rawEmailKeys.filter(Boolean))); if (emailKeys.length === 0) { return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`); } const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadata = (await emailStorage.get(feedMetadataKey, { type: "json", })) as FeedMetadata | null; if (!feedMetadata) { return c.text("Feed not found", 404); } const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); let deletedCount = 0; for (const emailKey of emailKeys) { if (!allowedKeys.has(emailKey)) { continue; } await emailStorage.delete(emailKey); deletedCount += 1; } feedMetadata.emails = feedMetadata.emails.filter( (email) => !emailKeys.includes(email.key), ); await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); return c.redirect( `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedCount}`, ); } catch (error) { console.error("Error bulk deleting emails:", error); return c.text("Error bulk deleting emails. Please try again.", 400); } }); // Helper function to list all feeds async function listAllFeeds( emailStorage: KVNamespace, ): Promise { try { const feedListKey = "feeds:list"; const feedList = (await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null; return feedList?.feeds || []; } catch (error) { console.error("Error listing feeds:", error); return []; } } // Helper function to add a feed to the list of all feeds async function addFeedToList( emailStorage: KVNamespace, feedId: string, title: string, description?: string, ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; // Add new feed to the list feedList.feeds.push({ id: feedId, title, description, }); // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } catch (error) { console.error("Error adding feed to list:", error); } } // Helper function to update a feed in the list of all feeds async function updateFeedInList( emailStorage: KVNamespace, feedId: string, title: string, description?: string, ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; // Find and update the feed in the list const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].description = description; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } } catch (error) { console.error("Error updating feed in list:", error); } } // Helper function to remove a feed from the list of all feeds async function removeFeedFromList( emailStorage: KVNamespace, feedId: string, ): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; // Filter out the removed feed feedList.feeds = feedList.feeds.filter((feed) => feed.id !== feedId); // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); } catch (error) { console.error("Error removing feed from list:", error); } } // Update feed via API (for in-place editing) app.post("/api/feeds/:feedId/update", async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { // Parse JSON data from request const data = await c.req.json(); const { title, description } = data; // Validate inputs const parsedData = updateFeedSchema.parse({ title, description, language: "en", // We're defaulting to English }); // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; const existingConfig = (await emailStorage.get(feedConfigKey, { type: "json", })) as FeedConfig | null; if (!existingConfig) { return c.json({ error: "Feed not found" }, 404); } // Update feed configuration await emailStorage.put( feedConfigKey, JSON.stringify({ ...existingConfig, title: parsedData.title, description: parsedData.description, updated_at: Date.now(), }), ); // Update feed in the list of all feeds await updateFeedInList( emailStorage, feedId, parsedData.title, parsedData.description, ); // Return success response return c.json({ success: true }); } catch (error) { console.error("Error updating feed via API:", error); return c.json({ error: "Error updating feed" }, 400); } }); // Export the Hono app export const handle = app;