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');
+ });
+
+});