mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(favicon): per-feed icon from the last sender's domain
Resolve each feed's most recent sender domain and serve its favicon at GET /favicon/:feedId, falling back to the project icon. Icons are fetched in the background on ingestion (direct /favicon.ico then a DuckDuckGo fallback), cached base64 in KV keyed by domain with a 1-week TTL so the fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo> and rendered in the admin feed list, plus a landing-page feature card. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+47
-17
@@ -145,7 +145,12 @@ app.get("/login", (c) => {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="24" height="24" rx="12" fill="var(--color-primary)" />
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="var(--color-primary)"
|
||||
/>
|
||||
<path
|
||||
d="M17 9C17 7.89543 16.1046 7 15 7H9C7.89543 7 7 7.89543 7 9V15C7 16.1046 7.89543 17 9 17H15C16.1046 17 17 16.1046 17 15V9Z"
|
||||
stroke="white"
|
||||
@@ -161,9 +166,7 @@ app.get("/login", (c) => {
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="auth-title">kill-the-news</h1>
|
||||
{errorMessage && (
|
||||
<div class="auth-error">{errorMessage}</div>
|
||||
)}
|
||||
{errorMessage && <div class="auth-error">{errorMessage}</div>}
|
||||
<form class="auth-form" action="/admin/login" method="post">
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
@@ -641,7 +644,11 @@ app.get("/", async (c) => {
|
||||
title="Resize"
|
||||
></div>
|
||||
</th>
|
||||
<th class="th-resizable" data-sort-key="atom" aria-sort="none">
|
||||
<th
|
||||
class="th-resizable"
|
||||
data-sort-key="atom"
|
||||
aria-sort="none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="th-button"
|
||||
@@ -689,7 +696,10 @@ app.get("/", async (c) => {
|
||||
const sortEmail = emailAddress.toLowerCase();
|
||||
const sortRss = rssUrl.toLowerCase();
|
||||
const sortAtom = atomUrl.toLowerCase();
|
||||
const descDisplay = clampText(feed.description || "", 220);
|
||||
const descDisplay = clampText(
|
||||
feed.description || "",
|
||||
220,
|
||||
);
|
||||
const descHover = clampText(feed.description || "", 1000);
|
||||
const searchHaystack =
|
||||
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
||||
@@ -718,18 +728,30 @@ app.get("/", async (c) => {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong class="truncate" title={titleHover}>
|
||||
{titleDisplay}
|
||||
</strong>
|
||||
{feed.description && (
|
||||
<div
|
||||
class="muted truncate"
|
||||
style="font-size: var(--font-size-sm); margin-top: 4px;"
|
||||
title={descHover}
|
||||
>
|
||||
{descDisplay}
|
||||
<div class="feed-title-cell">
|
||||
<img
|
||||
class="feed-icon"
|
||||
src={`/favicon/${feed.id}`}
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<strong class="truncate" title={titleHover}>
|
||||
{titleDisplay}
|
||||
</strong>
|
||||
{feed.description && (
|
||||
<div
|
||||
class="muted truncate"
|
||||
style="font-size: var(--font-size-sm); margin-top: 4px;"
|
||||
title={descHover}
|
||||
>
|
||||
{descDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{feed.id}</code>
|
||||
@@ -842,6 +864,14 @@ app.get("/", async (c) => {
|
||||
>
|
||||
<div class="feed-header">
|
||||
<h3 class="feed-title" title={titleHover}>
|
||||
<img
|
||||
class="feed-icon"
|
||||
src={`/favicon/${feed.id}`}
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
loading="lazy"
|
||||
/>
|
||||
{titleDisplay}
|
||||
</h3>
|
||||
{feed.expires_at && (
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "../index";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { iconKey } from "../utils/storage";
|
||||
import type { Env } from "../types";
|
||||
|
||||
function req(path: string): Request {
|
||||
return new Request(`https://test.getmynews.app${path}`);
|
||||
}
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 9, 8, 7]);
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
describe("project favicon", () => {
|
||||
it("serves an SVG favicon at /favicon.svg", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
@@ -27,3 +34,58 @@ describe("project favicon", () => {
|
||||
expect(body).toContain("<svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-feed favicon", () => {
|
||||
it("serves the cached domain icon when available", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [], iconDomain: "github.com" }),
|
||||
);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
iconKey("github.com"),
|
||||
JSON.stringify({ data: toBase64(PNG), contentType: "image/png" }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG);
|
||||
});
|
||||
|
||||
it("falls back to the project SVG when the feed has no icon domain", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [] }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
expect(await res.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("falls back to the project SVG for a negative cache entry", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [], iconDomain: "nope.test" }),
|
||||
);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
iconKey("nope.test"),
|
||||
JSON.stringify({ data: null, contentType: "" }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
});
|
||||
|
||||
it("falls back to the project SVG for an unknown feed", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const res = await worker.fetch(req("/favicon/missing"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
});
|
||||
});
|
||||
|
||||
+43
-1
@@ -1,5 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import { getFeedMetadata } from "../utils/storage";
|
||||
import { cacheFaviconForDomain, getCachedIcon } from "../utils/favicon-fetcher";
|
||||
|
||||
export const FAVICON_PATH = "/favicon.svg";
|
||||
|
||||
@@ -13,7 +15,7 @@ export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
export function handle(_c: Context<{ Bindings: Env }>): Response {
|
||||
function projectFavicon(): Response {
|
||||
return new Response(FAVICON_SVG, {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
@@ -21,3 +23,43 @@ export function handle(_c: Context<{ Bindings: Env }>): Response {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function handle(_c: Context<{ Bindings: Env }>): Response {
|
||||
return projectFavicon();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-feed favicon. Resolves the feed's most recent sender domain and serves
|
||||
* its cached icon; falls back to the project icon for any unresolved case
|
||||
* (no domain, cache miss, or negative cache entry).
|
||||
*/
|
||||
export async function handleFeedFavicon(
|
||||
c: Context<{ Bindings: Env }>,
|
||||
): Promise<Response> {
|
||||
const env = c.env;
|
||||
const feedId = c.req.param("feedId");
|
||||
if (!feedId) return projectFavicon();
|
||||
|
||||
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
|
||||
const domain = metadata?.iconDomain;
|
||||
if (!domain) return projectFavicon();
|
||||
|
||||
const icon = await getCachedIcon(domain, env);
|
||||
if (icon) {
|
||||
return new Response(icon.bytes, {
|
||||
headers: {
|
||||
"Content-Type": icon.contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Known domain but nothing cached yet: warm the cache in the background and
|
||||
// serve the fallback for now.
|
||||
try {
|
||||
c.executionCtx.waitUntil(cacheFaviconForDomain(domain, env));
|
||||
} catch {
|
||||
// No ExecutionContext (e.g. tests) — fallback is served regardless.
|
||||
}
|
||||
return projectFavicon();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user