mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
97ce9a62b4
Close the five open P1·S items from TODO.md: - X-Robots-Tag: noindex on rss/atom/entries/files + a /robots.txt - absolutize relative content URLs against the sender's site - promote lazy-loaded images (data-src → src, strip loading="lazy") - strip XML-illegal control chars from generated feeds (keep emoji) - plain-text feed <title> (strip HTML, decode entities) Sender-base derivation lives on the EmailAddress value object (siteBaseUrl) instead of a misplaced favicon helper. Bump to 0.2.1 and document the changes in README + CLAUDE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import { Context } from "hono";
|
|
import { html, raw } from "hono/html";
|
|
import { Env } from "../types";
|
|
import { processEmailContent } from "../infrastructure/html-processor";
|
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
|
import { formatBytes } from "../domain/format";
|
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
|
import { FeedId } from "../domain/value-objects/feed-id";
|
|
import { isExpired } from "../domain/feed";
|
|
import entryCss from "../styles/entry.css";
|
|
|
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|
const feedId = c.req.param("feedId");
|
|
const receivedAt = parseInt(c.req.param("entryId") ?? "", 10);
|
|
|
|
if (!feedId || isNaN(receivedAt)) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const repo = FeedRepository.from(c.env);
|
|
const id = FeedId.unchecked(feedId);
|
|
|
|
const [feedMetadata, feedConfig] = await Promise.all([
|
|
repo.getMetadata(id),
|
|
repo.getConfig(id),
|
|
]);
|
|
if (!feedMetadata) {
|
|
return new Response("Feed not found", { status: 404 });
|
|
}
|
|
if (feedConfig && isExpired(feedConfig)) {
|
|
return new Response("Feed has expired", { status: 410 });
|
|
}
|
|
|
|
const metaEntry = feedMetadata.emails.find(
|
|
(e) => e.receivedAt === receivedAt,
|
|
);
|
|
if (!metaEntry) {
|
|
return new Response("Entry not found", { status: 404 });
|
|
}
|
|
|
|
const emailData = await repo.getEmail(metaEntry.key);
|
|
if (!emailData) {
|
|
return new Response("Entry not found", { status: 404 });
|
|
}
|
|
|
|
c.header(
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
|
);
|
|
c.header("X-Robots-Tag", "noindex");
|
|
|
|
const bodyContent = processEmailContent(
|
|
emailData.content,
|
|
emailData.attachments,
|
|
"",
|
|
EmailAddress.parse(emailData.from)?.siteBaseUrl() ?? "",
|
|
);
|
|
|
|
// Inline images render in place (cid: refs are rewritten by processEmailContent);
|
|
// only genuine, downloadable attachments belong in the list below.
|
|
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
|
|
const attachmentsSection = attachments.length
|
|
? html`<section class="attachments">
|
|
<h2>Attachments</h2>
|
|
<ul>
|
|
${attachments.map(
|
|
(a) =>
|
|
html`<li>
|
|
<a
|
|
href="/files/${a.id}/${encodeURIComponent(a.filename)}"
|
|
download
|
|
>${a.filename}</a
|
|
>
|
|
<span class="size">${formatBytes(a.size)}</span>
|
|
</li>`,
|
|
)}
|
|
</ul>
|
|
</section>`
|
|
: "";
|
|
|
|
return c.html(
|
|
html`<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta
|
|
name="viewport"
|
|
content="width=device-width, initial-scale=1.0"
|
|
/>
|
|
<title>${emailData.subject}</title>
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
<style>
|
|
${raw(entryCss)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${emailData.subject}</h1>
|
|
<dl class="meta">
|
|
<dt>From:</dt>
|
|
<dd>${emailData.from}</dd>
|
|
<dt>Date:</dt>
|
|
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
|
</dl>
|
|
<div class="content">${raw(bodyContent)}</div>
|
|
${attachmentsSection}
|
|
</body>
|
|
</html>`,
|
|
);
|
|
}
|