import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { Env } from "../types"; import { csrf } from "hono/csrf"; import { ADMIN_COOKIE_MAX_AGE } from "../config/constants"; import { logger } from "../infrastructure/logger"; import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth"; import { Layout, clampText } from "./admin/ui"; import { FeedRepository } from "../infrastructure/feed-repository"; import { renameFeed } from "../application/feed-service"; import { feedRssUrl, feedAtomUrl, feedEmailAddress, } from "../infrastructure/urls"; 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(); }); // 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 (checkProxyAuth(c, env)) { 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}
); function formatExpiry(expiresAt: number): { label: string; expired: boolean } { const remaining = expiresAt - Date.now(); if (remaining <= 0) { const h = Math.floor(-remaining / 3_600_000); return { label: h > 0 ? `Expired ${h}h ago` : "Just expired", expired: true, }; } const h = Math.floor(remaining / 3_600_000); if (h >= 48) { return { label: `Expires in ${Math.floor(h / 24)}d`, expired: false }; } const m = Math.floor((remaining % 3_600_000) / 60_000); return { label: h > 0 ? `Expires in ${h}h ${m}m` : `Expires in ${m}m`, expired: false, }; } const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => { const { label, expired } = formatExpiry(expiresAt); return ( {label} ); }; // Admin dashboard route app.get("/", async (c) => { // Type assertion for environment variables const env = c.env; 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 FeedRepository.from(env).listFeeds(); // 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.
Emails from these senders/domains are always rejected, even if they match the allowlist.
{env.FEED_TTL_HOURS ? ( Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server configuration. ) : ( Leave empty for no expiry. )}
{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 = feedEmailAddress(feed.id, env); const rssUrl = feedRssUrl(feed.id, env); const atomUrl = feedAtomUrl(feed.id, env); 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(); const isExpired = feed.expires_at !== undefined && feed.expires_at <= Date.now(); return ( ); })}
Expires
Actions
{titleDisplay} {feed.description && (
{descDisplay}
)}
{feed.id} {feed.expires_at ? ( ) : ( )}
{isExpired ? ( <> Edit Emails ) : ( <> Edit Emails )}
) : ( <>
Tip: use Table view for bulk deletion.
    {feedsWithConfig.map((feed) => { const emailAddress = feedEmailAddress(feed.id, env); const rssUrl = feedRssUrl(feed.id, env); const atomUrl = feedAtomUrl(feed.id, env); 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(); const isExpired = feed.expires_at !== undefined && feed.expires_at <= Date.now(); return (
  • {titleDisplay}

    {feed.expires_at && ( )} {feed.description && (

    {descDisplay}

    )}
    Email:
    {emailAddress}
    RSS Feed:
    {rssUrl}
    Atom Feed:
    {atomUrl}
    {isExpired ? ( <> Edit Emails ) : ( <> Edit Emails )}
  • ); })}
)}