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:
@@ -1,15 +1,27 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* 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,
|
||||
@@ -72,11 +106,31 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -99,8 +153,11 @@ export class FavoritesService {
|
||||
const url = normalizeUrl(dto.url);
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
if (!iconUrl) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -121,6 +178,11 @@ export class FavoritesService {
|
||||
* 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);
|
||||
@@ -142,6 +204,10 @@ export class FavoritesService {
|
||||
|
||||
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 {
|
||||
@@ -153,6 +219,10 @@ export class FavoritesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||
data.iconVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -162,6 +232,10 @@ export class FavoritesService {
|
||||
/**
|
||||
* 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);
|
||||
@@ -172,6 +246,10 @@ export class FavoritesService {
|
||||
}
|
||||
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
|
||||
if (link.uploadedIconMime !== null) {
|
||||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,16 +320,148 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 no icon on record. Throws a 502 HttpException
|
||||
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
||||
* SSRF-blocked) -- never returns a placeholder image.
|
||||
* 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,
|
||||
@@ -261,7 +471,29 @@ export class FavoritesService {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user