refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank
- Bytes liegen unter user-files/dashboard-images/<userId>/<id>.<ext>, die Zeile haelt nur noch storagePath (Muster User.avatarPath) - Dateiname immer servergeneriert: UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ, originalName kommt in keinem Pfad vor (T-HK4-01) - Migration 20260922120000: storagePath dazu, data wird NULLbar, kein DROP (zweistufig, T-HK4-03); system_read_policy fuer den Umzug - onApplicationBootstrap zieht Altbestand automatisch um: systemgebunden lesen, je Zeile mandantengebunden schreiben (Muster DKV-Planer) - Upload nimmt die Zeile bei fehlgeschlagenem Schreiben zurueck, Loeschen entfernt die Datei mit, fehlende Datei -> 404 (T-HK4-04) - 11 neue Dienst-Tests gegen ein echtes Temp-Verzeichnis (kein fs-Mock) - Zugriffsklassifikation: Stand system-gebunden, Zahlen nachgemessen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
@@ -10,20 +19,53 @@ import {
|
||||
|
||||
/**
|
||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9).
|
||||
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
||||
* Datenbank.
|
||||
*
|
||||
* Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND
|
||||
* gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen,
|
||||
* `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404)
|
||||
* ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration
|
||||
* 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als
|
||||
* Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS
|
||||
* (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich
|
||||
* hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die
|
||||
* `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie
|
||||
* ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem
|
||||
* Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null`
|
||||
* — die Antwort bleibt 404, nur der Weg dorthin aendert sich.
|
||||
* WO DIE BYTES LIEGEN (hk4): unter
|
||||
* `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
|
||||
* haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
|
||||
* `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
* (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
|
||||
* per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
|
||||
* Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
|
||||
* Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
|
||||
* Argument (ein Bild wird je Browser einmal taeglich geladen).
|
||||
*
|
||||
* DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
|
||||
* `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
|
||||
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
|
||||
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
|
||||
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
|
||||
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
|
||||
* wird nie ungeprueft an `path.join` gereicht.
|
||||
*
|
||||
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
|
||||
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
|
||||
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
|
||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||
*
|
||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||
*
|
||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
|
||||
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
|
||||
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
|
||||
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
|
||||
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
|
||||
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
|
||||
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
|
||||
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
|
||||
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
|
||||
* dorthin aendert sich.
|
||||
*
|
||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||
@@ -31,9 +73,7 @@ import {
|
||||
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
||||
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
||||
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
||||
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
|
||||
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
|
||||
* einem HTTP-Header (T-PI9-06).
|
||||
* `detectImageMime` an den Bytes erkannt hat.
|
||||
*
|
||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||
@@ -47,7 +87,10 @@ import {
|
||||
|
||||
const ORIGINAL_NAME_MAX = 255;
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
|
||||
/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
|
||||
const STORAGE_PREFIX = 'user-files/dashboard-images/';
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
|
||||
const META_SELECT = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
@@ -64,10 +107,124 @@ export interface DashboardImageMeta {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
|
||||
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
|
||||
*
|
||||
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
|
||||
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
|
||||
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
|
||||
*/
|
||||
export function resolveDashboardImagesDir(): string {
|
||||
const override = process.env.DASHBOARD_IMAGES_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
function extensionFor(mimeType: string): string | null {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
|
||||
function relativeStoragePath(userId: string, id: string, extension: string): string {
|
||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
|
||||
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
|
||||
*/
|
||||
function absoluteImagePath(storagePath: string): string | null {
|
||||
const normalized = storagePath.split('\\').join('/');
|
||||
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
|
||||
|
||||
const base = resolveDashboardImagesDir();
|
||||
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService {
|
||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DashboardImagesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||
* Betreiber nichts von Hand ausfuehren muss.
|
||||
*
|
||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||
* `.planning/todos/pending/`).
|
||||
*
|
||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||
* DKV-Planer.
|
||||
*
|
||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||
* wird protokolliert und haelt den Start nicht auf.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const pending = await systemPrisma.dashboardImage.findMany({
|
||||
where: { storagePath: null },
|
||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let moved = 0;
|
||||
for (const row of pending) {
|
||||
if (row.data === null) continue;
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: row.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -80,7 +237,13 @@ export class DashboardImagesService {
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||
* begrenzt, gespeichert wird der erkannte Typ.
|
||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||
* die Zeile haelt den Pfad.
|
||||
*
|
||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||
* Zeile ohne Datei.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
@@ -102,26 +265,41 @@ export class DashboardImagesService {
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.dashboardImage.create({
|
||||
const created = await tenantPrisma.dashboardImage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||
mimeType,
|
||||
size: file.buffer.length,
|
||||
// Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
|
||||
// `Uint8Array<ArrayBuffer>`, multers `Buffer` ist aber ueber
|
||||
// `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
|
||||
// wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
|
||||
// einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
|
||||
// und ist damit ehrlich getypt statt zugesichert.
|
||||
data: new Uint8Array(file.buffer),
|
||||
},
|
||||
select: META_SELECT,
|
||||
});
|
||||
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: created.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
|
||||
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
|
||||
/**
|
||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||
*/
|
||||
async getBytes(
|
||||
id: string,
|
||||
userId: string,
|
||||
@@ -132,10 +310,31 @@ export class DashboardImagesService {
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute === null) {
|
||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(absolute);
|
||||
return { mimeType: row.mimeType, data };
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
|
||||
/**
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
@@ -143,6 +342,47 @@ export class DashboardImagesService {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||
* dabei (`recursive: true`).
|
||||
*/
|
||||
private async writeImageFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
|
||||
const storagePath = relativeStoragePath(userId, id, extension);
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user