From a590cf0a57512a36aa463110f7f5b9290680065e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:17:44 +0200 Subject: [PATCH] feat(domains): Favicons laden und im Filter anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Für jede Whitelist-Domain wird das Favicon jetzt einmalig geladen und im image-Verzeichnis abgelegt. SearchFilter zeigt das Icon neben dem Domain-Namen im Filter-Dropdown. - Migration 009: allowed_domain.favicon_path (NULL = noch nicht geladen). - Neues Modul $lib/server/domains/favicons.ts: fetchAndStoreFavicon(domain, imageDir) + ensureFavicons(db, imageDir) für Bulk-Nachzug; 8 parallele Worker mit 3s-Timeout. - Reihenfolge: erst /favicon.ico der Domain, Fallback Google-Service. - GET /api/domains zieht fehlende Favicons auf Abruf nach; POST /api/domains lädt direkt im selben Call. - .ico + .svg jetzt in der /images/[filename]-Route erlaubt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/components/SearchFilter.svelte | 16 +++ .../db/migrations/009_domain_favicon.sql | 5 + src/lib/server/domains/favicons.ts | 97 +++++++++++++++++++ src/lib/server/domains/repository.ts | 17 +++- src/lib/types.ts | 1 + src/routes/api/domains/+server.ts | 20 +++- src/routes/images/[filename]/+server.ts | 4 +- tests/integration/favicons.test.ts | 35 +++++++ 8 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 src/lib/server/db/migrations/009_domain_favicon.sql create mode 100644 src/lib/server/domains/favicons.ts create mode 100644 tests/integration/favicons.test.ts diff --git a/src/lib/components/SearchFilter.svelte b/src/lib/components/SearchFilter.svelte index fc769d6..f1883a5 100644 --- a/src/lib/components/SearchFilter.svelte +++ b/src/lib/components/SearchFilter.svelte @@ -95,6 +95,11 @@ {#if isOn}{/if} + {#if d.favicon_path} + + {:else} + + {/if} {d.display_name ?? d.domain} @@ -218,6 +223,17 @@ background: #2b6a3d; border-color: #2b6a3d; } + .favicon { + width: 18px; + height: 18px; + border-radius: 3px; + object-fit: contain; + flex-shrink: 0; + } + .favicon.fallback { + background: #eef3ef; + display: inline-block; + } .dom { flex: 1; overflow: hidden; diff --git a/src/lib/server/db/migrations/009_domain_favicon.sql b/src/lib/server/db/migrations/009_domain_favicon.sql new file mode 100644 index 0000000..0c95c3f --- /dev/null +++ b/src/lib/server/db/migrations/009_domain_favicon.sql @@ -0,0 +1,5 @@ +-- Speichert das Favicon-Dateiname für jede Whitelist-Domain, damit die +-- UI (Filter-Dropdown, Karten) das Site-Icon neben dem Domain-Namen +-- anzeigen kann. NULL = noch nicht geladen; wird beim nächsten GET +-- /api/domains automatisch nachgezogen. +ALTER TABLE allowed_domain ADD COLUMN favicon_path TEXT; diff --git a/src/lib/server/domains/favicons.ts b/src/lib/server/domains/favicons.ts new file mode 100644 index 0000000..34bb576 --- /dev/null +++ b/src/lib/server/domains/favicons.ts @@ -0,0 +1,97 @@ +import type Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fetchBuffer } from '../http'; +import { listDomains, setDomainFavicon } from './repository'; + +const EXT_BY_CONTENT_TYPE: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/svg+xml': '.svg', + 'image/x-icon': '.ico', + 'image/vnd.microsoft.icon': '.ico' +}; + +function extensionFor(contentType: string | null): string { + if (!contentType) return '.ico'; + const base = contentType.split(';')[0].trim().toLowerCase(); + return EXT_BY_CONTENT_TYPE[base] ?? '.ico'; +} + +async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: string | null } | null> { + try { + const res = await fetchBuffer(url, { timeoutMs: 3_000, maxBytes: 256 * 1024 }); + if (res.data.byteLength === 0) return null; + return res; + } catch { + return null; + } +} + +async function fetchFaviconBytes( + domain: string +): Promise<{ data: Uint8Array; contentType: string | null } | null> { + // 1. Versuche /favicon.ico direkt (klassisch, funktioniert auf fast allen Seiten). + const direct = await tryFetch(`https://${domain}/favicon.ico`); + if (direct) return direct; + // 2. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas und + // geben SVG/PNG in der gewünschten Größe. + return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`); +} + +async function persist( + data: Uint8Array, + contentType: string | null, + imageDir: string +): Promise { + const hash = createHash('sha256').update(data).digest('hex'); + const ext = extensionFor(contentType); + const filename = `favicon-${hash}${ext}`; + const target = join(imageDir, filename); + if (!existsSync(target)) { + await mkdir(imageDir, { recursive: true }); + await writeFile(target, data); + } + return filename; +} + +export async function fetchAndStoreFavicon( + domain: string, + imageDir: string +): Promise { + const result = await fetchFaviconBytes(domain); + if (!result) return null; + try { + return await persist(result.data, result.contentType, imageDir); + } catch { + return null; + } +} + +// Lädt Favicons für alle Whitelist-Domains, bei denen noch keines gespeichert +// ist. Parallel mit Limit 8. Bleibt bewusst sync vom Aufrufer aus gesehen, +// damit der erste GET /api/domains eine vollständige Liste zurückgibt. +// Beim zweiten Request ist nichts mehr zu tun. +export async function ensureFavicons( + db: Database.Database, + imageDir: string +): Promise { + const domains = listDomains(db).filter((d) => !d.favicon_path); + if (domains.length === 0) return; + const queue = [...domains]; + const LIMIT = 8; + const workers = Array.from({ length: Math.min(LIMIT, queue.length) }, async () => { + while (queue.length > 0) { + const d = queue.shift(); + if (!d) break; + const path = await fetchAndStoreFavicon(d.domain, imageDir); + if (path) setDomainFavicon(db, d.id, path); + } + }); + await Promise.all(workers); +} diff --git a/src/lib/server/domains/repository.ts b/src/lib/server/domains/repository.ts index 9dc9a18..3d2b83d 100644 --- a/src/lib/server/domains/repository.ts +++ b/src/lib/server/domains/repository.ts @@ -7,7 +7,9 @@ export function normalizeDomain(raw: string): string { export function listDomains(db: Database.Database): AllowedDomain[] { return db - .prepare('SELECT id, domain, display_name FROM allowed_domain ORDER BY domain') + .prepare( + 'SELECT id, domain, display_name, favicon_path FROM allowed_domain ORDER BY domain' + ) .all() as AllowedDomain[]; } @@ -22,7 +24,7 @@ export function addDomain( .prepare( `INSERT INTO allowed_domain(domain, display_name, added_by_profile_id) VALUES (?, ?, ?) - RETURNING id, domain, display_name` + RETURNING id, domain, display_name, favicon_path` ) .get(normalized, displayName, addedByProfileId) as AllowedDomain; return row; @@ -31,3 +33,14 @@ export function addDomain( export function removeDomain(db: Database.Database, id: number): void { db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id); } + +export function setDomainFavicon( + db: Database.Database, + id: number, + faviconPath: string | null +): void { + db.prepare('UPDATE allowed_domain SET favicon_path = ? WHERE id = ?').run( + faviconPath, + id + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3129f66..c2d63d2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -41,4 +41,5 @@ export type AllowedDomain = { id: number; domain: string; display_name: string | null; + favicon_path: string | null; }; diff --git a/src/routes/api/domains/+server.ts b/src/routes/api/domains/+server.ts index 766e037..7bd3b7d 100644 --- a/src/routes/api/domains/+server.ts +++ b/src/routes/api/domains/+server.ts @@ -2,7 +2,8 @@ import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; -import { addDomain, listDomains } from '$lib/server/domains/repository'; +import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository'; +import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons'; const CreateSchema = z.object({ domain: z.string().min(3).max(253), @@ -10,8 +11,13 @@ const CreateSchema = z.object({ added_by_profile_id: z.number().int().positive().nullable().optional() }); +const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images'; + export const GET: RequestHandler = async () => { - return json(listDomains(getDb())); + const db = getDb(); + // Favicons lazy nachziehen — beim zweiten Aufruf gibt es nichts mehr zu tun. + await ensureFavicons(db, IMAGE_DIR); + return json(listDomains(db)); }; export const POST: RequestHandler = async ({ request }) => { @@ -19,12 +25,20 @@ export const POST: RequestHandler = async ({ request }) => { const parsed = CreateSchema.safeParse(body); if (!parsed.success) error(400, { message: 'Invalid body' }); try { + const db = getDb(); const d = addDomain( - getDb(), + db, parsed.data.domain, parsed.data.display_name ?? null, parsed.data.added_by_profile_id ?? null ); + // Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das + // Icon enthält — der POST ist eh ein interaktiver Admin-Vorgang. + const favicon = await fetchAndStoreFavicon(d.domain, IMAGE_DIR); + if (favicon) { + setDomainFavicon(db, d.id, favicon); + d.favicon_path = favicon; + } return json(d, { status: 201 }); } catch (e) { error(409, { message: (e as Error).message }); diff --git a/src/routes/images/[filename]/+server.ts b/src/routes/images/[filename]/+server.ts index 45bb136..9ad634e 100644 --- a/src/routes/images/[filename]/+server.ts +++ b/src/routes/images/[filename]/+server.ts @@ -11,7 +11,9 @@ const MIME: Record = { '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', - '.avif': 'image/avif' + '.avif': 'image/avif', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml' }; export const GET: RequestHandler = ({ params }) => { diff --git a/tests/integration/favicons.test.ts b/tests/integration/favicons.test.ts new file mode 100644 index 0000000..d246a9f --- /dev/null +++ b/tests/integration/favicons.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { addDomain, listDomains } from '../../src/lib/server/domains/repository'; +import { ensureFavicons } from '../../src/lib/server/domains/favicons'; + +let imageDir: string; + +beforeEach(() => { + imageDir = mkdtempSync(join(tmpdir(), 'kochwas-favicon-')); +}); + +afterEach(() => { + rmSync(imageDir, { recursive: true, force: true }); +}); + +describe('ensureFavicons', () => { + it('is a no-op when every domain already has a favicon_path', async () => { + const db = openInMemoryForTest(); + const d = addDomain(db, 'example.com'); + // simulate already-stored favicon + db.prepare('UPDATE allowed_domain SET favicon_path = ? WHERE id = ?').run( + 'favicon-abc.png', + d.id + ); + await ensureFavicons(db, imageDir); + // No file written, no DB state changed + expect(readdirSync(imageDir).length).toBe(0); + const domains = listDomains(db); + expect(domains[0].favicon_path).toBe('favicon-abc.png'); + }); + +});