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>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user