98 lines
3.1 KiB
TypeScript
98 lines
3.1 KiB
TypeScript
|
|
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);
|
||
|
|
}
|