import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { Env, FeedConfig } from "../types"; import { csrf } from "hono/csrf"; import { ADMIN_COOKIE_MAX_AGE } from "../config/constants"; import { logger } from "../lib/logger"; import { Layout, clampText } from "./admin/ui"; import { listAllFeeds, updateFeedInList } from "./admin/helpers"; import { feedsRouter } from "./admin/feeds"; import { emailsRouter } from "./admin/emails"; import { dashboardScript } from "../scripts/generated/dashboard"; type AppEnv = { Bindings: Env }; /** * 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"; // Prevent accidental caching of admin pages and redirects. app.use("*", async (c, next) => { c.header("Cache-Control", "no-store, max-age=0"); await next(); }); function timingSafeEqual(a: string, b: string): boolean { const enc = new TextEncoder(); const aBytes = enc.encode(a); const bBytes = enc.encode(b); // Try native timing-safe implementation first (Cloudflare Workers runtime) // eslint-disable-next-line @typescript-eslint/no-explicit-any const subtle = crypto.subtle as any; if (typeof subtle.timingSafeEqual === "function") { if (aBytes.length !== bBytes.length) return false; return subtle.timingSafeEqual(aBytes, bBytes); } // Constant-time fallback for Node (test environment): encode length // mismatch into `diff` so the loop always runs over the full length. const len = Math.max(aBytes.length, bBytes.length); let diff = aBytes.length ^ bBytes.length; for (let i = 0; i < len; i++) { diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0); } return diff === 0; } // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.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(); } // 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: string) => s.trim()) .filter(Boolean); const clientIp = c.req.header("CF-Connecting-IP") ?? ""; const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? ""; const remoteUser = c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || ""; if ( trustedIps.includes(clientIp) && timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) && remoteUser.length > 0 ) { return next(); } } // Fallback: signed cookie 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); // CSRF middleware: validates Origin header on mutating requests (POST/PUT/DELETE/PATCH) // Skip on /admin/login — password itself provides protection for the pre-auth route const csrfMiddleware = csrf({ origin: (origin, c) => origin === `https://${c.env.DOMAIN}`, }); app.use("*", (c, next) => { const path = new URL(c.req.url).pathname; if (path === "/admin/login") return next(); return csrfMiddleware(c, next); }); // Schema for feed API updates (title/description only) 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"), }); // Login page app.get("/login", (c) => { const error = c.req.query("error"); const errorMessage = error === "invalid" ? "Invalid password. Please try again." : ""; return c.html(

kill-the-news

{errorMessage && (
{errorMessage}
)}
, ); }); // Handle login app.post("/login", async (c) => { const env = c.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 (timingSafeEqual(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) { logger.error("Login error", { error: String(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"); }); // dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`. // It is imported from src/scripts/generated/dashboard.ts above. // ── Shared SVG icons ────────────────────────────────────────────────────────── const CopyIcon = () => ( ); const CheckIcon = () => ( ); type CopyFieldInlineProps = { value: string; emailAddress?: string; }; const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
{value}
); // Admin dashboard route app.get("/", async (c) => { // Type assertion for environment variables 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"; 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 listHref = (() => { const nextUrl = new URL(url); nextUrl.pathname = "/admin"; nextUrl.searchParams.set("view", "list"); const qs = nextUrl.searchParams.toString(); return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`; })(); const tableHref = (() => { const nextUrl = new URL(url); nextUrl.pathname = "/admin"; nextUrl.searchParams.set("view", "table"); const qs = nextUrl.searchParams.toString(); return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`; })(); const viewToggle = ( ); return c.html(

kill-the-news

Manage your email newsletter feeds

Create New Feed

When set, inbound emails are only accepted from these senders/domains.
{message === "bulkDeleted" && (

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

)} {message === "bulkDeleteNoop" && (

No feeds were selected.

)}

Your Feeds

{feedsWithConfig.length}
{viewToggle}
{feedsWithConfig.length === 0 ? (

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

) : view === "table" ? (
Showing {feedsWithConfig.length} 0 selected
{feedsWithConfig.map((feed) => { const emailAddress = `${feed.id}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`; const titleDisplay = clampText(feed.title, 160); const titleHover = clampText(feed.title, 1000); const sortTitle = titleHover.toLowerCase(); const sortFeedId = feed.id.toLowerCase(); const sortEmail = emailAddress.toLowerCase(); const sortRss = rssUrl.toLowerCase(); const sortAtom = atomUrl.toLowerCase(); 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 ( ); })}
Actions
{titleDisplay} {feed.description && (
{descDisplay}
)}
{feed.id}
) : ( <>
Tip: use Table view for bulk deletion.
    {feedsWithConfig.map((feed) => { const emailAddress = `${feed.id}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const atomUrl = `https://${env.DOMAIN}/atom/${feed.id}`; const titleDisplay = clampText(feed.title, 140); const titleHover = clampText(feed.title, 1000); const descDisplay = clampText(feed.description || "", 240); const descHover = clampText(feed.description || "", 1000); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return (
  • {titleDisplay}

    {feed.description && (

    {descDisplay}

    )}
    Email:
    {emailAddress}
    RSS Feed:
    {rssUrl}
    Atom Feed:
    {atomUrl}
  • ); })}
)}