feat(domains): Favicons laden und im Filter anzeigen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 08:17:44 +02:00
parent d004430854
commit a590cf0a57
8 changed files with 189 additions and 6 deletions

View File

@@ -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;

View File

@@ -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<string, string> = {
'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<string> {
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<string | null> {
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<void> {
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);
}

View File

@@ -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
);
}