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>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
+51 -3
View File
@@ -12,12 +12,16 @@ import {
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
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';
/**
@@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service';
* - 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
* - 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)
*/
@@ -122,7 +129,12 @@ export class FavoritesController {
);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400');
// 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
@@ -132,6 +144,42 @@ export class FavoritesController {
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,