mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
+145
-26
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user