Files
tessera-ctl/apps/api/src/dashboard/dashboard-image-rules.ts
T
schalli 737974b653 feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck
- Prisma-Modell DashboardImage (bytea) mit Migration 20260921120000: Tabelle,
  Indizes, RLS ENABLE/FORCE und tenant_isolation_policy mit Benutzerdimension
- dashboard-image-rules.ts: detectImageMime ueber Magic Bytes (PNG/JPEG/GIF/
  WebP), Grenzen 5 MiB je Datei und 30 je Benutzer
- DashboardImagesService: list/upload/getBytes/remove, je Methode
  forTenant(prisma, tenantId, userId); Besitz = Mandant UND Benutzer, sonst 404
- DashboardImagesController unter dashboard/images: GET, POST (FileInterceptor
  image, 5 MiB, eine Datei), GET :id mit Content-Type aus dem erkannten Typ,
  Cache-Control private, nosniff, Content-Disposition inline ohne Dateinamen,
  CSP sandbox; DELETE :id
- CreateWidgetDto kennt 'picture-frame'
- Klassifikationsdokument: neues Paar dashboard-images.service.ts/
  dashboardImage; Bereichs- und Summenzeilen nachgemessen (dashboard 12->18,
  settings 3->4 und bug-reports waren in der Summe nie mitgezaehlt)
- Befund: Prisma-Bytes verlangt Uint8Array<ArrayBuffer>, multers Buffer wird
  ohne Zusicherung abgelehnt - Kopie per new Uint8Array(buffer) statt Cast

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:47:07 +02:00

52 lines
2.3 KiB
TypeScript

/**
* dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget
* (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die
* Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte
* anwenden und die Erkennung direkt an den Bytes testbar ist.
*
* Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung
* (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte
* HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier
* Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08).
*
* Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
*/
/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */
export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */
export const DASHBOARD_IMAGE_MAX_COUNT = 30;
export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a"
const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a"
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean {
if (buffer.length < offset + signature.length) return false;
for (let i = 0; i < signature.length; i++) {
if (buffer[offset + i] !== signature[i]) return false;
}
return true;
}
/**
* Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere
* (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`.
* Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild.
*/
export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null {
if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png';
if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg';
if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif';
if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp';
return null;
}