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:
@@ -0,0 +1,59 @@
|
||||
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
|
||||
-- Dateibereich (Volume `user-files`).
|
||||
--
|
||||
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
|
||||
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
|
||||
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
|
||||
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
|
||||
-- dieser Version 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). Der Dateiname ist IMMER servergeneriert (die
|
||||
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
|
||||
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
|
||||
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
|
||||
--
|
||||
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
|
||||
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
|
||||
-- Platte passiert beim ersten Start dieser Version automatisch
|
||||
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
|
||||
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
|
||||
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
|
||||
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
|
||||
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
|
||||
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
|
||||
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
|
||||
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
|
||||
-- Stufe 2 (spaetere Freigabe, Migration
|
||||
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
|
||||
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
|
||||
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
|
||||
-- einer Version >= dieser gelaufen sind.
|
||||
--
|
||||
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
|
||||
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
|
||||
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
|
||||
--
|
||||
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
|
||||
-- unveraendert. Kein DROP POLICY.
|
||||
|
||||
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
|
||||
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
|
||||
-- NOT NULL.
|
||||
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
|
||||
|
||||
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
|
||||
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
|
||||
|
||||
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
|
||||
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
|
||||
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
|
||||
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
|
||||
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
|
||||
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
|
||||
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
|
||||
-- Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
CREATE POLICY system_read_policy ON "DashboardImage"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -212,12 +212,16 @@ model WidgetInstance {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
|
||||
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
|
||||
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
||||
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Benutzer) und die Magic-Byte-Erkennung leben in
|
||||
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
||||
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
||||
//
|
||||
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
||||
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
||||
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
||||
// (pg_dump) bleibt dadurch klein.
|
||||
model DashboardImage {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@@ -225,7 +229,14 @@ model DashboardImage {
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
data Bytes
|
||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||
data Bytes?
|
||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||
storagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
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 { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
|
||||
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
||||
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
||||
* Datenbank).
|
||||
*
|
||||
* Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload
|
||||
* ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE
|
||||
* Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND
|
||||
* fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes,
|
||||
* Loeschen eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
||||
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
||||
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
||||
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
||||
* eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
@@ -37,11 +57,13 @@ interface ImageRow {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
@@ -55,11 +77,31 @@ interface FakePrisma {
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
||||
let imagesDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
||||
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(imagesDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.DASHBOARD_IMAGES_DIR;
|
||||
} else {
|
||||
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
@@ -68,11 +110,30 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data ?? Uint8Array.from(PNG),
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
||||
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
||||
* Ablage).
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
return path.join(imagesDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return row;
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -86,9 +147,20 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string }; select?: Record<string, boolean> };
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
return rows
|
||||
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((r) => pick(r, args.select));
|
||||
@@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Omit<ImageRow, 'id' | 'createdAt'>; select?: Record<string, boolean> };
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
update: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
||||
const row = rows.find((r) => r.id === args.where.id);
|
||||
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
||||
Object.assign(row, args.data);
|
||||
return row;
|
||||
}),
|
||||
findUnique: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||
@@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
@@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(forSystem).mockClear();
|
||||
});
|
||||
|
||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
@@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||
@@ -234,19 +322,19 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
@@ -254,9 +342,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'eigen' }),
|
||||
makeRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
@@ -267,12 +355,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
await service.list('user-1', 'tenant-1');
|
||||
await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes('img-1', 'user-1', 'tenant-1');
|
||||
await service.remove('img-1', 'user-1', 'tenant-1');
|
||||
const created = await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
||||
await service.remove(created.id, 'user-1', 'tenant-1');
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
@@ -280,12 +368,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
||||
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
||||
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = makeService(prisma);
|
||||
const boeserName = '../../../etc/passwd.png';
|
||||
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
||||
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
||||
expect(result.originalName).toBe(boeserName);
|
||||
});
|
||||
|
||||
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
||||
const blocker = path.join(imagesDir, 'blockade');
|
||||
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
||||
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
||||
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
||||
try {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
} finally {
|
||||
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'nur-zeile',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
||||
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
||||
* Summe: 4 Dateien, 5 Aufrufe.
|
||||
*
|
||||
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
||||
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
||||
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
||||
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
||||
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
||||
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
||||
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
||||
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
||||
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
||||
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
||||
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
||||
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||
* Summe neu: 5 Dateien, 6 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
|
||||
Reference in New Issue
Block a user