import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { detectImageMime } from '../dashboard/dashboard-image-rules'; /** * favorite-icon-files — reine Regeln und Ablage-Hilfen fuer ein * hochgeladenes Favoriten-Symbol (quick-260923-lrr). Kein Nest, kein * Prisma: Grenzen, Erkennung und Pfadbildung, damit Dienst und Controller * dieselben Werte anwenden und die Erkennung direkt an den Bytes testbar * ist — Muster `dashboard-image-rules.ts` (quick-260921-pi9). * * Warum ZUSAETZLICH ICO und SVG (ueber die vier Typen aus * `detectImageMime` hinaus): Favicons liegen haeufig als `.ico` vor, und * ein selbst gezeichnetes Symbol oft als `.svg`. Beide Formate haben keine * fuehrende Signatur wie PNG/JPEG/GIF/WebP im klassischen Sinn — ICO traegt * nur ein vier Byte langes Kopfstueck (Typ-Feld `0x0001`, NICHT `0x0002` = * CUR-Cursor-Dateien, die deshalb bewusst NICHT erkannt werden), SVG ist * Text und wird ueber eine Praefix-Pruefung erkannt (XML-Deklaration, * Kommentare, ein optionales DOCTYPE mit Wurzel `svg`, dann `` oder `/`. Alles andere (z. B. `` * vor ``, ein DOCTYPE auf `html`) ergibt kein Treffer. */ const SVG_PREFIX_RE = /^(?:<\?xml[^>]*\?>\s*)?(?:(?:|]*>)\s*)*/]/i; function startsWithIcoHeader(buffer: Uint8Array): boolean { if (buffer.length < 6) return false; for (let i = 0; i < ICO_HEADER.length; i++) { if (buffer[i] !== ICO_HEADER[i]) return false; } return true; } /** * Prueft die ersten 4096 Bytes als UTF-8 gegen `SVG_PREFIX_RE`. Ein * fuehrendes BOM oder Leerraum vor der eigentlichen Deklaration wird * entfernt, bevor die Praefix-Form geprueft wird. Wirft nie — ein Puffer, * der sich nicht als UTF-8 lesen laesst, ist schlicht kein SVG. */ function looksLikeSvg(buffer: Uint8Array): boolean { let text: string; try { text = Buffer.from(buffer.subarray(0, 4096)).toString('utf-8'); } catch { return false; } text = text.replace(/^/, '').replace(/^\s+/, ''); return SVG_PREFIX_RE.test(text); } /** * Erkennt PNG, JPEG, GIF, WebP (ueber `detectImageMime`), ICO und SVG an * den Bytes; alles andere ergibt `null`. Wirft nie. */ export function detectFavoriteIconMime(buffer: Uint8Array): FavoriteIconMime | null { const known = detectImageMime(buffer); if (known !== null) return known; if (startsWithIcoHeader(buffer)) return 'image/x-icon'; if (looksLikeSvg(buffer)) return 'image/svg+xml'; return null; } /** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */ export function favoriteIconExtension(mime: string): string | null { switch (mime) { case 'image/png': return 'png'; case 'image/jpeg': return 'jpg'; case 'image/gif': return 'gif'; case 'image/webp': return 'webp'; case 'image/x-icon': return 'ico'; case 'image/svg+xml': return 'svg'; default: return null; } } /** * Loest das Symbolverzeichnis relativ zur Monorepo-Wurzel auf — Muster * `resolveDashboardImagesDir()` (dashboard-images.service.ts): zur Laufzeit * ist `__dirname` = apps/api/dist/favorites/, also vier Ebenen hoch. * * `FAVORITE_ICONS_DIR` ist ein Testschalter und im Betrieb nie gesetzt; die * Tests zeigen damit auf ein Wegwerfverzeichnis unter `os.tmpdir()`. */ export function resolveFavoriteIconsDir(): string { const override = process.env.FAVORITE_ICONS_DIR; if (override !== undefined && override !== '') { return path.resolve(override); } return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons'); } /** Nur Buchstaben, Ziffern und Bindestrich — kein Segment aus der Anfrage geht ungeprueft in einen Pfad. */ const SAFE_SEGMENT_RE = /^[A-Za-z0-9-]+$/; /** * Bildet den absoluten Ablagepfad `//.`. * `null`, wenn der Typ unbekannt ist, `userId`/`id` nicht ausschliesslich aus * Buchstaben/Ziffern/Bindestrich bestehen (schliesst `..`, `/`, `\`, leere * Segmente aus), oder das Ergebnis nicht unter dem Symbolverzeichnis liegt * (T-LRR-01). Kein Byte aus der Anfrage — insbesondere nicht `originalname` * — geht je in diesen Pfad ein: `id` ist die Zeilen-UUID, `ext` kommt aus * dem an den Bytes ERKANNTEN Typ. */ export function favoriteIconAbsolutePath(userId: string, id: string, mime: string): string | null { const ext = favoriteIconExtension(mime); if (ext === null) return null; if (!SAFE_SEGMENT_RE.test(userId) || !SAFE_SEGMENT_RE.test(id)) return null; const base = resolveFavoriteIconsDir(); const absolute = path.resolve(base, userId, `${id}.${ext}`); if (absolute !== base && !absolute.startsWith(base + path.sep)) return null; return absolute; } /** * Entfernt die Symboldatei eines hochgeladenen Favoriten-Symbols, falls sie * existiert — best effort, wirft NIE (Muster T-HK4-04: eine Dateileiche ist * harmloser als eine haengende Operation). Fuer Aufrufer ausserhalb von * `FavoritesService`, deren Vorgang (Loeschen ueber Datenbank-Kaskade, * T-LRR-07) nicht an einem Dateifehler scheitern darf: `DashboardService` * beim Loeschen eines Widgets oder eines ganzen Reiters, siehe dortigen * Kommentar. Liefert `true`, wenn eine Datei tatsaechlich entfernt wurde * (fuer eine Protokollzeile beim Aufrufer), sonst `false` — auch das ist * kein Fehlerzustand: die Datei kann bereits gefehlt haben. */ export async function removeFavoriteIconFileBestEffort( userId: string, id: string, mime: string, ): Promise { const absolute = favoriteIconAbsolutePath(userId, id, mime); if (absolute === null) return false; try { await fs.unlink(absolute); return true; } catch { return false; } }