Files
tessera-ctl/apps/api/src/favorites/favorite-icon-files.ts
T
schalli 7704372c3c 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>
2026-09-23 16:04:29 +02:00

174 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}