Files
tessera-ctl/apps/api/src/favorites/favorites.service.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

507 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
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,
detectFavoriteIconMime,
favoriteIconAbsolutePath,
} from './favorite-icon-files';
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
/**
* Service for managing per-user, per-widget favorite links.
*
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
*
* Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
* Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
* `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
* Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
*
* Access control (T-08-06 / Pitfall 3):
* - Every query is scoped by userId (prevents cross-user access).
* - list() additionally scopes by widgetId so each widget instance has its own set.
* - update() and remove() verify userId ownership before mutating.
* - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
* every updateMany by userId AND widgetId (see reorder() doc below).
*
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
*
* 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe:
* - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4):
* `user-files/favorite-icons/<userId>/<id>.<ext>`, Dateiname IMMER aus
* Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01).
* - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer
* die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ,
* wird protokolliert und auf `iconUrl` zurueckgefallen.
* - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich
* die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload;
* Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL.
* - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
*/
@Injectable()
export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iconDiscovery: IconDiscoveryService,
) {}
/**
* Returns all favorites for a user's widget instance, ordered by position asc.
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
*/
async list(tenantId: string, userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required');
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.favoriteLink.findMany({
where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
}
/**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
* sich etwas ueber die gepruefte Adresse ablesen liesse.
*/
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
try {
await this.iconDiscovery.fetchIconBytes(iconUrl);
} catch {
throw new UnprocessableEntityException(
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
);
}
}
/**
* Creates a new favorite link.
* Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
* If iconUrl IS provided (260923-lrr), it must load successfully or the
* create is rejected with 422 — nothing is written on a failed probe.
*/
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id: dto.widgetId },
select: { userId: true },
});
if (!widget || widget.userId !== userId) {
throw new NotFoundException('Widget not found');
}
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
// as "https://ctl.de" — otherwise the link and icon discovery both break.
const url = normalizeUrl(dto.url);
let iconUrl = dto.iconUrl ?? null;
if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
await this.assertIconUrlLoadable(iconUrl);
} else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
}
return tenantPrisma.favoriteLink.create({
data: {
userId,
tenantId,
widgetId: dto.widgetId,
title: dto.title,
url,
iconUrl,
position: dto.position ?? 0,
},
});
}
/**
* Updates an existing favorite.
* Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon).
*
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
*/
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
}
const data: Record<string, unknown> = {};
if (dto.title !== undefined) data.title = dto.title;
const normalizedUrl =
dto.url !== undefined ? normalizeUrl(dto.url) : undefined;
if (normalizedUrl !== undefined) data.url = normalizedUrl;
if (dto.position !== undefined) data.position = dto.position;
if ('iconUrl' in dto) {
if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
await this.assertIconUrlLoadable(dto.iconUrl);
}
// Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl;
} else {
// Icon cleared (empty/null) — re-run discovery against the effective
// (new or existing) url so editing a broken favorite repairs its icon.
const effectiveUrl = normalizedUrl ?? link.url;
data.iconUrl =
await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl);
}
}
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
data.iconVersion = { increment: 1 };
}
return tenantPrisma.favoriteLink.update({
where: { id },
data,
});
}
/**
* Deletes a favorite link.
* Verifies userId ownership before deleting (T-08-06).
* 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei
* NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird
* protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile
* gelingt in jedem Fall.
*/
async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
}
await tenantPrisma.favoriteLink.delete({ where: { id } });
if (link.uploadedIconMime !== null) {
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
}
}
/**
* Persists the display order of a user's favorites for one widget
* instance (260917-jdd, PUT /favorites/order).
*
* Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
* einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
* (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
* `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
* gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
* faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
* setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
* — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
* und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
* `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
* dieser Klasse es fuer alle Methoden vorsieht).
*
* `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
* `id` filtern koennte — der Ownership-Check muesste dann als separater
* Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
* ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
* der Schreiboperation und liefert `count`, das sofort geprueft wird.
*
* Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
* DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
* fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
* Grund zutraf (Muster T-GWH-05).
*
* Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
* keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
* kein Migrations- oder Sonderpfad noetig.
*/
async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
if (new Set(dto.ids).size !== dto.ids.length) {
throw new BadRequestException('ids must match the favorites of this widget exactly');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
const existing = await tx.favoriteLink.findMany({
where: { userId, widgetId: dto.widgetId },
select: { id: true },
});
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
if (
existing.length !== dto.ids.length ||
dto.ids.some((id) => !existingIds.has(id))
) {
throw new BadRequestException('ids must match the favorites of this widget exactly');
}
for (const [index, id] of dto.ids.entries()) {
const { count } = await tx.favoriteLink.updateMany({
where: { id, userId, widgetId: dto.widgetId },
data: { position: index },
});
if (count !== 1) {
throw new BadRequestException('ids must match the favorites of this widget exactly');
}
}
return tx.favoriteLink.findMany({
where: { userId, widgetId: dto.widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
});
});
}
/**
* Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge
* (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich
* ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile —
* scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen.
* Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte
* Datei danach entfernt (Fehler protokolliert und geschluckt).
*/
async uploadIcon(
tenantId: string,
id: string,
userId: string,
file: UploadedFileLike | undefined,
) {
if (!file) {
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
}
if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) {
// Zweites Netz — multer (`limits.fileSize` an der Route) faengt das
// in der Regel bereits vorher ab.
throw new PayloadTooLargeException(
'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.',
);
}
const mime = detectFavoriteIconMime(file.buffer);
if (mime === null) {
throw new BadRequestException(
'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
throw new NotFoundException('FavoriteLink not found');
}
const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime);
if (absolute === null) {
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
}
try {
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, file.buffer);
} catch (error) {
this.logger.error(
`Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
}
const previousMime = link.uploadedIconMime;
let updated: typeof link;
try {
updated = await tenantPrisma.favoriteLink.update({
where: { id },
data: { uploadedIconMime: mime, iconVersion: { increment: 1 } },
});
} catch (error) {
// Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen,
// wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt —
// sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die
// weiterhin gueltige alte Datei loeschen.
if (previousMime !== mime) {
await fs.unlink(absolute).catch(() => undefined);
}
throw error;
}
if (previousMime !== null && previousMime !== mime) {
await this.removeIconFile(id, link.userId, previousMime, 'alte');
}
return updated;
}
/**
* Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes
* `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein
* unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt,
* ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04).
*/
async removeUploadedIcon(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
throw new NotFoundException('FavoriteLink not found');
}
if (link.uploadedIconMime === null) {
return link;
}
const previousMime = link.uploadedIconMime;
const updated = await tenantPrisma.favoriteLink.update({
where: { id },
data: { uploadedIconMime: null, iconVersion: { increment: 1 } },
});
await this.removeIconFile(id, link.userId, previousMime, 'entfernte');
return updated;
}
/** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */
private async removeIconFile(
favoriteId: string,
userId: string,
mime: string,
label: string,
): Promise<void> {
const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime);
if (absolute === null) return;
try {
await fs.unlink(absolute);
} catch (error) {
this.logger.warn(
`${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Fetches the raw bytes of a favorite's stored icon, scoped to the
* requesting user (T-08-06 — same ownership check as update/remove).
* Never accepts a client-supplied URL — only the stored iconUrl on a
* row the caller owns is fetched (T-QFIP-01).
*
* 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt
* die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird
* protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen.
*
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
*/
async getIconBytes(
tenantId: string,
id: string,
userId: string,
): Promise<{ contentType: string; body: Buffer }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
throw new NotFoundException('FavoriteLink not found');
}
if (link.uploadedIconMime !== null) {
const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime);
if (absolute !== null) {
try {
const body = await fs.readFile(absolute);
return { contentType: link.uploadedIconMime, body };
} catch (error) {
this.logger.warn(
`Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
} else {
this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`);
}
}
if (!link.iconUrl) {
throw new NotFoundException('FavoriteLink not found');
}
try {
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
} catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
}
}
}