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:
+18
-11
@@ -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 });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+121
-2
@@ -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,8 +743,34 @@ 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">
|
||||||
|
{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
|
<a
|
||||||
href={`/admin/feeds/${feed.id}/edit`}
|
href={`/admin/feeds/${feed.id}/edit`}
|
||||||
class="button button-small"
|
class="button button-small"
|
||||||
@@ -693,6 +783,8 @@ app.get("/", async (c) => {
|
|||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
</a>
|
</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,6 +907,25 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
<div class="feed-buttons">
|
<div class="feed-buttons">
|
||||||
<div class="feed-buttons-left">
|
<div class="feed-buttons-left">
|
||||||
|
{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
|
<a
|
||||||
href={`/admin/feeds/${feed.id}/edit`}
|
href={`/admin/feeds/${feed.id}/edit`}
|
||||||
class="button button-small"
|
class="button button-small"
|
||||||
@@ -821,6 +938,8 @@ app.get("/", async (c) => {
|
|||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-buttons-right">
|
<div class="feed-buttons-right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+121
-65
@@ -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" />
|
||||||
|
|
||||||
|
{!isExpired && (
|
||||||
<button type="submit" class="button">
|
<button type="submit" class="button">
|
||||||
Update Feed
|
Update Feed
|
||||||
</button>
|
</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,9 +407,32 @@ 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.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,
|
...existingConfig,
|
||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
@@ -387,14 +440,17 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
allowed_senders: parsedData.allowedSenders,
|
allowed_senders: parsedData.allowedSenders,
|
||||||
blocked_senders: parsedData.blockedSenders,
|
blocked_senders: parsedData.blockedSenders,
|
||||||
updated_at: Date.now(),
|
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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
+15
-3
@@ -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([
|
||||||
|
emailStorage.get(
|
||||||
`feed:${feedId}:metadata`,
|
`feed:${feedId}:metadata`,
|
||||||
"json",
|
"json",
|
||||||
)) as FeedMetadata | null;
|
) 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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 * * *"]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user