feat(feeds): add configurable per-feed lifetime (TTL)

Replace the demo nightly KV wipe with a per-feed expiry. Feeds can be
given a lifetime at creation (and edited later); FEED_TTL_HOURS locks the
value server-side and greys out the UI field. Expired feeds stay visible
in admin (greyed, actions disabled), return 410 on rss/atom/entries, and
reject inbound emails. The scheduled handler now purges only expired
feeds (KV + R2 attachments) on an hourly global cron.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 09:05:48 +02:00
parent 24c7d2a53e
commit f4d5edda0e
11 changed files with 461 additions and 123 deletions
+18 -11
View File
@@ -10,6 +10,11 @@ import { hubRouter } from "./routes/hub";
import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { handleCloudflareEmail } from "./lib/cloudflare-email";
import { Env } from "./types"; import { Env } from "./types";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import {
listAllFeeds,
purgeExpiredFeeds,
removeFeedsFromListBulk,
} from "./routes/admin/helpers";
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants"; import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
type AppEnv = { Bindings: Env }; type AppEnv = { Bindings: Env };
@@ -176,16 +181,18 @@ export default {
await handleCloudflareEmail(message, env, ctx); await handleCloudflareEmail(message, env, ctx);
}, },
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
let cursor: string | undefined; const feeds = await listAllFeeds(env.EMAIL_STORAGE);
let deleted = 0; const now = Date.now();
do { const expiredIds = feeds
const result = await env.EMAIL_STORAGE.list({ cursor }); .filter((f) => f.expires_at !== undefined && f.expires_at <= now)
await Promise.all( .map((f) => f.id);
result.keys.map(({ name }) => env.EMAIL_STORAGE.delete(name)),
); for (const feedId of expiredIds) {
deleted += result.keys.length; await purgeExpiredFeeds(env.EMAIL_STORAGE, feedId, env.ATTACHMENT_BUCKET);
cursor = result.list_complete ? undefined : result.cursor; }
} while (cursor); if (expiredIds.length > 0) {
logger.info("Demo KV reset complete", { deleted }); await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds);
logger.info("Feed TTL cleanup", { deleted: expiredIds.length });
}
}, },
}; };
+10
View File
@@ -112,6 +112,16 @@ export async function validateEmail(
response: new Response("Feed does not exist", { status: 404 }), 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 || []) const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail) .map(normalizeEmail)
+145 -26
View File
@@ -279,6 +279,35 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
</div> </div>
); );
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 (
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
{label}
</span>
);
};
// Admin dashboard route // Admin dashboard route
app.get("/", async (c) => { app.get("/", async (c) => {
// Type assertion for environment variables // Type assertion for environment variables
@@ -396,6 +425,29 @@ app.get("/", async (c) => {
</small> </small>
</div> </div>
<div class="form-group">
<label for="lifetime_hours">
Lifetime (hours{env.FEED_TTL_HOURS ? "" : ", optional"})
</label>
<input
type="number"
id="lifetime_hours"
name="lifetime_hours"
min="1"
value={env.FEED_TTL_HOURS || ""}
disabled={!!env.FEED_TTL_HOURS}
placeholder={env.FEED_TTL_HOURS ? undefined : "No expiry"}
/>
{env.FEED_TTL_HOURS ? (
<small>
Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server
configuration.
</small>
) : (
<small>Leave empty for no expiry.</small>
)}
</div>
<input type="hidden" id="language" name="language" value="en" /> <input type="hidden" id="language" name="language" value="en" />
<input type="hidden" name="view" value={view} /> <input type="hidden" name="view" value={view} />
@@ -489,6 +541,7 @@ app.get("/", async (c) => {
<col data-col="email" style="width: 200px;" /> <col data-col="email" style="width: 200px;" />
<col data-col="rss" style="width: 190px;" /> <col data-col="rss" style="width: 190px;" />
<col data-col="atom" style="width: 190px;" /> <col data-col="atom" style="width: 190px;" />
<col data-col="expires" style="width: 130px;" />
<col data-col="actions" style="width: 170px;" /> <col data-col="actions" style="width: 170px;" />
</colgroup> </colgroup>
<thead> <thead>
@@ -606,6 +659,14 @@ app.get("/", async (c) => {
title="Resize" title="Resize"
></div> ></div>
</th> </th>
<th class="th-resizable">
<span>Expires</span>
<div
class="col-resizer"
data-col="expires"
title="Resize"
></div>
</th>
<th class="th-resizable"> <th class="th-resizable">
<span>Actions</span> <span>Actions</span>
<div <div
@@ -632,10 +693,13 @@ app.get("/", async (c) => {
const descHover = clampText(feed.description || "", 1000); const descHover = clampText(feed.description || "", 1000);
const searchHaystack = const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
const isExpired =
feed.expires_at !== undefined &&
feed.expires_at <= Date.now();
return ( return (
<tr <tr
class="feed-row" class={`feed-row${isExpired ? " feed-expired" : ""}`}
data-feed-id={feed.id} data-feed-id={feed.id}
data-search={searchHaystack} data-search={searchHaystack}
data-sort-title={sortTitle} data-sort-title={sortTitle}
@@ -679,20 +743,48 @@ app.get("/", async (c) => {
<td> <td>
<CopyFieldInline value={atomUrl} /> <CopyFieldInline value={atomUrl} />
</td> </td>
<td>
{feed.expires_at ? (
<ExpiryBadge expiresAt={feed.expires_at} />
) : (
<span class="muted"></span>
)}
</td>
<td> <td>
<div class="row-actions"> <div class="row-actions">
<a {isExpired ? (
href={`/admin/feeds/${feed.id}/edit`} <>
class="button button-small" <span
> class="button button-small button-disabled"
Edit aria-disabled="true"
</a> tabindex={-1}
<a >
href={`/admin/feeds/${feed.id}/emails`} Edit
class="button button-small" </span>
> <span
Emails class="button button-small button-disabled"
</a> aria-disabled="true"
tabindex={-1}
>
Emails
</span>
</>
) : (
<>
<a
href={`/admin/feeds/${feed.id}/edit`}
class="button button-small"
>
Edit
</a>
<a
href={`/admin/feeds/${feed.id}/emails`}
class="button button-small"
>
Emails
</a>
</>
)}
<button <button
type="button" type="button"
class="button button-small button-danger button-delete" class="button button-small button-danger button-delete"
@@ -738,10 +830,13 @@ app.get("/", async (c) => {
const descHover = clampText(feed.description || "", 1000); const descHover = clampText(feed.description || "", 1000);
const searchHaystack = const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
const isExpired =
feed.expires_at !== undefined &&
feed.expires_at <= Date.now();
return ( return (
<li <li
class="feed-item card feed-row" class={`feed-item card feed-row${isExpired ? " feed-expired" : ""}`}
data-feed-id={feed.id} data-feed-id={feed.id}
data-search={searchHaystack} data-search={searchHaystack}
> >
@@ -749,6 +844,9 @@ app.get("/", async (c) => {
<h3 class="feed-title" title={titleHover}> <h3 class="feed-title" title={titleHover}>
{titleDisplay} {titleDisplay}
</h3> </h3>
{feed.expires_at && (
<ExpiryBadge expiresAt={feed.expires_at} />
)}
{feed.description && ( {feed.description && (
<p class="feed-description"> <p class="feed-description">
<span title={descHover}>{descDisplay}</span> <span title={descHover}>{descDisplay}</span>
@@ -809,18 +907,39 @@ app.get("/", async (c) => {
<div class="feed-buttons"> <div class="feed-buttons">
<div class="feed-buttons-left"> <div class="feed-buttons-left">
<a {isExpired ? (
href={`/admin/feeds/${feed.id}/edit`} <>
class="button button-small" <span
> class="button button-small button-disabled"
Edit aria-disabled="true"
</a> tabindex={-1}
<a >
href={`/admin/feeds/${feed.id}/emails`} Edit
class="button button-small" </span>
> <span
Emails class="button button-small button-disabled"
</a> aria-disabled="true"
tabindex={-1}
>
Emails
</span>
</>
) : (
<>
<a
href={`/admin/feeds/${feed.id}/edit`}
class="button button-small"
>
Edit
</a>
<a
href={`/admin/feeds/${feed.id}/emails`}
class="button button-small"
>
Emails
</a>
</>
)}
</div> </div>
<div class="feed-buttons-right"> <div class="feed-buttons-right">
<button <button
+131 -75
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types"; import { Env, FeedConfig, FeedMetadata } from "../../types";
import { generateFeedId } from "../../utils/id-generator"; import { generateFeedId } from "../../utils/id-generator";
import { waitUntilSafe } from "../../utils/worker"; import { waitUntilSafe } from "../../utils/worker";
import { feedRssUrl, feedEmailAddress } from "../../utils/urls"; import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
@@ -12,6 +12,7 @@ import {
removeFeedFromList, removeFeedFromList,
removeFeedsFromListBulk, removeFeedsFromListBulk,
deleteKeysWithConcurrency, deleteKeysWithConcurrency,
purgeFeedKeysStep,
} from "./helpers"; } from "./helpers";
type AppEnv = { Bindings: Env }; type AppEnv = { Bindings: Env };
@@ -92,63 +93,6 @@ async function deleteFeedFast(
return result.ok; 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<EmailData | null>,
),
);
const attachmentIds = emailDataResults
.filter(
(r): r is PromiseFulfilledResult<EmailData | null> =>
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 ──────────────────────────────────────────────────────────────────── // ── Routes ────────────────────────────────────────────────────────────────────
feedsRouter.post("/create", async (c) => { feedsRouter.post("/create", async (c) => {
@@ -164,6 +108,7 @@ feedsRouter.post("/create", async (c) => {
let view: string; let view: string;
let allowedSenders: string[]; let allowedSenders: string[];
let blockedSenders: string[]; let blockedSenders: string[];
let lifetimeHoursRaw: string | undefined;
if (isJson) { if (isJson) {
const body = await c.req.json<Record<string, unknown>>(); const body = await c.req.json<Record<string, unknown>>();
@@ -182,6 +127,8 @@ feedsRouter.post("/create", async (c) => {
(body.blockedSenders as unknown[]).map(String), (body.blockedSenders as unknown[]).map(String),
) )
: []; : [];
lifetimeHoursRaw =
body.lifetimeHours != null ? String(body.lifetimeHours) : undefined;
} else { } else {
const formData = await c.req.formData(); const formData = await c.req.formData();
title = formData.get("title")?.toString() || ""; title = formData.get("title")?.toString() || "";
@@ -194,6 +141,7 @@ feedsRouter.post("/create", async (c) => {
blockedSenders = parseAllowedSenders( blockedSenders = parseAllowedSenders(
formData.get("blocked_senders")?.toString() || "", formData.get("blocked_senders")?.toString() || "",
); );
lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
} }
const parsedData = createFeedSchema.parse({ const parsedData = createFeedSchema.parse({
@@ -204,6 +152,17 @@ feedsRouter.post("/create", async (c) => {
blockedSenders, blockedSenders,
}); });
// FEED_TTL_HOURS overrides any client-submitted value
const resolvedHours = env.FEED_TTL_HOURS
? parseInt(env.FEED_TTL_HOURS, 10)
: lifetimeHoursRaw
? parseInt(lifetimeHoursRaw, 10)
: NaN;
const expiresAt =
Number.isFinite(resolvedHours) && resolvedHours > 0
? Date.now() + resolvedHours * 3_600_000
: undefined;
const feedId = generateFeedId(); const feedId = generateFeedId();
const feedConfig: FeedConfig = { const feedConfig: FeedConfig = {
@@ -214,6 +173,7 @@ feedsRouter.post("/create", async (c) => {
blocked_senders: parsedData.blockedSenders, blocked_senders: parsedData.blockedSenders,
created_at: Date.now(), created_at: Date.now(),
updated_at: Date.now(), updated_at: Date.now(),
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
}; };
const feedMetadata: FeedMetadata = { emails: [] }; const feedMetadata: FeedMetadata = { emails: [] };
@@ -228,6 +188,7 @@ feedsRouter.post("/create", async (c) => {
feedId, feedId,
parsedData.title, parsedData.title,
parsedData.description, parsedData.description,
expiresAt,
); );
if (isJson) { if (isJson) {
@@ -261,6 +222,22 @@ feedsRouter.get("/:feedId/edit", async (c) => {
return c.text("Feed not found", 404); return c.text("Feed not found", 404);
} }
const now = Date.now();
const isExpired =
feedConfig.expires_at !== undefined && feedConfig.expires_at <= now;
const ttlLocked = !!env.FEED_TTL_HOURS;
// Remaining hours: ceil so we don't show 0 when there's still time left
const remainingHours =
feedConfig.expires_at !== undefined && feedConfig.expires_at > now
? Math.ceil((feedConfig.expires_at - now) / 3_600_000)
: undefined;
const lifetimeFieldValue =
ttlLocked && !isExpired
? (env.FEED_TTL_HOURS ?? "")
: (remainingHours?.toString() ?? "");
return c.html( return c.html(
<Layout title="Edit Feed"> <Layout title="Edit Feed">
<div class="container fade-in"> <div class="container fade-in">
@@ -275,7 +252,25 @@ feedsRouter.get("/:feedId/edit", async (c) => {
</div> </div>
</div> </div>
<div class="card"> {isExpired && (
<div class="card card-warning">
<p>
<strong>This feed has expired.</strong> It no longer accepts
emails and its content is no longer publicly accessible.
</p>
<form
action={`/admin/feeds/${feedId}/delete`}
method="post"
style="margin-top: 0.75rem;"
>
<button type="submit" class="button button-danger">
Delete this feed
</button>
</form>
</div>
)}
<div class={`card${isExpired ? " card-disabled" : ""}`}>
<form action={`/admin/feeds/${feedId}/edit`} method="post"> <form action={`/admin/feeds/${feedId}/edit`} method="post">
<div class="form-group"> <div class="form-group">
<label for="title">Feed Title</label> <label for="title">Feed Title</label>
@@ -285,12 +280,18 @@ feedsRouter.get("/:feedId/edit", async (c) => {
name="title" name="title"
value={feedConfig.title} value={feedConfig.title}
required required
disabled={isExpired}
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">Description</label> <label for="description">Description</label>
<textarea id="description" name="description" rows={3}> <textarea
id="description"
name="description"
rows={3}
disabled={isExpired}
>
{feedConfig.description || ""} {feedConfig.description || ""}
</textarea> </textarea>
</div> </div>
@@ -304,6 +305,7 @@ feedsRouter.get("/:feedId/edit", async (c) => {
name="allowed_senders" name="allowed_senders"
rows={3} rows={3}
placeholder={"newsletter@example.com\ntechmeme.com"} placeholder={"newsletter@example.com\ntechmeme.com"}
disabled={isExpired}
> >
{(feedConfig.allowed_senders || []).join("\n")} {(feedConfig.allowed_senders || []).join("\n")}
</textarea> </textarea>
@@ -322,6 +324,7 @@ feedsRouter.get("/:feedId/edit", async (c) => {
name="blocked_senders" name="blocked_senders"
rows={3} rows={3}
placeholder={"spam@example.com\nunwanted.com"} placeholder={"spam@example.com\nunwanted.com"}
disabled={isExpired}
> >
{(feedConfig.blocked_senders || []).join("\n")} {(feedConfig.blocked_senders || []).join("\n")}
</textarea> </textarea>
@@ -331,11 +334,37 @@ feedsRouter.get("/:feedId/edit", async (c) => {
</small> </small>
</div> </div>
<div class="form-group">
<label for="lifetime_hours">Lifetime (hours)</label>
<input
type="number"
id="lifetime_hours"
name="lifetime_hours"
min="1"
value={lifetimeFieldValue}
disabled={isExpired || ttlLocked}
placeholder={feedConfig.expires_at ? undefined : "No expiry"}
/>
{ttlLocked ? (
<small>
Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server
configuration.
</small>
) : (
<small>
Hours from now until this feed expires. Leave empty to keep
the current expiry (or no expiry).
</small>
)}
</div>
<input type="hidden" id="language" name="language" value="en" /> <input type="hidden" id="language" name="language" value="en" />
<button type="submit" class="button"> {!isExpired && (
Update Feed <button type="submit" class="button">
</button> Update Feed
</button>
)}
</form> </form>
</div> </div>
</div> </div>
@@ -359,6 +388,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
const blockedSenders = parseAllowedSenders( const blockedSenders = parseAllowedSenders(
formData.get("blocked_senders")?.toString() || "", formData.get("blocked_senders")?.toString() || "",
); );
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
const parsedData = updateFeedSchema.parse({ const parsedData = updateFeedSchema.parse({
title, title,
@@ -377,24 +407,50 @@ feedsRouter.post("/:feedId/edit", async (c) => {
return c.text("Feed not found", 404); return c.text("Feed not found", 404);
} }
await emailStorage.put( // Expired feeds cannot be edited
feedConfigKey, if (
JSON.stringify({ existingConfig.expires_at !== undefined &&
...existingConfig, existingConfig.expires_at <= Date.now()
title: parsedData.title, ) {
description: parsedData.description, return c.text("Feed has expired and cannot be modified.", 403);
language: parsedData.language, }
allowed_senders: parsedData.allowedSenders,
blocked_senders: parsedData.blockedSenders, // Resolve new expires_at:
updated_at: Date.now(), // - 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( await updateFeedInList(
emailStorage, emailStorage,
feedId, feedId,
parsedData.title, parsedData.title,
parsedData.description, parsedData.description,
newExpiresAt,
); );
return c.redirect("/admin"); return c.redirect("/admin");
+78 -2
View File
@@ -1,4 +1,4 @@
import { FeedList, FeedListItem } from "../../types"; import { EmailData, FeedList, FeedListItem } from "../../types";
import { FEEDS_LIST_KEY } from "../../config/constants"; import { FEEDS_LIST_KEY } from "../../config/constants";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
@@ -49,13 +49,14 @@ export async function addFeedToList(
feedId: string, feedId: string,
title: string, title: string,
description?: string, description?: string,
expires_at?: number,
): Promise<void> { ): Promise<void> {
try { try {
const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, {
type: "json", type: "json",
})) as FeedList | null) || { feeds: [] }; })) 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)); await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
} catch (error) { } catch (error) {
logger.error("Error adding feed to list", { feedId, error: String(error) }); logger.error("Error adding feed to list", { feedId, error: String(error) });
@@ -67,6 +68,7 @@ export async function updateFeedInList(
feedId: string, feedId: string,
title: string, title: string,
description?: string, description?: string,
expires_at?: number,
): Promise<void> { ): Promise<void> {
try { try {
const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, { const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, {
@@ -77,6 +79,7 @@ export async function updateFeedInList(
if (feedIndex !== -1) { if (feedIndex !== -1) {
feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].title = title;
feedList.feeds[feedIndex].description = description; feedList.feeds[feedIndex].description = description;
feedList.feeds[feedIndex].expires_at = expires_at;
await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
} }
} catch (error) { } catch (error) {
@@ -128,3 +131,76 @@ export async function removeFeedFromList(
const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); const removed = await removeFeedsFromListBulk(emailStorage, [feedId]);
return removed.includes(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<EmailData | null>,
),
);
const attachmentIds = emailDataResults
.filter(
(r): r is PromiseFulfilledResult<EmailData | null> =>
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<void> {
let cursor: string | undefined;
do {
const step = await purgeFeedKeysStep(emailStorage, feedId, {
bucket,
limit: 100,
cursor,
});
cursor = step.listComplete ? undefined : step.cursor;
} while (cursor);
}
+6
View File
@@ -15,6 +15,12 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
if (!feedData) { if (!feedData) {
return new Response("Feed not found", { status: 404 }); 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 base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`; const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
+17 -5
View File
@@ -1,6 +1,6 @@
import { Context } from "hono"; import { Context } from "hono";
import { html, raw } from "hono/html"; 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"; import { processEmailContent } from "../utils/html-processor";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> { export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
@@ -13,13 +13,25 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const emailStorage = c.env.EMAIL_STORAGE; const emailStorage = c.env.EMAIL_STORAGE;
const feedMetadata = (await emailStorage.get( const [feedMetadata, feedConfig] = await Promise.all([
`feed:${feedId}:metadata`, emailStorage.get(
"json", `feed:${feedId}:metadata`,
)) as FeedMetadata | null; "json",
) as Promise<FeedMetadata | null>,
emailStorage.get(
`feed:${feedId}:config`,
"json",
) as Promise<FeedConfig | null>,
]);
if (!feedMetadata) { if (!feedMetadata) {
return new Response("Feed not found", { status: 404 }); 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( const metaEntry = feedMetadata.emails.find(
(e) => e.receivedAt === receivedAt, (e) => e.receivedAt === receivedAt,
+6
View File
@@ -15,6 +15,12 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
if (!feedData) { if (!feedData) {
return new Response("Feed not found", { status: 404 }); 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 base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`; const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
+38
View File
@@ -972,3 +972,41 @@ table.table code {
padding: 0; padding: 0;
margin: 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;
}
+3
View File
@@ -8,6 +8,7 @@ export interface Env {
FEED_MAX_SIZE_BYTES?: string; FEED_MAX_SIZE_BYTES?: string;
PROXY_TRUSTED_IPS?: string; PROXY_TRUSTED_IPS?: string;
PROXY_AUTH_SECRET?: string; PROXY_AUTH_SECRET?: string;
FEED_TTL_HOURS?: string;
} }
// Stored attachment metadata (bytes live in R2, keyed by id) // Stored attachment metadata (bytes live in R2, keyed by id)
@@ -38,6 +39,7 @@ export interface FeedConfig {
author?: string; author?: string;
created_at: number; created_at: number;
updated_at?: number; updated_at?: number;
expires_at?: number; // Unix timestamp ms — present when a TTL is configured
} }
// Feed metadata interface // Feed metadata interface
@@ -64,6 +66,7 @@ export interface FeedListItem {
id: string; id: string;
title: string; title: string;
description?: string; description?: string;
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
} }
// WebSub (PubSubHubbub) subscription configuration // WebSub (PubSubHubbub) subscription configuration
+9 -4
View File
@@ -24,6 +24,10 @@ fallthrough = false
enabled = true enabled = true
invocation_logs = true invocation_logs = true
# Hourly cleanup: purge KV + R2 data for feeds whose TTL has expired
[triggers]
crons = ["0 * * * *"]
# Global Environment variables # Global Environment variables
[vars] [vars]
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin UI) 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) # Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB)
# FEED_MAX_SIZE_BYTES = "524288" # 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) # Optional: external proxy auth (Authelia/Authentik)
# Comma-separated IPs of trusted reverse proxies # Comma-separated IPs of trusted reverse proxies
# PROXY_TRUSTED_IPS = "10.0.0.1" # PROXY_TRUSTED_IPS = "10.0.0.1"
@@ -88,7 +96,4 @@ routes = [
[env.demo.vars] [env.demo.vars]
DOMAIN = "demo.kill-the.news" DOMAIN = "demo.kill-the.news"
EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain
FEED_TTL_HOURS = "24" # Demo: all feeds expire after 24 hours (UI field is locked)
# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean
[env.demo.triggers]
crons = ["0 3 * * *"]