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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user