import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html, raw } from "hono/html"; 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"; 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( layout( "Login", html`

Email to RSS Admin

${errorMessage ? html`
${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"); }); // 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 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` `; 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`
Showing ${feedsWithConfig.length} 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 sortTitle = titleHover.toLowerCase(); const sortFeedId = feed.id.toLowerCase(); const sortEmail = emailAddress.toLowerCase(); const sortRss = rssUrl.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 html` `; })}
Actions
${titleDisplay} ${feed.description ? html`
${descDisplay}
` : ""}
${feed.id}
${emailAddress}
${rssUrl}
Edit Emails
` : html`
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 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 html`
  • ${titleDisplay}

    ${feed.description ? html`

    ${descDisplay}

    ` : ""}
    Email:
    ${emailAddress}
    RSS Feed:
    ${rssUrl}
  • `; })}
`}
`, ), ); }); // Mount sub-routers app.route("/feeds", feedsRouter); app.route("/", emailsRouter); // Update feed via API (for in-place editing) app.post( "/api/feeds/:feedId/update", zValidator( "json", updateFeedSchema.pick({ title: true, description: true }), (result, c) => { if (!result.success) return c.json({ success: false, error: result.error.issues }, 400); }, ), async (c) => { // Type assertion for environment variables const env = c.env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const { title, description } = c.req.valid("json"); const parsedData = { title, description, language: "en" as const }; // 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) { logger.error("Error updating feed via API", { error: String(error) }); return c.json({ error: "Error updating feed" }, 400); } }, ); // Export the Hono app export const handle = app;