feat: reader-compat batch — JSON Feed, OPML export, conditional GET, dedup

Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"):

- JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link
- OPML export at /admin/opml (admin-protected; the registry lists every
  feed URL, so it must not be public)
- Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on
  If-None-Match/If-Modified-Since, validators shared via http-cache.ts
- Duplicate-send dedup in ingestion: match by Message-ID, fall back to a
  SHA-256 of normalized subject+content; a duplicate is a no-op and bumps
  the new emails_deduplicated counter (status page + /api/v1/stats)

429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO +
landing cards) updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 20:47:54 +02:00
parent 334713fbd9
commit 0abd5f306c
23 changed files with 1015 additions and 11 deletions
+68
View File
@@ -0,0 +1,68 @@
import { FeedConfig, EmailData } from "../types";
export interface FeedValidators {
etag: string;
lastModified: string;
maxReceivedAt: number;
}
/**
* Compute HTTP cache validators (ETag + Last-Modified) for a feed.
* The ETag is derived from the feed format prefix, feedId, email count, and max
* receivedAt, making it a strong deterministic validator that changes whenever
* the feed content changes.
*/
export function computeFeedValidators(
format: "rss" | "atom",
feedId: string,
feedConfig: FeedConfig,
emails: EmailData[],
): FeedValidators {
const maxReceivedAt =
emails.length > 0
? Math.max(...emails.map((e) => e.receivedAt))
: (feedConfig.created_at ?? 0);
const hash = `${format}-${feedId}-${emails.length}-${maxReceivedAt}`;
const etag = `"${hash}"`;
const lastModified = new Date(maxReceivedAt).toUTCString();
return { etag, lastModified, maxReceivedAt };
}
/**
* Returns true if the request carries a matching conditional GET header,
* meaning a 304 Not Modified response is appropriate.
*/
export function isNotModified(
req: Request,
validators: FeedValidators,
): boolean {
const ifNoneMatch = req.headers.get("If-None-Match");
if (ifNoneMatch !== null) {
return ifNoneMatch === validators.etag;
}
const ifModifiedSince = req.headers.get("If-Modified-Since");
if (ifModifiedSince !== null) {
const clientTime = new Date(ifModifiedSince).getTime();
return !isNaN(clientTime) && clientTime >= validators.maxReceivedAt;
}
return false;
}
/**
* Build a 304 Not Modified response with the standard cache validator headers.
*/
export function notModifiedResponse(validators: FeedValidators): Response {
return new Response(null, {
status: 304,
headers: {
ETag: validators.etag,
"Last-Modified": validators.lastModified,
"Cache-Control": "max-age=1800",
"X-Robots-Tag": "noindex",
},
});
}