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
+145 -26
View File
@@ -279,6 +279,35 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
</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
app.get("/", async (c) => {
// Type assertion for environment variables
@@ -396,6 +425,29 @@ app.get("/", async (c) => {
</small>
</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" name="view" value={view} />
@@ -489,6 +541,7 @@ app.get("/", async (c) => {
<col data-col="email" style="width: 200px;" />
<col data-col="rss" style="width: 190px;" />
<col data-col="atom" style="width: 190px;" />
<col data-col="expires" style="width: 130px;" />
<col data-col="actions" style="width: 170px;" />
</colgroup>
<thead>
@@ -606,6 +659,14 @@ app.get("/", async (c) => {
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Expires</span>
<div
class="col-resizer"
data-col="expires"
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div
@@ -632,10 +693,13 @@ app.get("/", async (c) => {
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 (
<tr
class="feed-row"
class={`feed-row${isExpired ? " feed-expired" : ""}`}
data-feed-id={feed.id}
data-search={searchHaystack}
data-sort-title={sortTitle}
@@ -679,20 +743,48 @@ app.get("/", async (c) => {
<td>
<CopyFieldInline value={atomUrl} />
</td>
<td>
{feed.expires_at ? (
<ExpiryBadge expiresAt={feed.expires_at} />
) : (
<span class="muted"></span>
)}
</td>
<td>
<div class="row-actions">
<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>
{isExpired ? (
<>
<span
class="button button-small button-disabled"
aria-disabled="true"
tabindex={-1}
>
Edit
</span>
<span
class="button button-small button-disabled"
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
type="button"
class="button button-small button-danger button-delete"
@@ -738,10 +830,13 @@ app.get("/", async (c) => {
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 (
<li
class="feed-item card feed-row"
class={`feed-item card feed-row${isExpired ? " feed-expired" : ""}`}
data-feed-id={feed.id}
data-search={searchHaystack}
>
@@ -749,6 +844,9 @@ app.get("/", async (c) => {
<h3 class="feed-title" title={titleHover}>
{titleDisplay}
</h3>
{feed.expires_at && (
<ExpiryBadge expiresAt={feed.expires_at} />
)}
{feed.description && (
<p class="feed-description">
<span title={descHover}>{descDisplay}</span>
@@ -809,18 +907,39 @@ app.get("/", async (c) => {
<div class="feed-buttons">
<div class="feed-buttons-left">
<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>
{isExpired ? (
<>
<span
class="button button-small button-disabled"
aria-disabled="true"
tabindex={-1}
>
Edit
</span>
<span
class="button button-small button-disabled"
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 class="feed-buttons-right">
<button
+131 -75
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { z } from "zod";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
import { Env, FeedConfig, FeedMetadata } from "../../types";
import { generateFeedId } from "../../utils/id-generator";
import { waitUntilSafe } from "../../utils/worker";
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
@@ -12,6 +12,7 @@ import {
removeFeedFromList,
removeFeedsFromListBulk,
deleteKeysWithConcurrency,
purgeFeedKeysStep,
} from "./helpers";
type AppEnv = { Bindings: Env };
@@ -92,63 +93,6 @@ async function deleteFeedFast(
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 ────────────────────────────────────────────────────────────────────
feedsRouter.post("/create", async (c) => {
@@ -164,6 +108,7 @@ feedsRouter.post("/create", async (c) => {
let view: string;
let allowedSenders: string[];
let blockedSenders: string[];
let lifetimeHoursRaw: string | undefined;
if (isJson) {
const body = await c.req.json<Record<string, unknown>>();
@@ -182,6 +127,8 @@ feedsRouter.post("/create", async (c) => {
(body.blockedSenders as unknown[]).map(String),
)
: [];
lifetimeHoursRaw =
body.lifetimeHours != null ? String(body.lifetimeHours) : undefined;
} else {
const formData = await c.req.formData();
title = formData.get("title")?.toString() || "";
@@ -194,6 +141,7 @@ feedsRouter.post("/create", async (c) => {
blockedSenders = parseAllowedSenders(
formData.get("blocked_senders")?.toString() || "",
);
lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
}
const parsedData = createFeedSchema.parse({
@@ -204,6 +152,17 @@ feedsRouter.post("/create", async (c) => {
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 feedConfig: FeedConfig = {
@@ -214,6 +173,7 @@ feedsRouter.post("/create", async (c) => {
blocked_senders: parsedData.blockedSenders,
created_at: Date.now(),
updated_at: Date.now(),
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
};
const feedMetadata: FeedMetadata = { emails: [] };
@@ -228,6 +188,7 @@ feedsRouter.post("/create", async (c) => {
feedId,
parsedData.title,
parsedData.description,
expiresAt,
);
if (isJson) {
@@ -261,6 +222,22 @@ feedsRouter.get("/:feedId/edit", async (c) => {
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(
<Layout title="Edit Feed">
<div class="container fade-in">
@@ -275,7 +252,25 @@ feedsRouter.get("/:feedId/edit", async (c) => {
</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">
<div class="form-group">
<label for="title">Feed Title</label>
@@ -285,12 +280,18 @@ feedsRouter.get("/:feedId/edit", async (c) => {
name="title"
value={feedConfig.title}
required
disabled={isExpired}
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows={3}>
<textarea
id="description"
name="description"
rows={3}
disabled={isExpired}
>
{feedConfig.description || ""}
</textarea>
</div>
@@ -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")}
</textarea>
@@ -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")}
</textarea>
@@ -331,11 +334,37 @@ feedsRouter.get("/:feedId/edit", async (c) => {
</small>
</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" />
<button type="submit" class="button">
Update Feed
</button>
{!isExpired && (
<button type="submit" class="button">
Update Feed
</button>
)}
</form>
</div>
</div>
@@ -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");
+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 { logger } from "../../lib/logger";
@@ -49,13 +49,14 @@ export async function addFeedToList(
feedId: string,
title: string,
description?: string,
expires_at?: number,
): Promise<void> {
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<void> {
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<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) {
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}`;
+17 -5
View File
@@ -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<Response> {
@@ -13,13 +13,25 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
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<FeedMetadata | null>,
emailStorage.get(
`feed:${feedId}:config`,
"json",
) as Promise<FeedConfig | null>,
]);
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,
+6
View File
@@ -15,6 +15,12 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
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}`;