import { Body, Controller, Delete, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Query, Req, Res, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files'; import { FavoritesService } from './favorites.service'; /** * REST controller for per-user, per-widget favorite links. * * All routes are protected by the global JwtAuthGuard + TenantGuard. * * Mandantenquelle (260911-gwh): `extractContext` liest `req.tenantId ?? * req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts`, unter * dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt ueber * `widgetId` an `WidgetInstance`; eine andere Quelle (z. B. das Claim, wie * bei `auth` fuer Selbstbedienung) wuerde Widget und Link unter einem * `x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten * auseinanderreissen. Das Favoriten-Frontend sendet die `x-tenant-id`- * Kopfzeile heute nicht — die Entscheidung haengt an der Bauform, nicht am * heutigen Aufrufer. * * Routes: * - GET /favorites?widgetId= — list favorites for a widget instance * - POST /favorites — create a favorite (triggers server-side icon discovery) * - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd) * - GET /favorites/:id/icon — stream a favorite's stored icon bytes (append `?v=` * client-side to bust the 24h cache after any change to the icon source, 260923-lrr) * - POST /favorites/:id/icon — upload a custom icon (multipart field `icon`, ≤512 KB, 260923-lrr) * - DELETE /favorites/:id/icon — remove a previously uploaded icon (260923-lrr) * - PATCH /favorites/:id — update a favorite (ownership verified in service) * - DELETE /favorites/:id — delete a favorite (ownership verified in service) */ @Controller('favorites') export class FavoritesController { constructor(private readonly favoritesService: FavoritesService) {} private extractContext(req: AuthenticatedRequest) { const userId = req.user?.id; const tenantId = req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } if (!userId) { throw new ForbiddenException('No user context'); } return { userId, tenantId }; } @Get() async list( @Query('widgetId', ParseUUIDPipe) widgetId: string, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.list(tenantId, userId, widgetId); } @Post() async create( @Body() dto: CreateFavoriteDto, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.create(tenantId, userId, dto); } /** * PUT /favorites/order — persists the display order for a widget's * favorites (260917-jdd). Declared BEFORE the `:id` routes below on * purpose (NestJS route order — a later `:id` route would otherwise * shadow the literal segment "order"; precedent tenders.controller.ts * Z. 636-648). */ @Put('order') async reorder( @Body() dto: ReorderFavoritesDto, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.reorder(tenantId, userId, dto); } /** * GET /favorites/:id/icon — streams the stored icon bytes for a favorite * owned by the caller, from Tessera's own origin. This avoids the browser * blocking a cross-origin hotlink when the external site sends * Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai). * * Takes only a FavoriteLink id — never a client-supplied URL — so this * cannot be used as an arbitrary-URL SSRF proxy (T-QFIP-01). */ @Get(':id/icon') async getIcon( @Param('id') id: string, @Req() req: AuthenticatedRequest, @Res() res: Response, ) { const { userId, tenantId } = this.extractContext(req); const { contentType, body } = await this.favoritesService.getIconBytes( tenantId, id, userId, ); res.setHeader('Content-Type', contentType); // 260923-lrr: private statt public — kein gemeinsamer Zwischenspeicher // (Nginx Proxy Manager) haelt benutzerbezogene Symbole vor. Die Adresse // traegt clientseitig `?v=` (T-LRR-05), damit der lange // 24h-Browser-Zwischenspeicher nach einer Aenderung trotzdem sofort // ungueltig wird. res.setHeader('Cache-Control', 'private, max-age=86400'); // 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges // Zertifikat. Als -Unterressource ignoriert der Browser diese // Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne // Skript und ohne Tessera-Origin (T-JDD-02). res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); res.send(body); } /** * POST /favorites/:id/icon — laedt ein eigenes Symbol fuer einen * Favoriten hoch (260923-lrr). Groessengrenze JE ROUTE (Muster * `dashboard-images.controller.ts` T-PI9-02): `FileInterceptor` nimmt * genau eine Datei bis 512 KB; multers `LIMIT_FILE_SIZE` bildet Nest auf * 413 ab. Typ und Besitzpruefung laufen im Dienst (T-LRR-01/T-LRR-03). */ @Post(':id/icon') @UseInterceptors( FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }), ) async uploadIcon( @Param('id', ParseUUIDPipe) id: string, @Req() req: AuthenticatedRequest, @UploadedFile() file?: UploadedFileLike, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.uploadIcon(tenantId, id, userId, file); } /** * DELETE /favorites/:id/icon — entfernt ein zuvor hochgeladenes Symbol * wieder; die Kachel faellt danach auf `iconUrl` bzw. automatische * Erkennung zurueck (260923-lrr). */ @Delete(':id/icon') async removeUploadedIcon( @Param('id', ParseUUIDPipe) id: string, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.removeUploadedIcon(tenantId, id, userId); } @Patch(':id') async update( @Param('id') id: string, @Body() dto: UpdateFavoriteDto, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.update(tenantId, id, userId, dto); } @Delete(':id') async remove( @Param('id') id: string, @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.favoritesService.remove(tenantId, id, userId); } }