diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 7e76cb3..6d2acff 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -3,19 +3,14 @@ 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, - FeedList, - FeedMetadata, - EmailMetadata, - EmailData, - FeedListItem, -} from "../types"; -import { generateFeedId } from "../utils/id-generator"; -import { designSystem } from "../styles/index"; -import { interactiveScripts } from "../scripts/index"; +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 }; @@ -34,35 +29,6 @@ const app = new Hono(); export default app; const ADMIN_COOKIE_NAME = "admin_auth"; -const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week - -function waitUntilSafe(c: Context, promise: Promise) { - // Hono throws when ExecutionContext isn't present (ex: Node unit tests). - try { - c.executionCtx.waitUntil(promise); - } catch { - // ignore - } -} - -function normalizeAllowedSenders(senders: string[]): string[] { - return senders.map((s) => s.trim().toLowerCase()).filter(Boolean); -} - -function parseAllowedSenders(rawAllowedSenders: string): string[] { - return normalizeAllowedSenders(rawAllowedSenders.split(/[\n,]+/)); -} - -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) => { @@ -147,15 +113,7 @@ app.use("*", (c, next) => { return csrfMiddleware(c, next); }); -// 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 +// Schema for feed API updates (title/description only) const updateFeedSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional(), @@ -168,29 +126,6 @@ const authSchema = z.object({ password: z.string().min(1, "Password is required"), }); -// Base HTML layout with design system -// eslint-disable-next-line @typescript-eslint/no-explicit-any -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"); @@ -281,7 +216,7 @@ app.post("/login", async (c) => { // Incorrect password - redirect back to login with an error message return c.redirect("/admin/login?error=invalid"); } catch (error) { - console.error("Login error:", error); + logger.error("Login error", { error: String(error) }); return c.redirect("/admin/login?error=invalid"); } }); @@ -1616,2159 +1551,9 @@ app.get("/", async (c) => { ); }); -// Create a new feed -app.post("/feeds/create", async (c) => { - // Type assertion for environment variables - const env = c.env; - const emailStorage = env.EMAIL_STORAGE; - const isJson = - c.req.header("Content-Type")?.includes("application/json") ?? false; - - try { - let title: string; - let description: string | undefined; - let language: string; - let view: string; - let allowedSenders: string[]; - - if (isJson) { - const body = await c.req.json>(); - title = String(body.title ?? ""); - description = - body.description != null ? String(body.description) : undefined; - language = String(body.language ?? "en"); - view = "list"; - allowedSenders = Array.isArray(body.allowedSenders) - ? normalizeAllowedSenders( - (body.allowedSenders as unknown[]).map(String), - ) - : []; - } else { - const formData = await c.req.formData(); - title = formData.get("title")?.toString() || ""; - description = formData.get("description")?.toString(); - language = formData.get("language")?.toString() || "en"; - view = formData.get("view")?.toString() === "table" ? "table" : "list"; - 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: [], - }; - - await Promise.all([ - emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)), - emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)), - ]); - - await addFeedToList( - emailStorage, - feedId, - parsedData.title, - parsedData.description, - ); - - if (isJson) { - return c.json({ - feedId, - email: `${feedId}@${env.DOMAIN}`, - feedUrl: feedConfig.feed_url, - }); - } - - // Redirect back to admin page - return c.redirect(`/admin?view=${view}`); - } catch (error) { - console.error("Error creating feed:", error); - if (isJson) { - return c.json({ error: "Error creating feed." }, 400); - } - 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; - 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; - 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 deleteKeysWithConcurrency( - emailStorage: KVNamespace, - keys: string[], - concurrency: number, -): Promise<{ ok: string[]; failed: string[] }> { - const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); - const ok: string[] = []; - const failed: string[] = []; - const limit = Math.max(1, Math.floor(concurrency) || 1); - - for (let i = 0; i < uniqueKeys.length; i += limit) { - const batch = uniqueKeys.slice(i, i + limit); - const results = await Promise.allSettled( - batch.map((key) => emailStorage.delete(key)), - ); - results.forEach((result, idx) => { - const key = batch[idx]; - if (result.status === "fulfilled") { - ok.push(key); - } else { - failed.push(key); - } - }); - } - - return { ok, failed }; -} - -async function deleteFeedFast( - emailStorage: KVNamespace, - feedId: string, -): Promise { - const result = await deleteFeedFastDetailed(emailStorage, feedId); - return result.ok; -} - -type DeleteFeedFastResult = { - // "ok" means the feed is deactivated (config deleted). Metadata is best-effort. - ok: boolean; - configDeleted: boolean; - metadataDeleted: boolean; - errors: string[]; -}; - -async function deleteFeedFastDetailed( - emailStorage: KVNamespace, - feedId: string, -): Promise { - const feedConfigKey = `feed:${feedId}:config`; - const feedMetadataKey = `feed:${feedId}:metadata`; - - const errors: string[] = []; - let configDeleted = false; - let metadataDeleted = false; - - try { - await emailStorage.delete(feedConfigKey); - configDeleted = true; - } catch (error) { - errors.push(`config delete failed: ${String(error)}`); - } - - // Best-effort: if config is gone the feed is effectively disabled. - try { - await emailStorage.delete(feedMetadataKey); - metadataDeleted = true; - } catch (error) { - errors.push(`metadata delete failed: ${String(error)}`); - } - - return { ok: configDeleted, configDeleted, metadataDeleted, errors }; -} - -async function purgeFeedKeysStep( - emailStorage: KVNamespace, - feedId: string, - options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {}, -): Promise<{ - deletedKeys: string[]; - failedKeys: string[]; - cursor: string; - listComplete: boolean; -}> { - const prefix = `feed:${feedId}:`; - const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100))); - const cursor = options.cursor || undefined; - - const listed = await emailStorage.list({ prefix, cursor, limit }); - const keys = (listed.keys || []).map((k) => k.name); - - // Collect R2 attachment IDs from email entries before deleting - if (options.bucket && keys.length > 0) { - const emailKeys = keys.filter((k) => { - const suffix = k.slice(prefix.length); - return suffix !== "config" && suffix !== "metadata"; - }); - if (emailKeys.length > 0) { - const emailDataResults = await Promise.allSettled( - emailKeys.map( - (k) => - emailStorage.get(k, { type: "json" }) as Promise, - ), - ); - const attachmentIds = emailDataResults - .filter( - (r): r is PromiseFulfilledResult => - r.status === "fulfilled", - ) - .flatMap((r) => r.value?.attachments?.map((a) => a.id) ?? []); - if (attachmentIds.length > 0) { - await Promise.allSettled( - attachmentIds.map((id) => options.bucket!.delete(id)), - ); - } - } - } - - const { ok, failed } = await deleteKeysWithConcurrency( - emailStorage, - keys, - 35, - ); - - return { - deletedKeys: ok, - failedKeys: failed, - cursor: listed.cursor || "", - listComplete: !!listed.list_complete, - }; -} - -// Delete feed -app.post("/feeds/:feedId/delete", async (c) => { - const env = c.env; - const emailStorage = env.EMAIL_STORAGE; - const feedId = c.req.param("feedId"); - const view = c.req.query("view") === "table" ? "table" : "list"; - const wantsJson = (c.req.header("Accept") || "").includes("application/json"); - - try { - await deleteFeedFast(emailStorage, feedId); - await removeFeedFromList(emailStorage, feedId); - - // Best-effort cleanup in the background so the request stays fast. - // Use the UI purge endpoint for full, user-visible progress. - waitUntilSafe( - c, - purgeFeedKeysStep(emailStorage, feedId, { - bucket: env.ATTACHMENT_BUCKET, - }), - ); - if (wantsJson) { - return c.json({ ok: true, feedId }); - } - return c.redirect(`/admin?view=${view}`); - } catch (error) { - console.error("Error deleting feed:", error); - if (wantsJson) { - return c.json( - { ok: false, error: "Error deleting feed. Please try again." }, - 400, - ); - } - return c.text("Error deleting feed. Please try again.", 400); - } -}); - -// Purge all keys for a feed in small steps (used by the admin UI after deleting feeds). -app.post("/feeds/:feedId/purge", async (c) => { - const env = c.env; - const emailStorage = env.EMAIL_STORAGE; - const feedId = c.req.param("feedId"); - - try { - const body = (await c.req.json().catch(() => null)) as { - cursor?: unknown; - limit?: unknown; - } | null; - - const cursor = body?.cursor ? String(body.cursor) : undefined; - // Keep purge requests small to avoid Cloudflare per-request limits. - const limit = Number.isFinite(Number(body?.limit)) - ? Number(body?.limit) - : 100; - - const step = await purgeFeedKeysStep(emailStorage, feedId, { - cursor, - limit, - bucket: env.ATTACHMENT_BUCKET, - }); - - return c.json({ - ok: step.failedKeys.length === 0, - deletedCount: step.deletedKeys.length, - failedCount: step.failedKeys.length, - cursor: step.cursor, - listComplete: step.listComplete, - }); - } catch (error) { - console.error("Error purging feed keys:", error); - return c.json({ ok: false, error: "Error purging feed keys" }, 500); - } -}); - -// Bulk delete feeds selected in the dashboard -app.post("/feeds/bulk-delete", async (c) => { - const env = c.env; - const emailStorage = env.EMAIL_STORAGE; - const contentType = c.req.header("Content-Type") || ""; - const wantsJson = - contentType.includes("application/json") || - (c.req.header("Accept") || "").includes("application/json"); - - try { - if (wantsJson) { - const body = (await c.req.json().catch(() => null)) as { - feedIds?: unknown; - } | null; - - const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; - const parsedFeedIds = Array.from( - new Set(rawIds.map((value) => String(value)).filter(Boolean)), - ); - - if (parsedFeedIds.length === 0) { - return c.json({ ok: false, error: "No feeds were selected." }, 400); - } - - // The UI batches requests; cap to avoid accidental huge deletes in one request. - if (parsedFeedIds.length > 50) { - return c.json( - { - ok: false, - error: - "Too many feedIds for a single request. Please delete in smaller batches.", - }, - 413, - ); - } - - const okIds: string[] = []; - const failures: Array<{ feedId: string; error: string }> = []; - const warnings: Array<{ feedId: string; warning: string }> = []; - - // Keep this request intentionally small/cheap (the UI already batches calls). - for (const feedId of parsedFeedIds) { - try { - const result = await deleteFeedFastDetailed(emailStorage, feedId); - if (!result.ok) { - failures.push({ - feedId, - error: - result.errors.join("; ") || - "Failed to delete feed config (feed may still be active).", - }); - continue; - } - - if (!result.metadataDeleted) { - warnings.push({ - feedId, - warning: - "Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.", - }); - } - - okIds.push(feedId); - } catch (error) { - console.error("Error bulk deleting feed:", feedId, error); - failures.push({ feedId, error: String(error) }); - } - } - - const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); - - // If config deletion succeeded but list removal didn't, surface it explicitly. - const removed = new Set(deletedFeedIds); - okIds.forEach((feedId) => { - if (!removed.has(feedId)) { - failures.push({ - feedId, - error: - "Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.", - }); - } - }); - - const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId))); - - return c.json({ - ok: failedFeedIds.length === 0, - deletedFeedIds, - failedFeedIds, - failures, - warnings, - }); - } - - 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 parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean))); - - if (parsedFeedIds.length === 0) { - return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); - } - - const okIds: string[] = []; - - for (const feedId of parsedFeedIds) { - try { - const result = await deleteFeedFastDetailed(emailStorage, feedId); - if (result.ok) okIds.push(feedId); - } catch (error) { - console.error("Error bulk deleting feed:", feedId, error); - } - } - - const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); - - return c.redirect( - `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, - ); - } catch (error) { - console.error("Error bulk deleting feeds:", error); - return wantsJson - ? c.json( - { - ok: false, - error: - "Server error while deleting feeds. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", - }, - 500, - ) - : c.text("Error bulk deleting feeds. Please try again.", 500); - } -}); - -// View all emails for a feed -app.get("/feeds/:feedId/emails", async (c) => { - // Type assertion for environment variables - const env = c.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` -
-
-
- - Showing ${feedMetadata.emails.length} - 0 selected - - - -
-
- -
- - - - - - - - - - - - - - - - - ${feedMetadata.emails.map((email: EmailMetadata) => { - const subjectDisplay = clampText(email.subject, 180); - const subjectHover = clampText(email.subject, 1000); - const sortSubject = subjectHover.toLowerCase(); - const sortReceivedAt = String(email.receivedAt); - const searchHaystack = clampText( - email.subject, - 320, - ).toLowerCase(); - - return html` - - - - - - - `; - })} - -
- - - -
-
- -
-
- 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; - 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; - const emailStorage = env.EMAIL_STORAGE; - const emailKey = c.req.param("emailKey"); - const wantsJson = (c.req.header("Accept") || "").includes("application/json"); - - try { - // Get feedId from query parameters instead of form data - const feedId = c.req.query("feedId"); - - if (!feedId) { - if (wantsJson) { - return c.json({ ok: false, error: "Feed ID is required" }, 400); - } - return c.text("Feed ID is required", 400); - } - - // Load metadata first to collect attachment IDs for R2 cleanup - const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, { - type: "json", - })) as FeedMetadata | null; - - const attachmentIds = - feedMetadata?.emails.find((e) => e.key === emailKey)?.attachmentIds ?? []; - - // Delete the email - await emailStorage.delete(emailKey); - - 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)); - } - - // Best-effort R2 attachment cleanup - if (env.ATTACHMENT_BUCKET && attachmentIds.length > 0) { - await Promise.allSettled( - attachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)), - ); - } - - if (wantsJson) { - return c.json({ ok: true, emailKey, feedId }); - } - // Redirect back to the feed emails page - return c.redirect(`/admin/feeds/${feedId}/emails`); - } catch (error) { - console.error("Error deleting email:", error); - if (wantsJson) { - return c.json( - { ok: false, error: "Error deleting email. Please try again." }, - 400, - ); - } - 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; - const emailStorage = env.EMAIL_STORAGE; - const feedId = c.req.param("feedId"); - const contentType = c.req.header("Content-Type") || ""; - const wantsJson = - contentType.includes("application/json") || - (c.req.header("Accept") || "").includes("application/json"); - - try { - const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, { - type: "json", - })) as FeedMetadata | null; - if (!feedMetadata) { - return wantsJson - ? c.json({ ok: false, error: "Feed not found" }, 404) - : c.text("Feed not found", 404); - } - - const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); - - if (wantsJson) { - const body = (await c.req.json().catch(() => null)) as { - emailKeys?: unknown; - } | null; - - const rawEmailKeys = Array.isArray(body?.emailKeys) - ? body?.emailKeys - : []; - const emailKeys = Array.from( - new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)), - ); - - if (emailKeys.length === 0) { - return c.json({ ok: false, error: "No emails were selected." }, 400); - } - - // The UI batches requests; cap to avoid accidental huge deletes in one request. - if (emailKeys.length > 250) { - return c.json( - { - ok: false, - error: - "Too many emailKeys for a single request. Please delete in smaller batches.", - }, - 413, - ); - } - - const candidates = emailKeys.filter((key) => allowedKeys.has(key)); - - // Collect attachment IDs from metadata before deleting (no extra KV reads needed) - const candidateSet = new Set(candidates); - const r2AttachmentIds = feedMetadata.emails - .filter((e) => candidateSet.has(e.key)) - .flatMap((e) => e.attachmentIds ?? []); - - const { ok: deletedOk, failed: failedEmailKeys } = - await deleteKeysWithConcurrency(emailStorage, candidates, 35); - - const deletedSet = new Set(deletedOk); - feedMetadata.emails = feedMetadata.emails.filter( - (email) => !deletedSet.has(email.key), - ); - await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); - - // Best-effort R2 attachment cleanup - if (env.ATTACHMENT_BUCKET && r2AttachmentIds.length > 0) { - await Promise.allSettled( - r2AttachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)), - ); - } - - return c.json({ - ok: failedEmailKeys.length === 0, - deletedEmailKeys: deletedOk, - failedEmailKeys, - }); - } - - 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 candidates = emailKeys.filter((key) => allowedKeys.has(key)); - const { ok: deletedOk } = await deleteKeysWithConcurrency( - emailStorage, - candidates, - 35, - ); - - const deletedSet = new Set(deletedOk); - feedMetadata.emails = feedMetadata.emails.filter( - (email) => !deletedSet.has(email.key), - ); - await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); - - return c.redirect( - `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`, - ); - } catch (error) { - console.error("Error bulk deleting emails:", error); - return wantsJson - ? c.json( - { - ok: false, - error: - "Server error while deleting emails. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", - }, - 500, - ) - : c.text("Error bulk deleting emails. Please try again.", 500); - } -}); - -// 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); - } -} - -async function removeFeedsFromListBulk( - emailStorage: KVNamespace, - feedIds: string[], -): Promise { - try { - const feedListKey = "feeds:list"; - const feedList = ((await emailStorage.get(feedListKey, { - type: "json", - })) as FeedList | null) || { feeds: [] }; - - const toRemove = new Set(feedIds.filter(Boolean)); - if (toRemove.size === 0) { - return []; - } - - const removed: string[] = []; - const nextFeeds: FeedListItem[] = []; - - for (const feed of feedList.feeds) { - if (toRemove.has(feed.id)) { - removed.push(feed.id); - continue; - } - nextFeeds.push(feed); - } - - if (removed.length === 0) { - return []; - } - - feedList.feeds = nextFeeds; - - // Store updated list - await emailStorage.put(feedListKey, JSON.stringify(feedList)); - return removed; - } catch (error) { - console.error("Error removing feed from list:", error); - return []; - } -} - -// Helper function to remove a feed from the list of all feeds -async function removeFeedFromList( - emailStorage: KVNamespace, - feedId: string, -): Promise { - const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); - return removed.includes(feedId); -} +// Mount sub-routers +app.route("/feeds", feedsRouter); +app.route("/", emailsRouter); // Update feed via API (for in-place editing) app.post( @@ -3823,7 +1608,7 @@ app.post( // Return success response return c.json({ success: true }); } catch (error) { - console.error("Error updating feed via API:", error); + logger.error("Error updating feed via API", { error: String(error) }); return c.json({ error: "Error updating feed" }, 400); } }, diff --git a/src/routes/admin/emails.ts b/src/routes/admin/emails.ts new file mode 100644 index 0000000..1d06084 --- /dev/null +++ b/src/routes/admin/emails.ts @@ -0,0 +1,1144 @@ +import { Hono } from "hono"; +import { html, raw } from "hono/html"; +import { + Env, + FeedConfig, + FeedMetadata, + EmailData, + EmailMetadata, +} from "../../types"; +import { logger } from "../../lib/logger"; +import { layout, clampText } from "./ui"; +import { deleteKeysWithConcurrency } from "./helpers"; + +type AppEnv = { Bindings: Env }; + +export const emailsRouter = new Hono(); + +// ── View all emails for a feed ──────────────────────────────────────────────── + +emailsRouter.get("/feeds/:feedId/emails", async (c) => { + const env = c.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"); + + const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, { + type: "json", + })) as FeedConfig | null; + const feedMetadata = (await emailStorage.get(`feed:${feedId}:metadata`, { + 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` +
+
+
+ + Showing ${feedMetadata.emails.length} + 0 selected + + + +
+
+ +
+ + + + + + + + + + + + + + + + + ${feedMetadata.emails.map((email: EmailMetadata) => { + const subjectDisplay = clampText(email.subject, 180); + const subjectHover = clampText(email.subject, 1000); + const sortSubject = subjectHover.toLowerCase(); + const sortReceivedAt = String(email.receivedAt); + const searchHaystack = clampText( + email.subject, + 320, + ).toLowerCase(); + return html` + + + + + + + `; + })} + +
+ + + +
+
+ +
+
+ Actions +
+
+
+
+ ` + : html`
+

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

+
`} +
+ + + `, + ), + ); +}); + +// ── View single email ───────────────────────────────────────────────────────── + +emailsRouter.get("/emails/:emailKey", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const emailKey = c.req.param("emailKey"); + + const emailData = (await emailStorage.get(emailKey, { + type: "json", + })) as EmailData | null; + + if (!emailData) return c.text("Email not found", 404); + + const feedId = emailKey.split(":")[1]; + + const htmlContent = `${emailData.content}`; + + const encodedHtmlContent = (() => { + const encoder = new TextEncoder(); + const bytes = encoder.encode(htmlContent); + return btoa(String.fromCharCode(...new Uint8Array(bytes))); + })(); + + return c.html( + layout( + `Email: ${emailData.subject}`, + html` +
+
+

Email Content

+ +
+ +
+ + +
+ + +
+ + +
+
+ + + `, + ), + ); +}); + +// ── Delete single email ─────────────────────────────────────────────────────── + +emailsRouter.post("/emails/:emailKey/delete", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const emailKey = c.req.param("emailKey"); + const wantsJson = (c.req.header("Accept") || "").includes("application/json"); + + try { + const feedId = c.req.query("feedId"); + if (!feedId) { + if (wantsJson) + return c.json({ ok: false, error: "Feed ID is required" }, 400); + return c.text("Feed ID is required", 400); + } + + const feedMetadataKey = `feed:${feedId}:metadata`; + const feedMetadata = (await emailStorage.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; + const attachmentIds = + feedMetadata?.emails.find((e) => e.key === emailKey)?.attachmentIds ?? []; + + await emailStorage.delete(emailKey); + + if (feedMetadata) { + feedMetadata.emails = feedMetadata.emails.filter( + (email) => email.key !== emailKey, + ); + await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); + } + + if (env.ATTACHMENT_BUCKET && attachmentIds.length > 0) { + await Promise.allSettled( + attachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)), + ); + } + + if (wantsJson) return c.json({ ok: true, emailKey, feedId }); + return c.redirect(`/admin/feeds/${feedId}/emails`); + } catch (error) { + logger.error("Error deleting email", { emailKey, error: String(error) }); + if (wantsJson) + return c.json( + { ok: false, error: "Error deleting email. Please try again." }, + 400, + ); + return c.text("Error deleting email. Please try again.", 400); + } +}); + +// ── Bulk delete emails ──────────────────────────────────────────────────────── + +emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const feedId = c.req.param("feedId"); + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); + + try { + const feedMetadataKey = `feed:${feedId}:metadata`; + const feedMetadata = (await emailStorage.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; + + if (!feedMetadata) { + return wantsJson + ? c.json({ ok: false, error: "Feed not found" }, 404) + : c.text("Feed not found", 404); + } + + const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); + + if (wantsJson) { + const body = (await c.req.json().catch(() => null)) as { + emailKeys?: unknown; + } | null; + const rawEmailKeys = Array.isArray(body?.emailKeys) + ? body?.emailKeys + : []; + const emailKeys = Array.from( + new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)), + ); + + if (emailKeys.length === 0) + return c.json({ ok: false, error: "No emails were selected." }, 400); + if (emailKeys.length > 250) { + return c.json( + { + ok: false, + error: + "Too many emailKeys for a single request. Please delete in smaller batches.", + }, + 413, + ); + } + + const candidates = emailKeys.filter((key) => allowedKeys.has(key)); + const candidateSet = new Set(candidates); + const r2AttachmentIds = feedMetadata.emails + .filter((e) => candidateSet.has(e.key)) + .flatMap((e) => e.attachmentIds ?? []); + + const { ok: deletedOk, failed: failedEmailKeys } = + await deleteKeysWithConcurrency(emailStorage, candidates, 35); + + const deletedSet = new Set(deletedOk); + feedMetadata.emails = feedMetadata.emails.filter( + (email) => !deletedSet.has(email.key), + ); + await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); + + if (env.ATTACHMENT_BUCKET && r2AttachmentIds.length > 0) { + await Promise.allSettled( + r2AttachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)), + ); + } + + return c.json({ + ok: failedEmailKeys.length === 0, + deletedEmailKeys: deletedOk, + failedEmailKeys, + }); + } + + 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 candidates = emailKeys.filter((key) => allowedKeys.has(key)); + const { ok: deletedOk } = await deleteKeysWithConcurrency( + emailStorage, + candidates, + 35, + ); + + const deletedSet = new Set(deletedOk); + feedMetadata.emails = feedMetadata.emails.filter( + (email) => !deletedSet.has(email.key), + ); + await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); + + return c.redirect( + `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`, + ); + } catch (error) { + logger.error("Error bulk deleting emails", { + feedId, + error: String(error), + }); + return wantsJson + ? c.json( + { + ok: false, + error: + "Server error while deleting emails. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", + }, + 500, + ) + : c.text("Error bulk deleting emails. Please try again.", 500); + } +}); diff --git a/src/routes/admin/feeds.ts b/src/routes/admin/feeds.ts new file mode 100644 index 0000000..5ea2e69 --- /dev/null +++ b/src/routes/admin/feeds.ts @@ -0,0 +1,575 @@ +import { Hono } from "hono"; +import { html } from "hono/html"; +import { z } from "zod"; +import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types"; +import { generateFeedId } from "../../utils/id-generator"; +import { waitUntilSafe } from "../../utils/worker"; +import { logger } from "../../lib/logger"; +import { layout } from "./ui"; +import { + addFeedToList, + updateFeedInList, + removeFeedFromList, + removeFeedsFromListBulk, + deleteKeysWithConcurrency, +} from "./helpers"; + +type AppEnv = { Bindings: Env }; + +export const feedsRouter = new Hono(); + +function normalizeAllowedSenders(senders: string[]): string[] { + return senders.map((s) => s.trim().toLowerCase()).filter(Boolean); +} + +function parseAllowedSenders(rawAllowedSenders: string): string[] { + return normalizeAllowedSenders(rawAllowedSenders.split(/[\n,]+/)); +} + +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([]), +}); + +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([]), +}); + +// ── Delete helpers ──────────────────────────────────────────────────────────── + +type DeleteFeedFastResult = { + ok: boolean; + configDeleted: boolean; + metadataDeleted: boolean; + errors: string[]; +}; + +async function deleteFeedFastDetailed( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const feedConfigKey = `feed:${feedId}:config`; + const feedMetadataKey = `feed:${feedId}:metadata`; + + const errors: string[] = []; + let configDeleted = false; + let metadataDeleted = false; + + try { + await emailStorage.delete(feedConfigKey); + configDeleted = true; + } catch (error) { + errors.push(`config delete failed: ${String(error)}`); + } + + try { + await emailStorage.delete(feedMetadataKey); + metadataDeleted = true; + } catch (error) { + errors.push(`metadata delete failed: ${String(error)}`); + } + + return { ok: configDeleted, configDeleted, metadataDeleted, errors }; +} + +async function deleteFeedFast( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const result = await deleteFeedFastDetailed(emailStorage, feedId); + return result.ok; +} + +async function purgeFeedKeysStep( + emailStorage: KVNamespace, + feedId: string, + options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {}, +): Promise<{ + deletedKeys: string[]; + failedKeys: string[]; + cursor: string; + listComplete: boolean; +}> { + const prefix = `feed:${feedId}:`; + const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100))); + const cursor = options.cursor || undefined; + + const listed = await emailStorage.list({ prefix, cursor, limit }); + const keys = (listed.keys || []).map((k) => k.name); + + if (options.bucket && keys.length > 0) { + const emailKeys = keys.filter((k) => { + const suffix = k.slice(prefix.length); + return suffix !== "config" && suffix !== "metadata"; + }); + if (emailKeys.length > 0) { + const emailDataResults = await Promise.allSettled( + emailKeys.map( + (k) => + emailStorage.get(k, { type: "json" }) as Promise, + ), + ); + const attachmentIds = emailDataResults + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled", + ) + .flatMap((r) => r.value?.attachments?.map((a) => a.id) ?? []); + if (attachmentIds.length > 0) { + await Promise.allSettled( + attachmentIds.map((id) => options.bucket!.delete(id)), + ); + } + } + } + + const { ok, failed } = await deleteKeysWithConcurrency( + emailStorage, + keys, + 35, + ); + + return { + deletedKeys: ok, + failedKeys: failed, + cursor: listed.cursor || "", + listComplete: !!listed.list_complete, + }; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +feedsRouter.post("/create", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const isJson = + c.req.header("Content-Type")?.includes("application/json") ?? false; + + try { + let title: string; + let description: string | undefined; + let language: string; + let view: string; + let allowedSenders: string[]; + + if (isJson) { + const body = await c.req.json>(); + title = String(body.title ?? ""); + description = + body.description != null ? String(body.description) : undefined; + language = String(body.language ?? "en"); + view = "list"; + allowedSenders = Array.isArray(body.allowedSenders) + ? normalizeAllowedSenders( + (body.allowedSenders as unknown[]).map(String), + ) + : []; + } else { + const formData = await c.req.formData(); + title = formData.get("title")?.toString() || ""; + description = formData.get("description")?.toString(); + language = formData.get("language")?.toString() || "en"; + view = formData.get("view")?.toString() === "table" ? "table" : "list"; + allowedSenders = parseAllowedSenders( + formData.get("allowed_senders")?.toString() || "", + ); + } + + const parsedData = createFeedSchema.parse({ + title, + description, + language, + allowedSenders, + }); + + const feedId = generateFeedId(); + + 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(), + }; + + const feedMetadata: FeedMetadata = { emails: [] }; + + await Promise.all([ + emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)), + emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)), + ]); + + await addFeedToList( + emailStorage, + feedId, + parsedData.title, + parsedData.description, + ); + + if (isJson) { + return c.json({ + feedId, + email: `${feedId}@${env.DOMAIN}`, + feedUrl: feedConfig.feed_url, + }); + } + + return c.redirect(`/admin?view=${view}`); + } catch (error) { + logger.error("Error creating feed", { error: String(error) }); + if (c.req.header("Content-Type")?.includes("application/json")) { + return c.json({ error: "Error creating feed." }, 400); + } + return c.text("Error creating feed. Please try again.", 400); + } +}); + +feedsRouter.get("/:feedId/edit", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const feedId = c.req.param("feedId"); + + const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, { + 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. +
+ + + + +
+
+
+ `, + ), + ); +}); + +feedsRouter.post("/:feedId/edit", async (c) => { + const env = c.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() || "", + ); + + const parsedData = updateFeedSchema.parse({ + title, + description, + language, + allowedSenders, + }); + + 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); + } + + await emailStorage.put( + feedConfigKey, + JSON.stringify({ + ...existingConfig, + title: parsedData.title, + description: parsedData.description, + language: parsedData.language, + allowed_senders: parsedData.allowedSenders, + updated_at: Date.now(), + }), + ); + + await updateFeedInList( + emailStorage, + feedId, + parsedData.title, + parsedData.description, + ); + + return c.redirect("/admin"); + } catch (error) { + logger.error("Error updating feed", { feedId, error: String(error) }); + return c.text("Error updating feed. Please try again.", 400); + } +}); + +feedsRouter.post("/:feedId/delete", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const feedId = c.req.param("feedId"); + const view = c.req.query("view") === "table" ? "table" : "list"; + const wantsJson = (c.req.header("Accept") || "").includes("application/json"); + + try { + await deleteFeedFast(emailStorage, feedId); + await removeFeedFromList(emailStorage, feedId); + + waitUntilSafe( + c, + purgeFeedKeysStep(emailStorage, feedId, { + bucket: env.ATTACHMENT_BUCKET, + }), + ); + + if (wantsJson) { + return c.json({ ok: true, feedId }); + } + return c.redirect(`/admin?view=${view}`); + } catch (error) { + logger.error("Error deleting feed", { feedId, error: String(error) }); + if (wantsJson) { + return c.json( + { ok: false, error: "Error deleting feed. Please try again." }, + 400, + ); + } + return c.text("Error deleting feed. Please try again.", 400); + } +}); + +feedsRouter.post("/:feedId/purge", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const feedId = c.req.param("feedId"); + + try { + const body = (await c.req.json().catch(() => null)) as { + cursor?: unknown; + limit?: unknown; + } | null; + + const cursor = body?.cursor ? String(body.cursor) : undefined; + const limit = Number.isFinite(Number(body?.limit)) + ? Number(body?.limit) + : 100; + + const step = await purgeFeedKeysStep(emailStorage, feedId, { + cursor, + limit, + bucket: env.ATTACHMENT_BUCKET, + }); + + return c.json({ + ok: step.failedKeys.length === 0, + deletedCount: step.deletedKeys.length, + failedCount: step.failedKeys.length, + cursor: step.cursor, + listComplete: step.listComplete, + }); + } catch (error) { + logger.error("Error purging feed keys", { feedId, error: String(error) }); + return c.json({ ok: false, error: "Error purging feed keys" }, 500); + } +}); + +feedsRouter.post("/bulk-delete", async (c) => { + const env = c.env; + const emailStorage = env.EMAIL_STORAGE; + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); + + try { + if (wantsJson) { + const body = (await c.req.json().catch(() => null)) as { + feedIds?: unknown; + } | null; + + const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; + const parsedFeedIds = Array.from( + new Set(rawIds.map((value) => String(value)).filter(Boolean)), + ); + + if (parsedFeedIds.length === 0) { + return c.json({ ok: false, error: "No feeds were selected." }, 400); + } + + if (parsedFeedIds.length > 50) { + return c.json( + { + ok: false, + error: + "Too many feedIds for a single request. Please delete in smaller batches.", + }, + 413, + ); + } + + const okIds: string[] = []; + const failures: Array<{ feedId: string; error: string }> = []; + const warnings: Array<{ feedId: string; warning: string }> = []; + + for (const feedId of parsedFeedIds) { + try { + const result = await deleteFeedFastDetailed(emailStorage, feedId); + if (!result.ok) { + failures.push({ + feedId, + error: + result.errors.join("; ") || + "Failed to delete feed config (feed may still be active).", + }); + continue; + } + + if (!result.metadataDeleted) { + warnings.push({ + feedId, + warning: + "Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.", + }); + } + + okIds.push(feedId); + } catch (error) { + logger.error("Error bulk deleting feed", { + feedId, + error: String(error), + }); + failures.push({ feedId, error: String(error) }); + } + } + + const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + + const removed = new Set(deletedFeedIds); + okIds.forEach((feedId) => { + if (!removed.has(feedId)) { + failures.push({ + feedId, + error: + "Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.", + }); + } + }); + + const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId))); + + return c.json({ + ok: failedFeedIds.length === 0, + deletedFeedIds, + failedFeedIds, + failures, + warnings, + }); + } + + 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 parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean))); + + if (parsedFeedIds.length === 0) { + return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); + } + + const okIds: string[] = []; + + for (const feedId of parsedFeedIds) { + try { + const result = await deleteFeedFastDetailed(emailStorage, feedId); + if (result.ok) okIds.push(feedId); + } catch (error) { + logger.error("Error bulk deleting feed", { + feedId, + error: String(error), + }); + } + } + + const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + + return c.redirect( + `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, + ); + } catch (error) { + logger.error("Error bulk deleting feeds", { error: String(error) }); + return wantsJson + ? c.json( + { + ok: false, + error: + "Server error while deleting feeds. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.", + }, + 500, + ) + : c.text("Error bulk deleting feeds. Please try again.", 500); + } +}); diff --git a/src/routes/admin/helpers.ts b/src/routes/admin/helpers.ts new file mode 100644 index 0000000..b54188b --- /dev/null +++ b/src/routes/admin/helpers.ts @@ -0,0 +1,130 @@ +import { FeedList, FeedListItem } from "../../types"; +import { FEEDS_LIST_KEY } from "../../config/constants"; +import { logger } from "../../lib/logger"; + +export async function deleteKeysWithConcurrency( + emailStorage: KVNamespace, + keys: string[], + concurrency: number, +): Promise<{ ok: string[]; failed: string[] }> { + const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); + const ok: string[] = []; + const failed: string[] = []; + const limit = Math.max(1, Math.floor(concurrency) || 1); + + for (let i = 0; i < uniqueKeys.length; i += limit) { + const batch = uniqueKeys.slice(i, i + limit); + const results = await Promise.allSettled( + batch.map((key) => emailStorage.delete(key)), + ); + results.forEach((result, idx) => { + const key = batch[idx]; + if (result.status === "fulfilled") { + ok.push(key); + } else { + failed.push(key); + } + }); + } + + return { ok, failed }; +} + +export async function listAllFeeds( + emailStorage: KVNamespace, +): Promise { + try { + const feedList = (await emailStorage.get(FEEDS_LIST_KEY, { + type: "json", + })) as FeedList | null; + return feedList?.feeds || []; + } catch (error) { + logger.error("Error listing feeds", { error: String(error) }); + return []; + } +} + +export async function addFeedToList( + emailStorage: KVNamespace, + feedId: string, + title: string, + description?: string, +): Promise { + try { + const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { + type: "json", + })) as FeedList | null) || { feeds: [] }; + + feedList.feeds.push({ id: feedId, title, description }); + await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); + } catch (error) { + logger.error("Error adding feed to list", { feedId, error: String(error) }); + } +} + +export async function updateFeedInList( + emailStorage: KVNamespace, + feedId: string, + title: string, + description?: string, +): Promise { + try { + const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { + type: "json", + })) as FeedList | null) || { feeds: [] }; + + const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); + if (feedIndex !== -1) { + feedList.feeds[feedIndex].title = title; + feedList.feeds[feedIndex].description = description; + await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); + } + } catch (error) { + logger.error("Error updating feed in list", { + feedId, + error: String(error), + }); + } +} + +export async function removeFeedsFromListBulk( + emailStorage: KVNamespace, + feedIds: string[], +): Promise { + try { + const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { + type: "json", + })) as FeedList | null) || { feeds: [] }; + + const toRemove = new Set(feedIds.filter(Boolean)); + if (toRemove.size === 0) return []; + + const removed: string[] = []; + const nextFeeds: FeedListItem[] = []; + + for (const feed of feedList.feeds) { + if (toRemove.has(feed.id)) { + removed.push(feed.id); + continue; + } + nextFeeds.push(feed); + } + + if (removed.length === 0) return []; + + feedList.feeds = nextFeeds; + await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); + return removed; + } catch (error) { + logger.error("Error removing feeds from list", { error: String(error) }); + return []; + } +} + +export async function removeFeedFromList( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); + return removed.includes(feedId); +} diff --git a/src/routes/admin/ui.ts b/src/routes/admin/ui.ts new file mode 100644 index 0000000..fed431d --- /dev/null +++ b/src/routes/admin/ui.ts @@ -0,0 +1,36 @@ +import { html, raw } from "hono/html"; +import { designSystem } from "../../styles/index"; +import { interactiveScripts } from "../../scripts/index"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const layout = (title: string, content: any) => { + return html` + + + ${title} - Email to RSS Admin + + + + + + + + ${content} + + `; +}; + +export 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()}...`; +}