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:
Julien Herr
2026-05-23 14:05:14 +02:00
parent d299c8891d
commit eb12f21894
19 changed files with 592 additions and 30 deletions
+62
View File
@@ -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/);
});
});