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