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],
|
||||
|
||||
@@ -168,14 +168,14 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||
| dashboard | 1 | 18 | 0 | **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| **Summe** | **61** | **187** | **5** | **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
||||
|
||||
@@ -673,7 +673,7 @@ werden.
|
||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
@@ -808,7 +808,10 @@ werden.
|
||||
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
||||
(Etappe 3c) — erledigt (260914-eym):** Migration
|
||||
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer
|
||||
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
|
||||
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
|
||||
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
|
||||
Schwesterhelfer
|
||||
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
||||
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
|
||||
|
||||
Reference in New Issue
Block a user