feat(domains): Favicons laden und im Filter anzeigen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
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:
@@ -95,6 +95,11 @@
|
|||||||
<span class="box" class:on={isOn}>
|
<span class="box" class:on={isOn}>
|
||||||
{#if isOn}<Check size={14} strokeWidth={3} />{/if}
|
{#if isOn}<Check size={14} strokeWidth={3} />{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{#if d.favicon_path}
|
||||||
|
<img class="favicon" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="favicon fallback" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
<span class="dom">{d.display_name ?? d.domain}</span>
|
<span class="dom">{d.display_name ?? d.domain}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -218,6 +223,17 @@
|
|||||||
background: #2b6a3d;
|
background: #2b6a3d;
|
||||||
border-color: #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 {
|
.dom {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal file
5
src/lib/server/db/migrations/009_domain_favicon.sql
Normal 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;
|
||||||
97
src/lib/server/domains/favicons.ts
Normal file
97
src/lib/server/domains/favicons.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ export function normalizeDomain(raw: string): string {
|
|||||||
|
|
||||||
export function listDomains(db: Database.Database): AllowedDomain[] {
|
export function listDomains(db: Database.Database): AllowedDomain[] {
|
||||||
return db
|
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[];
|
.all() as AllowedDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export function addDomain(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
`INSERT INTO allowed_domain(domain, display_name, added_by_profile_id)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
RETURNING id, domain, display_name`
|
RETURNING id, domain, display_name, favicon_path`
|
||||||
)
|
)
|
||||||
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
.get(normalized, displayName, addedByProfileId) as AllowedDomain;
|
||||||
return row;
|
return row;
|
||||||
@@ -31,3 +33,14 @@ export function addDomain(
|
|||||||
export function removeDomain(db: Database.Database, id: number): void {
|
export function removeDomain(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM allowed_domain WHERE id = ?').run(id);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export type AllowedDomain = {
|
|||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
|
favicon_path: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { RequestHandler } from './$types';
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
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({
|
const CreateSchema = z.object({
|
||||||
domain: z.string().min(3).max(253),
|
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()
|
added_by_profile_id: z.number().int().positive().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
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 }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
@@ -19,12 +25,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const parsed = CreateSchema.safeParse(body);
|
const parsed = CreateSchema.safeParse(body);
|
||||||
if (!parsed.success) error(400, { message: 'Invalid body' });
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
try {
|
try {
|
||||||
|
const db = getDb();
|
||||||
const d = addDomain(
|
const d = addDomain(
|
||||||
getDb(),
|
db,
|
||||||
parsed.data.domain,
|
parsed.data.domain,
|
||||||
parsed.data.display_name ?? null,
|
parsed.data.display_name ?? null,
|
||||||
parsed.data.added_by_profile_id ?? 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 });
|
return json(d, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(409, { message: (e as Error).message });
|
error(409, { message: (e as Error).message });
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const MIME: Record<string, string> = {
|
|||||||
'.png': 'image/png',
|
'.png': 'image/png',
|
||||||
'.webp': 'image/webp',
|
'.webp': 'image/webp',
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
'.avif': 'image/avif'
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: RequestHandler = ({ params }) => {
|
export const GET: RequestHandler = ({ params }) => {
|
||||||
|
|||||||
35
tests/integration/favicons.test.ts
Normal file
35
tests/integration/favicons.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user