diff --git a/src/index.ts b/src/index.ts index 6f33ab0..cf0c7b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,11 @@ import { hubRouter } from "./routes/hub"; import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { Env } from "./types"; import { logger } from "./lib/logger"; +import { + listAllFeeds, + purgeExpiredFeeds, + removeFeedsFromListBulk, +} from "./routes/admin/helpers"; import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants"; type AppEnv = { Bindings: Env }; @@ -176,16 +181,18 @@ export default { await handleCloudflareEmail(message, env, ctx); }, async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { - let cursor: string | undefined; - let deleted = 0; - do { - const result = await env.EMAIL_STORAGE.list({ cursor }); - await Promise.all( - result.keys.map(({ name }) => env.EMAIL_STORAGE.delete(name)), - ); - deleted += result.keys.length; - cursor = result.list_complete ? undefined : result.cursor; - } while (cursor); - logger.info("Demo KV reset complete", { deleted }); + const feeds = await listAllFeeds(env.EMAIL_STORAGE); + const now = Date.now(); + const expiredIds = feeds + .filter((f) => f.expires_at !== undefined && f.expires_at <= now) + .map((f) => f.id); + + for (const feedId of expiredIds) { + await purgeExpiredFeeds(env.EMAIL_STORAGE, feedId, env.ATTACHMENT_BUCKET); + } + if (expiredIds.length > 0) { + await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds); + logger.info("Feed TTL cleanup", { deleted: expiredIds.length }); + } }, }; diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 43a28d9..e3b6e87 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -112,6 +112,16 @@ export async function validateEmail( response: new Response("Feed does not exist", { status: 404 }), }; } + if ( + feedConfig.expires_at !== undefined && + feedConfig.expires_at <= Date.now() + ) { + logger.warn("Rejected email: feed expired", { feedId }); + return { + ok: false, + response: new Response("Feed has expired", { status: 410 }), + }; + } const allowedSenders = (feedConfig.allowed_senders || []) .map(normalizeEmail) diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index cb55f9a..abcdd9c 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -279,6 +279,35 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => ( ); +function formatExpiry(expiresAt: number): { label: string; expired: boolean } { + const remaining = expiresAt - Date.now(); + if (remaining <= 0) { + const h = Math.floor(-remaining / 3_600_000); + return { + label: h > 0 ? `Expired ${h}h ago` : "Just expired", + expired: true, + }; + } + const h = Math.floor(remaining / 3_600_000); + if (h >= 48) { + return { label: `Expires in ${Math.floor(h / 24)}d`, expired: false }; + } + const m = Math.floor((remaining % 3_600_000) / 60_000); + return { + label: h > 0 ? `Expires in ${h}h ${m}m` : `Expires in ${m}m`, + expired: false, + }; +} + +const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => { + const { label, expired } = formatExpiry(expiresAt); + return ( + + {label} + + ); +}; + // Admin dashboard route app.get("/", async (c) => { // Type assertion for environment variables @@ -396,6 +425,29 @@ app.get("/", async (c) => { +
+ + + {env.FEED_TTL_HOURS ? ( + + Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server + configuration. + + ) : ( + Leave empty for no expiry. + )} +
+ @@ -489,6 +541,7 @@ app.get("/", async (c) => { + @@ -606,6 +659,14 @@ app.get("/", async (c) => { title="Resize" > + + Expires +
+ Actions
{ const descHover = clampText(feed.description || "", 1000); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); + const isExpired = + feed.expires_at !== undefined && + feed.expires_at <= Date.now(); return ( { + + {feed.expires_at ? ( + + ) : ( + + )} +
- - Edit - - - Emails - + {isExpired ? ( + <> + + Edit + + + Emails + + + ) : ( + <> + + Edit + + + Emails + + + )}
-
+ {isExpired && ( +
+

+ This feed has expired. It no longer accepts + emails and its content is no longer publicly accessible. +

+
+ +
+
+ )} + +
@@ -285,12 +280,18 @@ feedsRouter.get("/:feedId/edit", async (c) => { name="title" value={feedConfig.title} required + disabled={isExpired} />
-
@@ -304,6 +305,7 @@ feedsRouter.get("/:feedId/edit", async (c) => { name="allowed_senders" rows={3} placeholder={"newsletter@example.com\ntechmeme.com"} + disabled={isExpired} > {(feedConfig.allowed_senders || []).join("\n")} @@ -322,6 +324,7 @@ feedsRouter.get("/:feedId/edit", async (c) => { name="blocked_senders" rows={3} placeholder={"spam@example.com\nunwanted.com"} + disabled={isExpired} > {(feedConfig.blocked_senders || []).join("\n")} @@ -331,11 +334,37 @@ feedsRouter.get("/:feedId/edit", async (c) => {
+
+ + + {ttlLocked ? ( + + Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server + configuration. + + ) : ( + + Hours from now until this feed expires. Leave empty to keep + the current expiry (or no expiry). + + )} +
+ - + {!isExpired && ( + + )}
@@ -359,6 +388,7 @@ feedsRouter.post("/:feedId/edit", async (c) => { const blockedSenders = parseAllowedSenders( formData.get("blocked_senders")?.toString() || "", ); + const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString(); const parsedData = updateFeedSchema.parse({ title, @@ -377,24 +407,50 @@ feedsRouter.post("/:feedId/edit", async (c) => { 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, - blocked_senders: parsedData.blockedSenders, - updated_at: Date.now(), - }), - ); + // Expired feeds cannot be edited + if ( + existingConfig.expires_at !== undefined && + existingConfig.expires_at <= Date.now() + ) { + return c.text("Feed has expired and cannot be modified.", 403); + } + + // Resolve new expires_at: + // - FEED_TTL_HOURS set: always recompute from env (reset TTL from now) + // - Field submitted: set new expiry from now + // - Field empty: preserve existing expires_at (no silent removal) + let newExpiresAt: number | undefined; + if (env.FEED_TTL_HOURS) { + const h = parseInt(env.FEED_TTL_HOURS, 10); + newExpiresAt = + Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined; + } else if (lifetimeHoursRaw) { + const h = parseInt(lifetimeHoursRaw, 10); + newExpiresAt = + Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined; + } else { + newExpiresAt = existingConfig.expires_at; + } + + const updatedConfig: FeedConfig = { + ...existingConfig, + title: parsedData.title, + description: parsedData.description, + language: parsedData.language, + allowed_senders: parsedData.allowedSenders, + blocked_senders: parsedData.blockedSenders, + updated_at: Date.now(), + expires_at: newExpiresAt, + }; + + await emailStorage.put(feedConfigKey, JSON.stringify(updatedConfig)); await updateFeedInList( emailStorage, feedId, parsedData.title, parsedData.description, + newExpiresAt, ); return c.redirect("/admin"); diff --git a/src/routes/admin/helpers.ts b/src/routes/admin/helpers.ts index b54188b..19e4763 100644 --- a/src/routes/admin/helpers.ts +++ b/src/routes/admin/helpers.ts @@ -1,4 +1,4 @@ -import { FeedList, FeedListItem } from "../../types"; +import { EmailData, FeedList, FeedListItem } from "../../types"; import { FEEDS_LIST_KEY } from "../../config/constants"; import { logger } from "../../lib/logger"; @@ -49,13 +49,14 @@ export async function addFeedToList( feedId: string, title: string, description?: string, + expires_at?: number, ): Promise { try { const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { type: "json", })) as FeedList | null) || { feeds: [] }; - feedList.feeds.push({ id: feedId, title, description }); + feedList.feeds.push({ id: feedId, title, description, expires_at }); await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); } catch (error) { logger.error("Error adding feed to list", { feedId, error: String(error) }); @@ -67,6 +68,7 @@ export async function updateFeedInList( feedId: string, title: string, description?: string, + expires_at?: number, ): Promise { try { const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { @@ -77,6 +79,7 @@ export async function updateFeedInList( if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].description = description; + feedList.feeds[feedIndex].expires_at = expires_at; await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); } } catch (error) { @@ -128,3 +131,76 @@ export async function removeFeedFromList( const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); return removed.includes(feedId); } + +export 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, + }; +} + +export async function purgeExpiredFeeds( + emailStorage: KVNamespace, + feedId: string, + bucket?: R2Bucket, +): Promise { + let cursor: string | undefined; + do { + const step = await purgeFeedKeysStep(emailStorage, feedId, { + bucket, + limit: 100, + cursor, + }); + cursor = step.listComplete ? undefined : step.cursor; + } while (cursor); +} diff --git a/src/routes/atom.ts b/src/routes/atom.ts index 5bf318e..bb1bf52 100644 --- a/src/routes/atom.ts +++ b/src/routes/atom.ts @@ -15,6 +15,12 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { if (!feedData) { return new Response("Feed not found", { status: 404 }); } + if ( + feedData.feedConfig.expires_at !== undefined && + feedData.feedConfig.expires_at <= Date.now() + ) { + return new Response("Feed has expired", { status: 410 }); + } const base = baseUrl(c.env); const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`; diff --git a/src/routes/entries.ts b/src/routes/entries.ts index a8f7618..bb01189 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -1,6 +1,6 @@ import { Context } from "hono"; import { html, raw } from "hono/html"; -import { Env, FeedMetadata, EmailData } from "../types"; +import { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; import { processEmailContent } from "../utils/html-processor"; export async function handle(c: Context<{ Bindings: Env }>): Promise { @@ -13,13 +13,25 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { const emailStorage = c.env.EMAIL_STORAGE; - const feedMetadata = (await emailStorage.get( - `feed:${feedId}:metadata`, - "json", - )) as FeedMetadata | null; + const [feedMetadata, feedConfig] = await Promise.all([ + emailStorage.get( + `feed:${feedId}:metadata`, + "json", + ) as Promise, + emailStorage.get( + `feed:${feedId}:config`, + "json", + ) as Promise, + ]); if (!feedMetadata) { return new Response("Feed not found", { status: 404 }); } + if ( + feedConfig?.expires_at !== undefined && + feedConfig.expires_at <= Date.now() + ) { + return new Response("Feed has expired", { status: 410 }); + } const metaEntry = feedMetadata.emails.find( (e) => e.receivedAt === receivedAt, diff --git a/src/routes/rss.ts b/src/routes/rss.ts index ef7dbcf..98cd58d 100644 --- a/src/routes/rss.ts +++ b/src/routes/rss.ts @@ -15,6 +15,12 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { if (!feedData) { return new Response("Feed not found", { status: 404 }); } + if ( + feedData.feedConfig.expires_at !== undefined && + feedData.feedConfig.expires_at <= Date.now() + ) { + return new Response("Feed has expired", { status: 410 }); + } const base = baseUrl(c.env); const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`; diff --git a/src/styles/components.css b/src/styles/components.css index df86ab4..1647a69 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -972,3 +972,41 @@ table.table code { padding: 0; margin: 0; } + +/* Feed TTL — expiry badges */ +.pill-expiry { + background-color: rgba(255, 159, 10, 0.15); + color: var(--color-warning); + border-color: rgba(255, 159, 10, 0.3); +} + +.pill-expired { + background-color: rgba(255, 69, 58, 0.15); + color: var(--color-danger); + border-color: rgba(255, 69, 58, 0.3); +} + +/* Feed TTL — expired feed row */ +.feed-expired .feed-header, +.feed-expired td:not(:last-child) { + opacity: 0.5; +} + +/* Feed TTL — disabled-looking action buttons on expired feeds */ +.button-disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; + user-select: none; +} + +/* Feed TTL — card states in edit form */ +.card-warning { + border-color: rgba(255, 159, 10, 0.4); + background-color: rgba(255, 159, 10, 0.06); +} + +.card-disabled { + opacity: 0.6; + pointer-events: none; +} diff --git a/src/types/index.ts b/src/types/index.ts index bca5607..a1fe05c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export interface Env { FEED_MAX_SIZE_BYTES?: string; PROXY_TRUSTED_IPS?: string; PROXY_AUTH_SECRET?: string; + FEED_TTL_HOURS?: string; } // Stored attachment metadata (bytes live in R2, keyed by id) @@ -38,6 +39,7 @@ export interface FeedConfig { author?: string; created_at: number; updated_at?: number; + expires_at?: number; // Unix timestamp ms — present when a TTL is configured } // Feed metadata interface @@ -64,6 +66,7 @@ export interface FeedListItem { id: string; title: string; description?: string; + expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads } // WebSub (PubSubHubbub) subscription configuration diff --git a/wrangler-example.toml b/wrangler-example.toml index 2ff9d3b..e4202ec 100644 --- a/wrangler-example.toml +++ b/wrangler-example.toml @@ -24,6 +24,10 @@ fallthrough = false enabled = true invocation_logs = true +# Hourly cleanup: purge KV + R2 data for feeds whose TTL has expired +[triggers] +crons = ["0 * * * *"] + # Global Environment variables [vars] DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin UI) @@ -35,6 +39,10 @@ DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin U # Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB) # FEED_MAX_SIZE_BYTES = "524288" +# Optional: lock feed lifetime for all users (hours). When set, the TTL field in +# the admin UI is pre-filled and read-only. Remove to allow per-feed configuration. +# FEED_TTL_HOURS = "24" + # Optional: external proxy auth (Authelia/Authentik) # Comma-separated IPs of trusted reverse proxies # PROXY_TRUSTED_IPS = "10.0.0.1" @@ -88,7 +96,4 @@ routes = [ [env.demo.vars] DOMAIN = "demo.kill-the.news" EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain - -# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean -[env.demo.triggers] -crons = ["0 3 * * *"] +FEED_TTL_HOURS = "24" # Demo: all feeds expire after 24 hours (UI field is locked)