feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck

- Prisma-Modell DashboardImage (bytea) mit Migration 20260921120000: Tabelle,
  Indizes, RLS ENABLE/FORCE und tenant_isolation_policy mit Benutzerdimension
- dashboard-image-rules.ts: detectImageMime ueber Magic Bytes (PNG/JPEG/GIF/
  WebP), Grenzen 5 MiB je Datei und 30 je Benutzer
- DashboardImagesService: list/upload/getBytes/remove, je Methode
  forTenant(prisma, tenantId, userId); Besitz = Mandant UND Benutzer, sonst 404
- DashboardImagesController unter dashboard/images: GET, POST (FileInterceptor
  image, 5 MiB, eine Datei), GET :id mit Content-Type aus dem erkannten Typ,
  Cache-Control private, nosniff, Content-Disposition inline ohne Dateinamen,
  CSP sandbox; DELETE :id
- CreateWidgetDto kennt 'picture-frame'
- Klassifikationsdokument: neues Paar dashboard-images.service.ts/
  dashboardImage; Bereichs- und Summenzeilen nachgemessen (dashboard 12->18,
  settings 3->4 und bug-reports waren in der Summe nie mitgezaehlt)
- Befund: Prisma-Bytes verlangt Uint8Array<ArrayBuffer>, multers Buffer wird
  ohne Zusicherung abgelehnt - Kopie per new Uint8Array(buffer) statt Cast

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:47:07 +02:00
parent 573d070041
commit 737974b653
11 changed files with 884 additions and 10 deletions
@@ -0,0 +1,54 @@
-- 260921-pi9 — Bilderrahmen-Widget: hochgeladene Bilder eines Benutzers.
--
-- Zweck: die Tabelle "DashboardImage" traegt die Bilddaten (bytea) fuer das
-- Dashboard-Widget „Bilderrahmen“. Bilder liegen in der Datenbank statt in
-- einem Docker-Volume, damit die bestehende Sicherung sie mit abdeckt.
--
-- Grenzen (durchgesetzt in der Anwendung, apps/api/src/dashboard/
-- dashboard-image-rules.ts + dashboard-images.service.ts): hoechstens 5 MiB
-- je Datei (multer-Limit je Route), hoechstens 30 Bilder je Benutzer
-- (Zaehler je Mandant+Benutzer vor dem Anlegen); erlaubt sind nur PNG, JPEG,
-- GIF und WebP, erkannt an den Magic Bytes — "mimeType" ist der ERKANNTE Typ,
-- nie der vom Browser behauptete.
--
-- Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
-- gleicher Benutzer). Die Regel unten traegt deshalb von Anfang an die
-- Benutzerdimension (Form aus 20260911120000_rls_user_dimension_personal_
-- tables); die Anwendung prueft den Besitz zusaetzlich in getBytes/remove und
-- antwortet fuer fremde Kennungen mit 404 (nie 403).
--
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirkt die Regel erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md).
-- CreateTable
CREATE TABLE "DashboardImage" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"data" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DashboardImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DashboardImage_userId_idx" ON "DashboardImage"("userId");
-- CreateIndex
CREATE INDEX "DashboardImage_tenantId_idx" ON "DashboardImage"("tenantId");
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000)
ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DashboardImage"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
+20
View File
@@ -212,6 +212,26 @@ 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
// 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).
model DashboardImage {
id String @id @default(uuid())
userId String
tenantId String
originalName String
mimeType String
size Int
data Bytes
createdAt DateTime @default(now())
@@index([userId])
@@index([tenantId])
}
model SearchProvider {
id String @id @default(uuid())
userId String?
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
DASHBOARD_IMAGE_MAX_BYTES,
DASHBOARD_IMAGE_MAX_COUNT,
detectImageMime,
} from './dashboard-image-rules';
/**
* dashboard-image-rules.spec — NEU (quick-260921-pi9, Bilderrahmen-Widget).
*
* Die Magic-Byte-Erkennung entscheidet Annahme UND gespeicherten Typ eines
* hochgeladenen Bildes (T-PI9-01); Dateiname und behaupteter MIME-Typ werden
* nie gelesen. Deshalb wird sie hier direkt an den Bytes geprueft: vier
* gueltige Signaturen, und alles, was NICHT durchgehen darf (leer, Text,
* SVG, PDF, RIFF ohne WEBP).
*/
function bytes(...parts: (number[] | string)[]): Uint8Array {
const out: number[] = [];
for (const p of parts) {
if (typeof p === 'string') {
for (const ch of p) out.push(ch.charCodeAt(0));
} else {
out.push(...p);
}
}
return Uint8Array.from(out);
}
describe('detectImageMime (quick-260921-pi9)', () => {
it('Test 1: PNG-Signatur 89 50 4E 47 0D 0A 1A 0A -> image/png', () => {
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13]))).toBe(
'image/png',
);
});
it('Test 2: FF D8 FF -> image/jpeg', () => {
expect(detectImageMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
});
it('Test 3: GIF87a und GIF89a -> image/gif', () => {
expect(detectImageMime(bytes('GIF87a', [1, 0, 1, 0]))).toBe('image/gif');
expect(detectImageMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
});
it('Test 4: RIFF????WEBP -> image/webp', () => {
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe('image/webp');
});
it('Test 5: leerer Puffer -> null', () => {
expect(detectImageMime(new Uint8Array(0))).toBeNull();
});
it('Test 6: Textdatei -> null, auch wenn sie lang genug ist', () => {
expect(detectImageMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
});
it('Test 7: SVG-Text und PDF (%PDF) -> null', () => {
expect(detectImageMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBeNull();
expect(detectImageMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
});
it('Test 8: RIFF-Kopf ohne WEBP an Stelle 8 (z. B. WAVE) -> null', () => {
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WAVE', 'fmt '))).toBeNull();
});
it('Test 9: zu kurze Puffer werfen nicht — PNG-Anfang mit 4 Bytes ist null, JPEG-Anfang mit 2 Bytes ist null', () => {
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
expect(detectImageMime(bytes([0xff, 0xd8]))).toBeNull();
expect(detectImageMime(bytes('RIFF'))).toBeNull();
});
it('Test 10: Grenzen — 5 MiB je Datei, 30 Bilder je Benutzer', () => {
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
expect(DASHBOARD_IMAGE_MAX_COUNT).toBe(30);
});
});
@@ -0,0 +1,51 @@
/**
* dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget
* (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die
* Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte
* anwenden und die Erkennung direkt an den Bytes testbar ist.
*
* Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung
* (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte
* HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier
* Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08).
*
* Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
*/
/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */
export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */
export const DASHBOARD_IMAGE_MAX_COUNT = 30;
export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a"
const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a"
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean {
if (buffer.length < offset + signature.length) return false;
for (let i = 0; i < signature.length; i++) {
if (buffer[offset + i] !== signature[i]) return false;
}
return true;
}
/**
* Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere
* (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`.
* Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild.
*/
export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null {
if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png';
if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg';
if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif';
if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp';
return null;
}
@@ -0,0 +1,122 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
/**
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je
* Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst
* ihre Optionen in einer Klasse ohne lesbare Metadaten.
*/
const { fileInterceptorMock } = vi.hoisted(() => ({
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
}));
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import type { AuthUser } from '../auth/types/auth-user';
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
import { DashboardImagesController } from './dashboard-images.controller';
/**
* dashboard-images.controller.spec — NEU (quick-260921-pi9).
*
* Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge
* (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld
* `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten
* Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein
* `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich
* aus dem Sitzungsnachweis (T-PI9-11).
*/
const user: AuthUser = {
id: 'user-1',
username: 'anna',
role: 'USER',
tenantId: 'tenant-1',
mustChangePassword: false,
};
function makeService() {
return {
list: vi.fn(async () => []),
upload: vi.fn(async () => ({ id: 'new' })),
getBytes: vi.fn(async () => ({
mimeType: 'image/webp',
data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]),
})),
remove: vi.fn(async (id: string) => ({ id })),
};
}
function makeRes() {
const headers: Record<string, string> = {};
return {
headers,
setHeader: vi.fn((name: string, value: string) => {
headers[name] = value;
}),
send: vi.fn(),
};
}
// Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht
// beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf.
describe('DashboardImagesController (quick-260921-pi9)', () => {
it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => {
expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images');
const proto = DashboardImagesController.prototype;
const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor');
expect(order).toEqual(['list', 'upload', 'getImage', 'remove']);
expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id');
expect(Reflect.getMetadata('path', proto.remove)).toBe(':id');
expect(Reflect.getMetadata('path', proto.list)).toBe('/');
expect(Reflect.getMetadata('path', proto.upload)).toBe('/');
});
it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => {
expect(fileInterceptorMock).toHaveBeenCalledTimes(1);
expect(fileInterceptorMock.mock.calls[0]).toEqual([
'image',
{ limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } },
]);
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
});
it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => {
const service = makeService();
const controller = new DashboardImagesController(service as never);
const res = makeRes();
await controller.getImage('img-1', user, res as never);
expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1');
expect(res.headers['Content-Type']).toBe('image/webp');
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
expect(res.headers['Content-Disposition']).toBe('inline');
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
const sent = res.send.mock.calls[0][0] as unknown;
expect(Buffer.isBuffer(sent)).toBe(true);
expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true);
});
it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => {
const proto = DashboardImagesController.prototype;
for (const name of ['list', 'upload', 'getImage', 'remove'] as const) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
}
});
it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => {
const service = makeService();
const controller = new DashboardImagesController(service as never);
const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 };
await controller.list(user);
expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1');
await controller.upload(user, file);
expect(service.upload).toHaveBeenCalledWith(user, file);
await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' });
expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1');
});
});
@@ -0,0 +1,87 @@
import {
Controller,
Delete,
Get,
Param,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
import { DashboardImagesService } from './dashboard-images.service';
/**
* Bilder des Bilderrahmen-Widgets (quick-260921-pi9).
*
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
* (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt
* weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem
* Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der
* Multipart-Rumpf hat genau das Feld `image`.
*
* Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine
* Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die
* deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status).
* `main.ts` bleibt ohne globales Body-Limit.
*
* Routen (statische VOR `:id`, sonst 404-Shadowing):
* - GET /dashboard/images — eigene Bilder, nur Metadaten
* - POST /dashboard/images — Bild hochladen (multipart-Feld `image`)
* - GET /dashboard/images/:id — Bytes eines eigenen Bildes
* - DELETE /dashboard/images/:id — eigenes Bild loeschen
*/
@Controller('dashboard/images')
export class DashboardImagesController {
constructor(private readonly service: DashboardImagesService) {}
@Get()
async list(@CurrentUser() user: AuthUser) {
return this.service.list(user.id, user.tenantId);
}
@Post()
@UseInterceptors(
FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }),
)
async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) {
return this.service.upload(user, file);
}
/**
* Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der
* gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das
* Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen
* (`originalName` gehoert in keinen Header), `private` haelt gemeinsame
* Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im
* Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster
* favorites.controller.ts `getIcon`).
*
* Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber
* denselben Speicher, keine Kopie, keine Zusicherung.
*/
@Get(':id')
async getImage(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
@Res() res: Response,
) {
const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId);
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'private, max-age=86400');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', 'inline');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
}
@Delete(':id')
async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
return this.service.remove(id, user.id, user.tenantId);
}
}
@@ -0,0 +1,291 @@
import { 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).
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
prisma.__makeBoundClient(tenantId, userId),
),
}));
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service';
/**
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
*
* 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
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
* Argument aufruft.
*/
interface ImageRow {
id: string;
userId: string;
tenantId: string;
originalName: string;
mimeType: string;
size: number;
data: Uint8Array;
createdAt: Date;
}
interface BoundCall {
tenantId: string;
userId: string | undefined;
model: string;
method: string;
}
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface FakePrisma {
dashboardImage: ModelMethods;
__rows: ImageRow[];
__boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
}
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
const TEXT = Buffer.from('nur Text, kein Bild');
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
return {
id: overrides.id ?? 'img-1',
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
originalName: overrides.originalName ?? 'foto.png',
mimeType: overrides.mimeType ?? 'image/png',
size: overrides.size ?? PNG.length,
data: overrides.data ?? Uint8Array.from(PNG),
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
};
}
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
if (!select) return row;
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) {
if (select[key]) out[key] = row[key as keyof ImageRow];
}
return out;
}
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> };
return rows
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((r) => pick(r, args.select));
}),
count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } };
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 created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
rows.push(created);
return pick(created, args.select);
}),
findUnique: vi.fn(async (raw: unknown) => {
const args = raw as { where: { id: string } };
return rows.find((r) => r.id === args.where.id) ?? null;
}),
delete: vi.fn(async (raw: unknown) => {
const args = raw as { where: { id: string } };
const idx = rows.findIndex((r) => r.id === args.where.id);
if (idx === -1) return null;
const [removed] = rows.splice(idx, 1);
return removed;
}),
};
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 fake;
}
function makeService(prisma: FakePrisma) {
// Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das
// Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten).
// `as never` statt einer Doppelzusicherung ueber unknown (Muster
// tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27).
return new DashboardImagesService(prisma as never);
}
const user: AuthUser = {
id: 'user-1',
username: 'anna',
role: 'USER',
tenantId: 'tenant-1',
mustChangePassword: false,
};
function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike {
return { buffer, mimetype, originalname, size: buffer.length };
}
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => {
const prisma = makeFakePrisma([
makeRow({ id: 'b', createdAt: new Date('2026-03-01') }),
makeRow({ id: 'a', createdAt: new Date('2026-01-01') }),
makeRow({ id: 'fremd', userId: 'user-2' }),
]);
const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) {
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
}
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
expect(call.select.data).toBeUndefined();
});
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow(
new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'),
);
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
});
it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => {
const prisma = makeFakePrisma();
const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt'));
expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length);
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
expect(prisma.__rows[0].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
});
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
);
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
});
it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => {
const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` }));
const full = makeFakePrisma(thirty);
await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
new BadRequestException(
'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.',
),
);
expect(full.dashboardImage.create).not.toHaveBeenCalled();
const almost = makeFakePrisma(thirty.slice(0, 29));
await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png',
});
});
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png',
});
const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown };
expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' });
});
it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => {
const prisma = makeFakePrisma();
const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400)));
expect(result.originalName).toHaveLength(255);
});
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeRow({ 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 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 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);
});
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' }),
]);
const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
expect(prisma.__rows).toHaveLength(2);
});
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 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');
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
for (const call of vi.mocked(forTenant).mock.calls) {
expect(call[0]).toBe(prisma);
expect(call[1]).toBe('tenant-1');
expect(call[2]).toBe('user-1');
}
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1');
expect(c.userId).toBe('user-1');
}
});
});
@@ -0,0 +1,148 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import {
DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime,
detectImageMime,
} from './dashboard-image-rules';
/**
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
* (quick-260921-pi9).
*
* 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.
*
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
*
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
* einem HTTP-Header (T-PI9-06).
*
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
* um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es
* betrifft nur den eigenen Speicher.
*
* Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des
* Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des
* Benutzers laedt sie selbst.
*/
const ORIGINAL_NAME_MAX = 255;
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
const META_SELECT = {
id: true,
originalName: true,
mimeType: true,
size: true,
createdAt: true,
} as const;
export interface DashboardImageMeta {
id: string;
originalName: string;
mimeType: string;
size: number;
createdAt: Date;
}
@Injectable()
export class DashboardImagesService {
constructor(private readonly prisma: PrismaService) {}
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.dashboardImage.findMany({
where: { tenantId, userId },
select: META_SELECT,
orderBy: { createdAt: 'asc' },
});
}
/**
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
* begrenzt, gespeichert wird der erkannte Typ.
*/
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
if (!file) {
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
}
const mimeType: DashboardImageMime | null = detectImageMime(file.buffer);
if (mimeType === null) {
throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.');
}
const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id);
const existing = await tenantPrisma.dashboardImage.count({
where: { tenantId: user.tenantId, userId: user.id },
});
if (existing >= DASHBOARD_IMAGE_MAX_COUNT) {
throw new BadRequestException(
`Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`,
);
}
return 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,
});
}
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
async getBytes(
id: string,
userId: string,
tenantId: string,
): Promise<{ mimeType: string; data: Uint8Array }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
throw new NotFoundException(`Image with id '${id}' not found`);
}
return { mimeType: row.mimeType, data: row.data };
}
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
throw new NotFoundException(`Image with id '${id}' not found`);
}
await tenantPrisma.dashboardImage.delete({ where: { id } });
return { id };
}
}
+6 -2
View File
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { DashboardController } from './dashboard.controller';
import { DashboardImagesController } from './dashboard-images.controller';
import { DashboardImagesService } from './dashboard-images.service';
import { DashboardService } from './dashboard.service';
/**
@@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service';
* Provides:
* - DashboardService: CRUD for per-user dashboard layouts and widget instances
* - DashboardController: REST API for layout and widget operations
* - DashboardImagesService/-Controller: Bilder des Bilderrahmen-Widgets
* (quick-260921-pi9), je Benutzer in der Datenbank
*
* Imports ModuleRegistryModule so DashboardService can inject
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
@@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service';
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [DashboardController],
providers: [DashboardService],
controllers: [DashboardController, DashboardImagesController],
providers: [DashboardService, DashboardImagesService],
exports: [DashboardService],
})
export class DashboardModule {}
@@ -2,12 +2,22 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
/**
* DTO for creating a new widget instance on a user's dashboard.
* widgetType must be one of the seven supported types.
* widgetType must be one of the eight supported types
* ('picture-frame' seit quick-260921-pi9).
* config is optional and defaults to {} on the model.
*/
export class CreateWidgetDto {
@IsString()
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
@IsIn([
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
])
widgetType!: string;
@IsOptional()