diff --git a/src/routes/admin.ts b/src/routes/admin.ts deleted file mode 100644 index 6d2acff..0000000 --- a/src/routes/admin.ts +++ /dev/null @@ -1,1618 +0,0 @@ -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` -
- List - Table -
- `; - - return c.html( - layout( - "Dashboard", - html` -
-
-
-

Email to RSS Admin

-

Manage your email newsletter feeds

-
-
- Logout -
-
- -
-

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} -
    - - - - - - - -
    -
    -
    -
    - -
    -
    - Edit - Emails -
    -
    - -
    -
    -
  • - `; - })} -
- `} -
- - - `, - ), - ); -}); - -// 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; diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx new file mode 100644 index 0000000..b477e6a --- /dev/null +++ b/src/routes/admin.tsx @@ -0,0 +1,1505 @@ +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"; + +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( + +
+
+ +

Email to RSS Admin

+ {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"); +}); + +// The large inline script for the dashboard page +const dashboardScript = ` + let FEED_ROWS = []; + let FEED_CHECKBOXES = []; + let FEED_SELECTED_COUNT_EL = null; + let FEED_MATCH_COUNT_EL = null; + let FEED_TOTAL_COUNT_EL = null; + let FEED_BULK_DELETE_BUTTON_EL = null; + let FEED_SELECT_ALL_EL = null; + let FEED_FILTER_TIMER = null; + let FEED_BULK_DELETE_IN_PROGRESS = false; + let FEED_SORT_KEY = 'title'; + let FEED_SORT_DIR = 'asc'; + const FEED_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + + function initFeedUI() { + FEED_ROWS = Array.from(document.querySelectorAll('.feed-row')); + FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select')); + FEED_SELECTED_COUNT_EL = document.getElementById('selected-feed-count'); + FEED_MATCH_COUNT_EL = document.getElementById('feed-match-count'); + FEED_TOTAL_COUNT_EL = document.getElementById('feed-total-count'); + FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button'); + FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds'); + setupFeedTableResizing(); + setupFeedTableSorting(); + setupFeedDeleteButtons(); + updateFeedMatchCount(); + updateFeedSelectionState(); + } + + function updateFeedMatchCount() { + if (!FEED_MATCH_COUNT_EL) return; + const total = FEED_ROWS.length; + const visible = FEED_ROWS.filter((row) => !row.hidden).length; + const query = (document.getElementById('feed-search')?.value || '').trim(); + FEED_MATCH_COUNT_EL.textContent = query ? ('Showing ' + visible + ' of ' + total) : ('Showing ' + total); + } + + function scheduleFeedFilter() { + if (FEED_FILTER_TIMER) { + clearTimeout(FEED_FILTER_TIMER); + } + FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120); + } + + function getSortValue(row, key) { + const prop = 'sort' + key.charAt(0).toUpperCase() + key.slice(1); + return (row.dataset && row.dataset[prop]) ? row.dataset[prop] : ''; + } + + function updateFeedSortIndicators(table) { + const headerCells = Array.from(table.querySelectorAll('th[data-sort-key]')); + headerCells.forEach((th) => { + const key = th.getAttribute('data-sort-key') || ''; + const indicator = th.querySelector('.sort-indicator'); + const active = key === FEED_SORT_KEY; + + if (indicator) { + indicator.textContent = active ? (FEED_SORT_DIR === 'asc' ? '^' : 'v') : ''; + } + th.setAttribute('aria-sort', active ? (FEED_SORT_DIR === 'asc' ? 'ascending' : 'descending') : 'none'); + }); + } + + function sortFeedTableBy(key) { + const table = document.querySelector('table.table-feeds'); + const tbody = document.getElementById('feed-table-body'); + if (!table || !tbody) return; + + if (FEED_SORT_KEY === key) { + FEED_SORT_DIR = FEED_SORT_DIR === 'asc' ? 'desc' : 'asc'; + } else { + FEED_SORT_KEY = key; + FEED_SORT_DIR = 'asc'; + } + + const dirMultiplier = FEED_SORT_DIR === 'asc' ? 1 : -1; + const rows = Array.from(tbody.querySelectorAll('.feed-row')); + rows.sort((a, b) => { + const av = getSortValue(a, FEED_SORT_KEY); + const bv = getSortValue(b, FEED_SORT_KEY); + return dirMultiplier * FEED_COLLATOR.compare(av, bv); + }); + + const fragment = document.createDocumentFragment(); + rows.forEach((row) => fragment.appendChild(row)); + tbody.appendChild(fragment); + + updateFeedSortIndicators(table); + } + + function setupFeedTableSorting() { + const table = document.querySelector('table.table-feeds'); + if (!table) return; + + table.querySelectorAll('button.th-button[data-sort-key]').forEach((button) => { + button.addEventListener('click', () => { + const key = button.getAttribute('data-sort-key') || ''; + if (!key) return; + sortFeedTableBy(key); + }); + }); + + updateFeedSortIndicators(table); + } + + function setupFeedTableResizing() { + const table = document.querySelector('table.table-feeds'); + if (!table) return; + + const storageKey = 'email-to-rss.admin.feedsTable.colWidths'; + const minWidths = { + title: 220, + feedId: 120, + email: 160, + rss: 160, + actions: 160, + }; + const defaultWidths = { + title: 340, + feedId: 160, + email: 220, + rss: 220, + actions: 200, + }; + + const cols = Array.from(table.querySelectorAll('colgroup col')); + const colByKey = {}; + cols.forEach((col) => { + const key = col.getAttribute('data-col'); + if (key) colByKey[key] = col; + }); + + // Restore widths + try { + const saved = JSON.parse(localStorage.getItem(storageKey) || '{}'); + Object.keys(saved || {}).forEach((key) => { + const px = Number(saved[key]); + if (!colByKey[key] || !Number.isFinite(px)) return; + colByKey[key].style.width = px + 'px'; + }); + } catch { + // Ignore bad localStorage values + } + + const persist = () => { + try { + const out = {}; + Object.keys(colByKey).forEach((key) => { + if (key === 'select') return; + const px = parseInt(colByKey[key].style.width || '0', 10); + if (Number.isFinite(px) && px > 0) out[key] = px; + }); + localStorage.setItem(storageKey, JSON.stringify(out)); + } catch { + // localStorage may be unavailable in some modes; ignore + } + }; + + let active = null; + let rafId = 0; + let pendingWidth = 0; + + table.querySelectorAll('.col-resizer').forEach((handle) => { + handle.addEventListener('pointerdown', (event) => { + event.preventDefault(); + event.stopPropagation(); + + const key = handle.getAttribute('data-col'); + const col = key ? colByKey[key] : null; + if (!key || !col) return; + + const th = handle.closest('th'); + const startWidth = th ? th.getBoundingClientRect().width : parseInt(col.style.width || '0', 10) || 120; + + active = { key, col, startX: event.clientX, startWidth }; + document.body.classList.add('is-resizing'); + handle.setPointerCapture(event.pointerId); + }); + + handle.addEventListener('pointermove', (event) => { + if (!active) return; + const minPx = minWidths[active.key] || 120; + const nextWidth = Math.max(minPx, Math.round(active.startWidth + (event.clientX - active.startX))); + pendingWidth = nextWidth; + if (rafId) return; + rafId = requestAnimationFrame(() => { + active.col.style.width = pendingWidth + 'px'; + rafId = 0; + }); + }); + + const finish = () => { + if (!active) return; + active = null; + document.body.classList.remove('is-resizing'); + persist(); + }; + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + + handle.addEventListener('dblclick', (event) => { + event.preventDefault(); + event.stopPropagation(); + const key = handle.getAttribute('data-col'); + const col = key ? colByKey[key] : null; + const px = key ? defaultWidths[key] : null; + if (!key || !col || !px) return; + col.style.width = px + 'px'; + persist(); + }); + }); + } + + const DELETE_CONFIRM_LABEL = 'Confirm delete'; + const DELETE_LOADING_LABEL = 'Deleting...'; + const DELETE_CONFIRM_TIMEOUT_MS = 4000; + + function getDeleteView() { + return new URL(window.location.href).searchParams.get('view') || 'list'; + } + + function resetDeleteButton(buttonEl) { + if (!buttonEl) return; + buttonEl.classList.remove('is-confirming'); + buttonEl.removeAttribute('data-confirming'); + buttonEl.disabled = false; + const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim() || 'Delete'; + buttonEl.innerHTML = original; + } + + function animateRowRemoval(row, onDone) { + if (!row) { + if (onDone) onDone(); + return; + } + + const isListItem = row.tagName.toLowerCase() === 'li'; + if (isListItem) { + row.style.maxHeight = row.getBoundingClientRect().height + 'px'; + row.style.overflow = 'hidden'; + } + + row.classList.add('is-removing'); + + requestAnimationFrame(() => { + if (isListItem) { + row.style.maxHeight = '0px'; + row.style.marginTop = '0px'; + row.style.marginBottom = '0px'; + row.style.paddingTop = '0px'; + row.style.paddingBottom = '0px'; + } + }); + + window.setTimeout(() => { + row.remove(); + if (onDone) onDone(); + }, 240); + } + + async function deleteFeedRequest(feedId, view) { + const res = await fetch('/admin/feeds/' + encodeURIComponent(feedId) + '/delete?view=' + encodeURIComponent(view), { + method: 'POST', + headers: { + 'Accept': 'application/json', + }, + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); + throw new Error(message); + } + return data; + } + + function refreshFeedRowCache() { + FEED_ROWS = Array.from(document.querySelectorAll('.feed-row')); + FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select')); + if (FEED_TOTAL_COUNT_EL) { + FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length); + } + updateFeedMatchCount(); + updateFeedSelectionState(); + } + + function setupFeedDeleteButtons() { + const buttons = Array.from(document.querySelectorAll('button[data-delete-kind="feed"]')); + buttons.forEach((button) => { + if (button.dataset.deleteReady === 'true') return; + button.dataset.deleteReady = 'true'; + const original = (button.textContent || '').trim() || 'Delete'; + button.dataset.originalLabel = original; + + let confirming = false; + let confirmTimer = 0; + let inFlight = false; + + const startConfirm = () => { + confirming = true; + button.classList.add('is-confirming'); + button.setAttribute('data-confirming', 'true'); + button.innerHTML = DELETE_CONFIRM_LABEL; + if (confirmTimer) window.clearTimeout(confirmTimer); + confirmTimer = window.setTimeout(() => { + confirming = false; + resetDeleteButton(button); + }, DELETE_CONFIRM_TIMEOUT_MS); + }; + + button.addEventListener('click', async (event) => { + event.preventDefault(); + if (inFlight) return; + + if (!confirming) { + startConfirm(); + return; + } + + if (confirmTimer) window.clearTimeout(confirmTimer); + inFlight = true; + setButtonLoading(button, true, DELETE_LOADING_LABEL); + + const toast = window.showToast + ? window.showToast('Deleting feed...', { type: 'info', loading: true, duration: 0 }) + : null; + + const feedId = button.getAttribute('data-feed-id') || ''; + const view = button.getAttribute('data-view') || getDeleteView(); + const row = button.closest('.feed-row'); + + try { + await deleteFeedRequest(feedId, view); + + if (toast && toast.update) { + toast.update('Feed deleted.', { type: 'success', loading: false, duration: 3200 }); + } else if (window.showToast) { + window.showToast('Feed deleted.', { type: 'success' }); + } + + animateRowRemoval(row, () => { + refreshFeedRowCache(); + }); + } catch (error) { + if (toast && toast.update) { + toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false }); + } else if (window.showToast) { + window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' }); + } + setButtonLoading(button, false); + confirming = false; + resetDeleteButton(button); + } finally { + inFlight = false; + if (!row) { + setButtonLoading(button, false); + confirming = false; + resetDeleteButton(button); + } + } + }); + + button.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && confirming && !inFlight) { + confirming = false; + if (confirmTimer) window.clearTimeout(confirmTimer); + resetDeleteButton(button); + } + }); + }); + } + + function updateFeedSelectionState() { + if (!FEED_CHECKBOXES.length) { + return; + } + + const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked); + + if (FEED_SELECTED_COUNT_EL) { + FEED_SELECTED_COUNT_EL.textContent = selected.length + ' selected'; + } + if (FEED_BULK_DELETE_BUTTON_EL) { + FEED_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0; + } + if (FEED_SELECT_ALL_EL) { + const visibleCheckboxes = FEED_CHECKBOXES.filter((checkbox) => !(checkbox.closest('tr')?.hidden)); + FEED_SELECT_ALL_EL.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked); + } + } + + function toggleAllFeeds(checked) { + FEED_CHECKBOXES.forEach((checkbox) => { + if (!checkbox.closest('tr')?.hidden) { + checkbox.checked = checked; + } + }) + updateFeedSelectionState(); + } + + function setVisibleFeedSelection(checked) { + FEED_CHECKBOXES.forEach((checkbox) => { + if (!checkbox.closest('tr')?.hidden) { + checkbox.checked = checked; + } + }) + updateFeedSelectionState(); + } + + function selectMatchingFeeds() { + setVisibleFeedSelection(true); + } + + function clearFeedSelection() { + FEED_CHECKBOXES.forEach((checkbox) => { + checkbox.checked = false; + }) + updateFeedSelectionState(); + } + + function filterFeedRows() { + const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim(); + FEED_ROWS.forEach((row) => { + const haystack = row.getAttribute('data-search') || ''; + row.hidden = !!query && !haystack.includes(query); + }); + updateFeedMatchCount(); + updateFeedSelectionState(); + } + + function confirmBulkFeedDelete() { + const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).length; + if (selected === 0) return false; + + const query = (document.getElementById('feed-search')?.value || '').trim(); + const extra = + selected >= 50 && !query + ? '\\n\\nThis is a large delete. Tip: use Search to narrow down spam first.' + : ''; + return confirm( + 'Delete ' + + selected + + ' selected feed(s)? This disables the feeds immediately. Stored emails are cleaned up best-effort and may take a while.' + + extra, + ); + } + + function setButtonLoading(buttonEl, loading, label) { + if (!buttonEl) return; + if (loading) { + if (!buttonEl.dataset.originalLabel) { + buttonEl.dataset.originalLabel = (buttonEl.textContent || '').trim(); + } + const text = label || 'Working...'; + buttonEl.classList.add('is-loading'); + buttonEl.disabled = true; + buttonEl.innerHTML = '' + text; + return; + } + + const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim(); + buttonEl.classList.remove('is-loading'); + buttonEl.innerHTML = original; + } + + function removeFeedRowsById(feedIds) { + const toRemove = new Set((feedIds || []).map((v) => String(v))); + if (toRemove.size === 0) return; + + FEED_ROWS.forEach((row) => { + const checkbox = row.querySelector('input.feed-select'); + const id = checkbox ? checkbox.value : ''; + if (toRemove.has(id)) { + row.remove(); + } + }); + + FEED_ROWS = Array.from(document.querySelectorAll('.feed-row')); + FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select')); + + if (FEED_TOTAL_COUNT_EL) { + FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length); + } + } + + function onBulkFeedDeleteSubmit(event) { + if (event && event.preventDefault) event.preventDefault(); + void bulkDeleteSelectedFeeds(); + return false; + } + + async function bulkDeleteSelectedFeeds() { + if (FEED_BULK_DELETE_IN_PROGRESS) return; + const selectedIds = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value); + if (selectedIds.length === 0) { + if (window.showToast) window.showToast('No feeds selected.', { type: 'info' }); + return; + } + if (!confirmBulkFeedDelete()) { + return; + } + + FEED_BULK_DELETE_IN_PROGRESS = true; + setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, true, 'Deleting...'); + + const toast = window.showToast + ? window.showToast('Deleting ' + selectedIds.length + ' feed(s)...', { type: 'info', loading: true, duration: 0 }) + : null; + + const batchSize = 10; + let deletedTotal = 0; + const failed = []; + + try { + for (let i = 0; i < selectedIds.length; i += batchSize) { + const batch = selectedIds.slice(i, i + batchSize); + const res = await fetch('/admin/feeds/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ feedIds: batch }), + }); + + let data = {}; + if (window.parseJsonResponseOrThrow) { + data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk feed delete failed' }); + } else { + data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Bulk feed delete failed (HTTP ' + res.status + ')'); + throw new Error(message); + } + } + + const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch; + const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : []; + const failureDetails = Array.isArray(data.failures) ? data.failures : []; + + removeFeedRowsById(deletedIds); + deletedTotal += deletedIds.length; + + if (toast && toast.update) { + const done = Math.min(i + batch.length, selectedIds.length); + toast.update('Deleting... (' + done + ' of ' + selectedIds.length + ')', { type: 'info' }); + } + + // Keep selection state consistent as rows disappear. + updateFeedMatchCount(); + updateFeedSelectionState(); + + // If a batch fails for some feeds, retry those one-by-one using the bulk-delete + // endpoint with a single id (keeps semantics consistent and avoids hiding active feeds). + if (failedIds.length > 0) { + if (toast && toast.update) { + toast.update('Retrying ' + failedIds.length + ' failed feed(s) one-by-one...', { type: 'info' }); + } + + const stillFailed = []; + for (let j = 0; j < failedIds.length; j++) { + const feedId = String(failedIds[j] || ''); + if (!feedId) continue; + try { + const retryRes = await fetch('/admin/feeds/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ feedIds: [feedId] }), + }); + + let retryData = {}; + if (window.parseJsonResponseOrThrow) { + retryData = await window.parseJsonResponseOrThrow(retryRes, { prefix: 'Retry delete failed' }); + } else { + retryData = await retryRes.json().catch(() => ({})); + if (!retryRes.ok) { + const message = retryData && retryData.error ? String(retryData.error) : ('Retry delete failed (HTTP ' + retryRes.status + ')'); + throw new Error(message); + } + } + + const retryDeleted = Array.isArray(retryData.deletedFeedIds) ? retryData.deletedFeedIds : []; + const retryFailed = Array.isArray(retryData.failedFeedIds) ? retryData.failedFeedIds : []; + + if (retryDeleted.includes(feedId)) { + removeFeedRowsById([feedId]); + deletedTotal += 1; + } else if (retryFailed.includes(feedId)) { + stillFailed.push(feedId); + } else { + stillFailed.push(feedId); + } + } catch (e) { + stillFailed.push(feedId); + } + + if (toast && toast.update) { + toast.update('Retrying... (' + (j + 1) + ' of ' + failedIds.length + ')', { type: 'info' }); + } + } + + // Replace failed ids from this batch with only the ones that still failed after retry. + if (stillFailed.length > 0) { + failed.push(...stillFailed); + if (window.showToast && failureDetails.length > 0) { + const first = failureDetails[0] && failureDetails[0].error ? String(failureDetails[0].error) : ''; + if (first) { + window.showToast('Some feeds failed to delete: ' + first, { type: 'error' }); + } + } + } + + updateFeedMatchCount(); + updateFeedSelectionState(); + } + } + + if (toast && toast.dismiss) toast.dismiss(); + const uniqueFailed = Array.from(new Set(failed.map((v) => String(v)).filter(Boolean))); + if (uniqueFailed.length > 0) { + if (window.showToast) { + window.showToast( + 'Deleted ' + deletedTotal + ' feed(s). ' + uniqueFailed.length + ' failed (still visible).', + { type: 'error' }, + ); + } + } else { + if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' feed(s).', { type: 'success' }); + } + } catch (error) { + if (toast && toast.dismiss) toast.dismiss(); + if (window.showToast) { + window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error' }); + } + } finally { + FEED_BULK_DELETE_IN_PROGRESS = false; + setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, false); + updateFeedSelectionState(); + } + } + + document.addEventListener('DOMContentLoaded', () => { + initFeedUI(); + }); +`; + +// ── 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( + +
+
+
+

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" && ( +
+

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 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 ( + + + + + + + + + ); + })} + +
+ + + +
+
+ +
+
+ +
+
+ +
+
+ Actions +
+
+ + + + {titleDisplay} + + {feed.description && ( +
+ {descDisplay} +
+ )} +
+ {feed.id} + + + + + +
+ + Edit + + + Emails + + +
+
+
+
+
+ ) : ( + <> +
+
+ + 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 ( +
  • +
    +

    + {titleDisplay} +

    + {feed.description && ( +

    + {descDisplay} +

    + )} +
    + +
    +
    + Email: +
    + + {emailAddress} + +
    + + +
    +
    +
    +
    + RSS Feed: +
    + + {rssUrl} + +
    + + +
    +
    +
    +
    + +
    + +
    + +
    +
    +
  • + ); + })} +
+ + )} +
+ +