feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe

- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000)
- favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne
  Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung
- FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen
  Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller
  Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle
- FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private
- T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan
  hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die
  Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf
  (best effort, nie blockierend)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
@@ -0,0 +1,173 @@
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 `<svg` selbst).
*
* Wie bei `dashboard-image-rules.ts` (T-PI9-01/T-PI9-08): was hier NICHT
* erkannt wird, kommt nicht auf die Platte — und der erkannte Typ ist
* zugleich der Typ, mit dem `GET /favorites/:id/icon` spaeter antwortet.
*
* Bewusst KEIN `file-type`-Paket (Muster T-PI9-SC): sechs feste Regeln sind
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
*/
/** Hoechstgroesse je Datei: 512 KiB (multer `limits.fileSize` an der Route, zweites Netz im Dienst). */
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
export type FavoriteIconMime =
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/webp'
| 'image/x-icon'
| 'image/svg+xml';
/** ICO-Kopfstueck: Reserviert=0, Typ=1 (Icon). Typ=2 waere CUR (Cursor) — bewusst NICHT erkannt. */
const ICO_HEADER = [0x00, 0x00, 0x01, 0x00];
/**
* Praefix-Form eines SVG-Dokuments: optionales BOM/Leerraum, optionale
* XML-Deklaration, beliebig viele Kommentare und/oder ein DOCTYPE mit
* Wurzel `svg` (in beliebiger Reihenfolge/Wiederholung), danach `<svg`
* direkt gefolgt von Leerraum, `>` oder `/`. Alles andere (z. B. `<html>`
* vor `<svg>`, ein DOCTYPE auf `html`) ergibt kein Treffer.
*/
const SVG_PREFIX_RE =
/^(?:<\?xml[^>]*\?>\s*)?(?:(?:<!--[\s\S]*?-->|<!DOCTYPE\s+svg\b[^>]*>)\s*)*<svg[\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 `<resolveFavoriteIconsDir()>/<userId>/<id>.<ext>`.
* `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<boolean> {
const absolute = favoriteIconAbsolutePath(userId, id, mime);
if (absolute === null) return false;
try {
await fs.unlink(absolute);
return true;
} catch {
return false;
}
}