737974b653
- 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>
88 lines
3.3 KiB
TypeScript
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);
|
|
}
|
|
}
|