feat(260924-m4n): Bilderrahmen Stufe 2 - alte Bildspalte data entfernt, storagePath Pflicht

- 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>
This commit is contained in:
2026-09-24 16:09:36 +02:00
parent b10734f382
commit dd54ec5d42
9 changed files with 230 additions and 288 deletions
@@ -4,21 +4,21 @@ import * as path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* 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`.
* 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).
* `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n,
* Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen.
*/
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()),
forSystem: vi.fn(() => {
throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen');
}),
}));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
@@ -39,15 +39,18 @@ import { DashboardImagesService } from './dashboard-images.service';
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
* Argument aufruft.
*
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): 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).
* bei fehlender Datei und das Mitloeschen der Datei.
*
* Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist
* Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`),
* 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug).
*/
interface ImageRow {
@@ -57,13 +60,11 @@ interface ImageRow {
originalName: string;
mimeType: string;
size: number;
data: Uint8Array | null;
storagePath: string | null;
storagePath: string;
createdAt: Date;
}
interface BoundCall {
via: 'tenant' | 'system';
tenantId: string;
userId: string | undefined;
model: string;
@@ -77,7 +78,6 @@ 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]);
@@ -102,16 +102,21 @@ afterAll(() => {
}
});
/**
* Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der
* Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht).
*/
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
const id = overrides.id ?? 'img-1';
const userId = overrides.userId ?? 'user-1';
return {
id: overrides.id ?? 'img-1',
userId: overrides.userId ?? 'user-1',
id,
userId,
tenantId: overrides.tenantId ?? 'tenant-1',
originalName: overrides.originalName ?? 'foto.png',
mimeType: overrides.mimeType ?? 'image/png',
size: overrides.size ?? PNG.length,
data: overrides.data === undefined ? null : overrides.data,
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`,
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
};
}
@@ -123,11 +128,10 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
*/
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 };
return row;
}
function storedFile(userId: string, id: string, ext = 'png'): string {
@@ -148,7 +152,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => {
const args = raw as {
where: { tenantId?: string; userId?: string; storagePath?: string | null };
where: { tenantId?: string; userId?: string };
select?: Record<string, boolean>;
};
const where = args.where ?? {};
@@ -156,9 +160,6 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
.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()
@@ -195,11 +196,11 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}),
};
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
function wrap(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 });
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
return dashboardImage[method](...args);
};
}
@@ -211,10 +212,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
__rows: rows,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) {
return wrap('tenant', tenantId, userId);
},
__makeSystemClient() {
return wrap('system', '', undefined);
return wrap(tenantId, userId);
},
};
return fake;
@@ -340,37 +338,6 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
});
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
const row = makeRow({
id: 'img-alt',
data: PNG,
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
});
const prisma = makeFakePrisma([row]);
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
});
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
const row = makeRow({
id: 'img-weg',
data: null,
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
});
const prisma = makeFakePrisma([row]);
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
const prisma = makeFakePrisma([
makeStoredRow({ id: 'eigen' }),
@@ -399,13 +366,13 @@ 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; der
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
// UUID der Zeile erst nach `create` feststeht (hk4).
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) {
expect(c.via).toBe('tenant');
expect(c.tenantId).toBe('tenant-1');
expect(c.userId).toBe('user-1');
}
@@ -421,10 +388,15 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
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();
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile.
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
expect(createArgs.data.data).toBeUndefined();
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id);
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
});
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 () => {
@@ -435,7 +407,7 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
expect(result.mimeType).toBe('image/jpeg');
const stored = prisma.__rows[0].storagePath ?? '';
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('..');
@@ -462,10 +434,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
}
});
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 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]);
const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1');
expect(Buffer.from(result.data).equals(JPEG)).toBe(true);
});
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
@@ -479,11 +451,6 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
);
});
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');
@@ -504,69 +471,3 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
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');
});
});
@@ -4,12 +4,12 @@ import {
InternalServerErrorException,
Logger,
NotFoundException,
type OnApplicationBootstrap,
} 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 { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import {
DASHBOARD_IMAGE_MAX_COUNT,
@@ -46,13 +46,22 @@ import {
* (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
* 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,
@@ -145,6 +154,33 @@ function relativeStoragePath(userId: string, id: string, extension: string): str
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
@@ -162,69 +198,11 @@ function absoluteImagePath(storagePath: string): string | null {
}
@Injectable()
export class DashboardImagesService implements OnApplicationBootstrap {
export class DashboardImagesService {
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);
@@ -240,10 +218,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
* 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.
* 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) {
@@ -265,8 +245,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
);
}
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),
@@ -277,11 +261,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
});
try {
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
await tenantPrisma.dashboardImage.update({
where: { id: created.id },
data: { storagePath },
});
await writeImageFile(storagePath, file.buffer);
} catch (error) {
this.logger.error(
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
@@ -297,8 +277,8 @@ export class DashboardImagesService implements OnApplicationBootstrap {
/**
* 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.
* 404. Gelesen wird die Datei; ein ungueltiger Pfad und eine fehlende
* Datei ergeben denselben 404.
*/
async getBytes(
id: string,
@@ -311,7 +291,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
throw new NotFoundException(`Image with id '${id}' not found`);
}
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
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`);
@@ -321,26 +301,6 @@ export class DashboardImagesService implements OnApplicationBootstrap {
const data = await fs.readFile(absolute);
return { mimeType: row.mimeType, data };
} catch (error) {
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
if (row.data !== null) {
try {
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
return { mimeType: row.mimeType, data: row.data };
} catch (writeError) {
this.logger.error(
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
writeError instanceof Error ? writeError.message : String(writeError)
}`,
);
}
}
this.logger.warn(
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
error instanceof Error ? error.message : String(error)
@@ -363,7 +323,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
}
await tenantPrisma.dashboardImage.delete({ where: { id } });
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) {
try {
await fs.unlink(absolute);
@@ -378,31 +338,4 @@ export class DashboardImagesService implements OnApplicationBootstrap {
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;
}
}
@@ -173,9 +173,16 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
*
* quick-260924-m4n: der SIEBTE FALL ist wieder ENTFERNT. Stufe 2 der
* Bilderrahmen-Umstellung (Migration 20260924120000_dashboard_image_drop_data)
* loescht die Spalte `data`; der Bootstrap-Umzug in
* `dashboard-images.service.ts` hat damit nichts mehr zu lesen und ist samt
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. 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/proxmox/proxmox.service.ts', 1],
+1 -1
View File
@@ -623,7 +623,7 @@ export class ProxmoxService {
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen,
* (Muster `DkvSchedulerService`, einmal lesen,
* viele bedienen).
*/
async loadActiveServersForScheduler(): Promise<