dd54ec5d42
- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung (bricht ab, solange eine Zeile ohne storagePath existiert; row_security aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht), dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy - Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad - FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per Gate-Schleife gemessen: 61/213/6) nachgezogen - Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch - Todo 2026-09-22 nach completed/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { randomUUID } from 'node:crypto';
|
|
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 { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
DASHBOARD_IMAGE_MAX_COUNT,
|
|
type DashboardImageMime,
|
|
detectImageMime,
|
|
} from './dashboard-image-rules';
|
|
|
|
/**
|
|
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
|
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
|
* Datenbank.
|
|
*
|
|
* 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 (mit der vom Dienst vergebenen UUID und dem daraus gebildeten
|
|
* Pfad), 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".
|
|
*
|
|
* STUFE 2 DER UMSTELLUNG (quick-260924-m4n, Migration
|
|
* 20260924120000_dashboard_image_drop_data): die alte Spalte `data` ist
|
|
* weg, `storagePath` ist Pflicht. Mit ihr sind der Bootstrap-Umzug
|
|
* (`onApplicationBootstrap()` mit `forSystem()`) und die Selbstheilung aus
|
|
* `data` in `getBytes` entfallen — der Umzug hatte auf allen Servern seine
|
|
* Arbeit getan, die Migration bricht ab, falls doch noch eine Zeile ohne
|
|
* Pfad existiert.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
|
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
|
* um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es
|
|
* betrifft nur den eigenen Speicher.
|
|
*
|
|
* Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des
|
|
* Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des
|
|
* Benutzers laedt sie selbst.
|
|
*/
|
|
|
|
const ORIGINAL_NAME_MAX = 255;
|
|
|
|
/** 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,
|
|
mimeType: true,
|
|
size: true,
|
|
createdAt: true,
|
|
} as const;
|
|
|
|
export interface DashboardImageMeta {
|
|
id: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
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}`;
|
|
}
|
|
|
|
/**
|
|
* Servergenerierter relativer Pfad fuer ein neues Bild: UUID der Zeile plus
|
|
* Endung aus dem ERKANNTEN Typ (T-HK4-01).
|
|
*/
|
|
function storagePathFor(userId: string, id: string, mimeType: string): string {
|
|
const extension = extensionFor(mimeType);
|
|
if (extension === null) {
|
|
// detectImageMime liefert nur die vier bekannten Typen; ein anderer
|
|
// Wert hier waere ein Programmierfehler, kein Benutzerfehler.
|
|
throw new InternalServerErrorException(`Unbekannter Bildtyp '${mimeType}'`);
|
|
}
|
|
return relativeStoragePath(userId, id, extension);
|
|
}
|
|
|
|
/**
|
|
* Schreibt die Bytes an den servergenerierten Ort. Der Ordner je Benutzer
|
|
* entsteht dabei (`recursive: true`).
|
|
*/
|
|
async function writeImageFile(storagePath: string, bytes: Uint8Array): Promise<void> {
|
|
const absolute = absoluteImagePath(storagePath);
|
|
if (absolute === null) {
|
|
throw new Error(`Ungueltiger Ablageort '${storagePath}'`);
|
|
}
|
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
await fs.writeFile(absolute, bytes);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
private readonly logger = new Logger(DashboardImagesService.name);
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
|
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
return tenantPrisma.dashboardImage.findMany({
|
|
where: { tenantId, userId },
|
|
select: META_SELECT,
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
|
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
|
* die Zeile haelt den Pfad.
|
|
*
|
|
* Reihenfolge (T-HK4-04): die UUID vergibt der Dienst selbst
|
|
* (`randomUUID()`, dieselbe Form wie Prismas `@default(uuid())`), damit
|
|
* die Zeile ihren Pfad gleich beim Anlegen traegt — `storagePath` ist seit
|
|
* Stufe 2 Pflicht. Zeile zuerst, dann die Datei; scheitert das Schreiben,
|
|
* 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) {
|
|
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
|
}
|
|
|
|
const mimeType: DashboardImageMime | null = detectImageMime(file.buffer);
|
|
if (mimeType === null) {
|
|
throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.');
|
|
}
|
|
|
|
const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id);
|
|
const existing = await tenantPrisma.dashboardImage.count({
|
|
where: { tenantId: user.tenantId, userId: user.id },
|
|
});
|
|
if (existing >= DASHBOARD_IMAGE_MAX_COUNT) {
|
|
throw new BadRequestException(
|
|
`Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`,
|
|
);
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const storagePath = storagePathFor(user.id, id, mimeType);
|
|
const created = await tenantPrisma.dashboardImage.create({
|
|
data: {
|
|
id,
|
|
storagePath,
|
|
userId: user.id,
|
|
tenantId: user.tenantId,
|
|
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
|
mimeType,
|
|
size: file.buffer.length,
|
|
},
|
|
select: META_SELECT,
|
|
});
|
|
|
|
try {
|
|
await writeImageFile(storagePath, file.buffer);
|
|
} 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. Gelesen wird die Datei; ein ungueltiger Pfad und eine fehlende
|
|
* Datei ergeben denselben 404.
|
|
*/
|
|
async getBytes(
|
|
id: string,
|
|
userId: string,
|
|
tenantId: string,
|
|
): Promise<{ mimeType: string; data: Uint8Array }> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
|
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
|
}
|
|
|
|
const absolute = 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.
|
|
* 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 } });
|
|
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
|
}
|
|
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
|
|
|
const absolute = 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 };
|
|
}
|
|
}
|