Files
tessera-ctl/apps/api/src/dashboard/dashboard-images.controller.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

88 lines
3.3 KiB
TypeScript

import {
Controller,
Delete,
Get,
Param,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
import { DashboardImagesService } from './dashboard-images.service';
/**
* Bilder des Bilderrahmen-Widgets (quick-260921-pi9).
*
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
* (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt
* weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem
* Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der
* Multipart-Rumpf hat genau das Feld `image`.
*
* Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine
* Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die
* deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status).
* `main.ts` bleibt ohne globales Body-Limit.
*
* Routen (statische VOR `:id`, sonst 404-Shadowing):
* - GET /dashboard/images — eigene Bilder, nur Metadaten
* - POST /dashboard/images — Bild hochladen (multipart-Feld `image`)
* - GET /dashboard/images/:id — Bytes eines eigenen Bildes
* - DELETE /dashboard/images/:id — eigenes Bild loeschen
*/
@Controller('dashboard/images')
export class DashboardImagesController {
constructor(private readonly service: DashboardImagesService) {}
@Get()
async list(@CurrentUser() user: AuthUser) {
return this.service.list(user.id, user.tenantId);
}
@Post()
@UseInterceptors(
FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }),
)
async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) {
return this.service.upload(user, file);
}
/**
* Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der
* gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das
* Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen
* (`originalName` gehoert in keinen Header), `private` haelt gemeinsame
* Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im
* Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster
* favorites.controller.ts `getIcon`).
*
* Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber
* denselben Speicher, keine Kopie, keine Zusicherung.
*/
@Get(':id')
async getImage(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
@Res() res: Response,
) {
const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId);
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'private, max-age=86400');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', 'inline');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
}
@Delete(':id')
async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
return this.service.remove(id, user.id, user.tenantId);
}
}