Files
tessera-ctl/apps/api/src/favorites/favorites.controller.ts
T
schalli 7704372c3c feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000)
- favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne
  Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung
- FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen
  Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller
  Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle
- FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private
- T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan
  hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die
  Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf
  (best effort, nie blockierend)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:04:29 +02:00

204 lines
7.1 KiB
TypeScript

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=<iconVersion>`
* 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 <img> 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=<iconVersion>` (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 <img>-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);
}
}