7704372c3c
- 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>
204 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
}
|