diff --git a/CLAUDE.md b/CLAUDE.md index 1c51f73..a617915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,12 @@ Decorate controllers/handlers with: @RequirePermissions(Permission.VIEW_SCANNER) ``` +`PermissionsGuard` is **deny by default**: every route needs `@RequirePermissions(...)`, +`@AllowAnyAuthenticated()` (any logged-in user, e.g. own user settings) or `@Public()`. +API keys carry no permissions and are rejected everywhere except routes marked +`@AllowApiKey()` (Paperless webhook, external upload, label-print agent). `routen-rechte.spec.ts` +fails if a route has no explicit decision or if the API-key route list changes. + Permissions map from OIDC groups (`PM_Admin`, `PM_Buchhaltung`, `PM_Posteingang`, etc.) to the `Permission` enum in `src/auth/permissions.enum.ts`. **This mapping is duplicated in `paperless-frontend/src/auth/permissions.ts` and both copies must list the same group names** — @@ -89,6 +95,9 @@ every authenticated request — do not re-introduce ad-hoc writes to those ident Use `@Public()` to bypass auth guards entirely. +Paperless custom fields 15 (Freigabe) and 16 (Zahlung) may only be set by the matching role — +see `src/paperless/status-felder.util.ts`. + ### Database - TypeORM with MySQL/MariaDB, UTF8MB4 diff --git a/docker-compose.yml b/docker-compose.yml index b641dee..beaea4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: - OLLAMA_MODEL=${OLLAMA_MODEL:-llava} - SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive} - OIDC_ISSUER=${OIDC_ISSUER:-} + # Audience-Prüfung der Tokens – muss dieselbe Client-ID wie im Frontend sein + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-${VITE_OIDC_CLIENT_ID:-ogOeIlrcsf2spjuwyOc7CuAPrRkRSt3RXfYguoLn}} - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_SECURE=${SMTP_SECURE:-false} diff --git a/docs/BACKEND_API.md b/docs/BACKEND_API.md index f00aa88..57e0e2d 100644 --- a/docs/BACKEND_API.md +++ b/docs/BACKEND_API.md @@ -12,7 +12,9 @@ Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key): Authorization: Bearer {token} ``` -`/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen **keine** spezifische Permission (nur gültigen Token). `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`. +`/events`, `/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` sind die Agent-Routen: Sie nehmen einen **API-Key** an (`X-API-Key`, `?apiKey=` oder `Authorization: Bearer pm_…`); Benutzer mit JWT brauchen dort `MANAGE_SETTINGS`. `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER` und nehmen **keinen** API-Key an. + +`printed` und `error` werden nur für vom Agenten abgeholte, noch offene Jobs angenommen, sonst `409 Conflict`. Ein wiederholtes `printed` bzw. `error` für einen bereits so abgeschlossenen Job ist ein No-op (kein zweiter Callback). --- diff --git a/paperless-backend/src/app.controller.ts b/paperless-backend/src/app.controller.ts index cce879e..5d67f8c 100644 --- a/paperless-backend/src/app.controller.ts +++ b/paperless-backend/src/app.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { AllowAnyAuthenticated } from './auth/permissions.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() + @AllowAnyAuthenticated() getHello(): string { return this.appService.getHello(); } diff --git a/paperless-backend/src/auth/api-keys.controller.spec.ts b/paperless-backend/src/auth/api-keys.controller.spec.ts new file mode 100644 index 0000000..80fe10f --- /dev/null +++ b/paperless-backend/src/auth/api-keys.controller.spec.ts @@ -0,0 +1,13 @@ +import { PERMISSIONS_KEY } from './permissions.decorator'; +import { Permission } from './permissions.enum'; +import { ApiKeysController } from './api-keys.controller'; + +describe('ApiKeysController', () => { + it('verlangt MANAGE_SETTINGS – sonst kann sich jeder OIDC-Benutzer einen API-Key erzeugen', () => { + const required: unknown = Reflect.getMetadata( + PERMISSIONS_KEY, + ApiKeysController, + ); + expect(required).toEqual([Permission.MANAGE_SETTINGS]); + }); +}); diff --git a/paperless-backend/src/auth/api-keys.controller.ts b/paperless-backend/src/auth/api-keys.controller.ts index 4d6cc36..cf79e90 100644 --- a/paperless-backend/src/auth/api-keys.controller.ts +++ b/paperless-backend/src/auth/api-keys.controller.ts @@ -9,9 +9,14 @@ import { } from '@nestjs/common'; import { ApiKeysService } from './api-keys.service'; import { JwtAuthGuard } from './jwt-auth.guard'; +import { RequirePermissions } from './permissions.decorator'; +import { Permission } from './permissions.enum'; @Controller('api/api-keys') @UseGuards(JwtAuthGuard) +// Ein API-Key umgeht im PermissionsGuard jede Rechteprüfung – Anlegen und +// Verwalten darf daher nur, wer ohnehin alle Einstellungen verwalten darf. +@RequirePermissions(Permission.MANAGE_SETTINGS) export class ApiKeysController { constructor(private readonly apiKeysService: ApiKeysService) {} diff --git a/paperless-backend/src/auth/jwt-optionen.spec.ts b/paperless-backend/src/auth/jwt-optionen.spec.ts new file mode 100644 index 0000000..9ea57d2 --- /dev/null +++ b/paperless-backend/src/auth/jwt-optionen.spec.ts @@ -0,0 +1,46 @@ +import { generateKeyPairSync } from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { jwtPruefOptionen } from './jwt-optionen'; + +describe('jwtPruefOptionen', () => { + it('verlangt die eigene Client-ID als Audience, wenn sie gesetzt ist', () => { + expect( + jwtPruefOptionen('https://auth.example/application/o/pm/', 'client-pm'), + ).toEqual({ + issuer: 'https://auth.example/application/o/pm/', + audience: 'client-pm', + algorithms: ['RS256'], + jwksUri: 'https://auth.example/application/o/pm/jwks/', + }); + }); + + it('lässt die Audience weg, wenn keine Client-ID konfiguriert ist', () => { + const o = jwtPruefOptionen('https://auth.example/application/o/pm', ''); + expect(o.audience).toBeUndefined(); + expect(o.jwksUri).toBe('https://auth.example/application/o/pm/jwks/'); + }); + + // Prüft, dass die Optionen in jsonwebtoken (das passport-jwt intern nutzt) + // tatsächlich fremde Tokens desselben Issuers abweisen. + it('weist Tokens anderer Anwendungen desselben Authentik ab', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const o = jwtPruefOptionen('https://auth.example/', 'client-pm'); + const token = (aud: string) => + jwt.sign({ sub: 'u1' }, privateKey, { + algorithm: 'RS256', + issuer: o.issuer, + audience: aud, + }); + const pruefe = (t: string) => + jwt.verify(t, publicKey, { + issuer: o.issuer, + audience: o.audience, + algorithms: o.algorithms, + }); + + expect(() => pruefe(token('client-pm'))).not.toThrow(); + expect(() => pruefe(token('client-andere-app'))).toThrow(/audience/); + }); +}); diff --git a/paperless-backend/src/auth/jwt-optionen.ts b/paperless-backend/src/auth/jwt-optionen.ts new file mode 100644 index 0000000..55c2034 --- /dev/null +++ b/paperless-backend/src/auth/jwt-optionen.ts @@ -0,0 +1,27 @@ +import type { Algorithm } from 'jsonwebtoken'; + +export interface JwtPruefOptionen { + issuer: string; + /** Client-ID dieser Anwendung; ohne sie wird `aud` nicht geprüft. */ + audience?: string; + algorithms: Algorithm[]; + jwksUri: string; +} + +/** + * Prüfoptionen für Access-Tokens von Authentik. Ohne Audience-Prüfung würde + * jedes Token desselben Issuers akzeptiert – also auch eines, das Authentik + * für eine andere Anwendung mit demselben Provider ausgestellt hat. + */ +export function jwtPruefOptionen( + issuer: string, + clientId: string | undefined, +): JwtPruefOptionen { + const basis = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer; + return { + issuer, + ...(clientId ? { audience: clientId } : {}), + algorithms: ['RS256'], + jwksUri: `${basis}/jwks/`, + }; +} diff --git a/paperless-backend/src/auth/jwt.strategy.ts b/paperless-backend/src/auth/jwt.strategy.ts index 5c47830..4dc0ee4 100644 --- a/paperless-backend/src/auth/jwt.strategy.ts +++ b/paperless-backend/src/auth/jwt.strategy.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { passportJwtSecret } from 'jwks-rsa'; import { mapGroupsToPermissions } from './permissions.enum'; +import { jwtPruefOptionen } from './jwt-optionen'; import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service'; @Injectable() @@ -12,20 +13,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { configService: ConfigService, private readonly identitySync: UserIdentitySyncService, ) { - const issuer = configService.get('OIDC_ISSUER', ''); + const optionen = jwtPruefOptionen( + configService.get('OIDC_ISSUER', ''), + configService.get('OIDC_CLIENT_ID', ''), + ); super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - issuer, - algorithms: ['RS256'], + issuer: optionen.issuer, + audience: optionen.audience, + algorithms: optionen.algorithms, secretOrKeyProvider: passportJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, - jwksUri: `${issuer.endsWith('/') ? issuer.slice(0, -1) : issuer}/jwks/`, + jwksUri: optionen.jwksUri, }), }); + + if (!optionen.audience) { + new Logger(JwtStrategy.name).warn( + 'OIDC_CLIENT_ID ist nicht gesetzt – die Audience von Tokens wird NICHT geprüft. ' + + 'Tokens anderer Anwendungen desselben Authentik würden akzeptiert.', + ); + } } validate(payload: any): { diff --git a/paperless-backend/src/auth/permissions.decorator.ts b/paperless-backend/src/auth/permissions.decorator.ts index 8618a10..1554081 100644 --- a/paperless-backend/src/auth/permissions.decorator.ts +++ b/paperless-backend/src/auth/permissions.decorator.ts @@ -2,5 +2,24 @@ import { SetMetadata } from '@nestjs/common'; import { Permission } from './permissions.enum'; export const PERMISSIONS_KEY = 'permissions'; +export const ALLOW_ANY_AUTHENTICATED_KEY = 'allowAnyAuthenticated'; +export const ALLOW_API_KEY_KEY = 'allowApiKey'; + +/** Route nur für Benutzer mit mindestens einer der Berechtigungen. */ export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions); + +/** + * Route für jeden angemeldeten Benutzer – auch ohne PM-Gruppe (z.B. eigene + * Benutzereinstellungen). Muss ausdrücklich gesetzt werden: Routen ohne + * Rechteangabe lehnt der PermissionsGuard ab. + */ +export const AllowAnyAuthenticated = () => + SetMetadata(ALLOW_ANY_AUTHENTICATED_KEY, true); + +/** + * Route darf (auch) mit API-Key aufgerufen werden. API-Keys haben keine + * eigenen Rechte; ohne diese Markierung lehnt der PermissionsGuard sie ab. + * Für Benutzer (JWT) gilt zusätzlich @RequirePermissions. + */ +export const AllowApiKey = () => SetMetadata(ALLOW_API_KEY_KEY, true); diff --git a/paperless-backend/src/auth/permissions.enum.spec.ts b/paperless-backend/src/auth/permissions.enum.spec.ts index e53cf84..f1aa0ad 100644 --- a/paperless-backend/src/auth/permissions.enum.spec.ts +++ b/paperless-backend/src/auth/permissions.enum.spec.ts @@ -16,9 +16,15 @@ describe('mapGroupsToPermissions', () => { ); }); - it('mappt PM_Buchhaltung auf PROCESS_MANUALLY', () => { - expect(mapGroupsToPermissions(['PM_Buchhaltung'])).toEqual([ - Permission.PROCESS_MANUALLY, + it('PM_Buchhaltung enthält den Posteingang (abgespeckte Buchhaltung)', () => { + expect(mapGroupsToPermissions(['PM_Buchhaltung']).sort()).toEqual( + [Permission.PROCESS_MANUALLY, Permission.VIEW_INBOX].sort(), + ); + }); + + it('PM_Posteingang bekommt nur den Posteingang', () => { + expect(mapGroupsToPermissions(['PM_Posteingang'])).toEqual([ + Permission.VIEW_INBOX, ]); }); @@ -52,4 +58,37 @@ describe('mapGroupsToPermissions', () => { const frontend = groupNames(readFileSync(frontendFile, 'utf8')); expect(backend).toEqual(frontend); }); + + // Gleiche Gruppennamen reichen nicht – jede Gruppe muss auch dieselben + // Berechtigungen bekommen (z.B. PM_Buchhaltung inkl. VIEW_INBOX). + it('vergibt pro Gruppe dieselben Berechtigungen wie das Frontend', () => { + const frontendFile = join( + __dirname, + '../../../paperless-frontend/src/auth/permissions.ts', + ); + if (!existsSync(frontendFile)) return; + + const mapping = (source: string) => { + const ergebnis: Record = {}; + const treffer = [...source.matchAll(/groups\.includes\('([^']+)'\)/g)]; + treffer.forEach((m, i) => { + const ende = treffer[i + 1]?.index ?? source.length; + const block = source.slice(m.index, ende); + ergebnis[m[1]] = [ + ...new Set( + [...block.matchAll(/permissions\.add\(Permission\.(\w+)\)/g)].map( + (p) => p[1], + ), + ), + ].sort(); + }); + return ergebnis; + }; + + const backend = mapping( + readFileSync(join(__dirname, 'permissions.enum.ts'), 'utf8'), + ); + expect(backend['PM_Buchhaltung']).toContain('VIEW_INBOX'); + expect(mapping(readFileSync(frontendFile, 'utf8'))).toEqual(backend); + }); }); diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index d7239d1..c646eb4 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -34,8 +34,11 @@ export function mapGroupsToPermissions( // Gruppe wurde in Authentik von 'PM_Belege' zu 'PM_Buchhaltung' umbenannt. // Muss identisch zu paperless-frontend/src/auth/permissions.ts bleiben. - if (groups.includes('PM_Buchhaltung')) + // Der Posteingang ist eine abgespeckte Buchhaltung – PM_Buchhaltung umfasst ihn. + if (groups.includes('PM_Buchhaltung')) { permissions.add(Permission.PROCESS_MANUALLY); + permissions.add(Permission.VIEW_INBOX); + } if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL); if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); diff --git a/paperless-backend/src/auth/permissions.guard.spec.ts b/paperless-backend/src/auth/permissions.guard.spec.ts new file mode 100644 index 0000000..b43aaa5 --- /dev/null +++ b/paperless-backend/src/auth/permissions.guard.spec.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Handler dienen nur als Metadaten-Schlüssel */ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionsGuard } from './permissions.guard'; +import { + AllowAnyAuthenticated, + AllowApiKey, + RequirePermissions, +} from './permissions.decorator'; +import { Public } from './public.decorator'; +import { Permission } from './permissions.enum'; + +class Routen { + @RequirePermissions(Permission.VIEW_MAIL) + mail() {} + + @AllowAnyAuthenticated() + eigeneEinstellungen() {} + + ohneAngabe() {} + + @Public() + oeffentlich() {} + + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) + agent() {} + + @AllowApiKey() + nurApiKey() {} +} + +@RequirePermissions(Permission.VIEW_INBOX) +class KlasseMitRecht { + route() {} +} + +function ctx( + handler: (...args: unknown[]) => unknown, + request: Record, + cls: object = Routen, +): ExecutionContext { + return { + getHandler: () => handler, + getClass: () => cls, + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +const r = Routen.prototype; +const nutzer = (...permissions: Permission[]) => ({ + method: 'GET', + url: '/x', + user: { permissions }, +}); +const apiKey = { + method: 'GET', + url: '/x', + apiKeyMetadata: { id: '1', name: 'Agent' }, +}; + +describe('PermissionsGuard', () => { + const guard = new PermissionsGuard(new Reflector()); + + it('erlaubt Benutzer mit passender Berechtigung', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_MAIL)))).toBe( + true, + ); + }); + + it('verweigert Benutzer ohne passende Berechtigung', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_INBOX)))).toBe( + false, + ); + }); + + it('erlaubt Admins (MANAGE_ALL) überall mit Rechteangabe', () => { + expect(guard.canActivate(ctx(r.mail, nutzer(Permission.MANAGE_ALL)))).toBe( + true, + ); + }); + + it('übernimmt Rechte von der Klasse', () => { + const k = KlasseMitRecht.prototype; + expect( + guard.canActivate( + ctx(k.route, nutzer(Permission.VIEW_INBOX), KlasseMitRecht), + ), + ).toBe(true); + expect( + guard.canActivate( + ctx(k.route, nutzer(Permission.VIEW_MAIL), KlasseMitRecht), + ), + ).toBe(false); + }); + + it('verweigert Routen ohne Rechteangabe (deny by default) – auch Admins', () => { + expect( + guard.canActivate(ctx(r.ohneAngabe, nutzer(Permission.MANAGE_ALL))), + ).toBe(false); + }); + + it('erlaubt @AllowAnyAuthenticated jedem angemeldeten Benutzer, auch ohne PM-Gruppe', () => { + expect(guard.canActivate(ctx(r.eigeneEinstellungen, nutzer()))).toBe(true); + expect( + guard.canActivate( + ctx(r.eigeneEinstellungen, { method: 'GET', url: '/x' }), + ), + ).toBe(false); + }); + + it('lässt @Public-Routen immer durch', () => { + expect( + guard.canActivate(ctx(r.oeffentlich, { method: 'GET', url: '/x' })), + ).toBe(true); + }); + + describe('API-Keys', () => { + it('gelten nur auf Routen mit @AllowApiKey', () => { + expect(guard.canActivate(ctx(r.agent, apiKey))).toBe(true); + expect(guard.canActivate(ctx(r.nurApiKey, apiKey))).toBe(true); + }); + + it('umgehen keine Rechteprüfung mehr auf anderen Routen', () => { + expect(() => guard.canActivate(ctx(r.mail, apiKey))).toThrow( + ForbiddenException, + ); + expect(() => + guard.canActivate(ctx(r.eigeneEinstellungen, apiKey)), + ).toThrow(ForbiddenException); + expect(() => guard.canActivate(ctx(r.ohneAngabe, apiKey))).toThrow( + ForbiddenException, + ); + }); + + it('Benutzer brauchen auf @AllowApiKey-Routen weiterhin die Berechtigung', () => { + expect( + guard.canActivate(ctx(r.agent, nutzer(Permission.VIEW_SCANNER))), + ).toBe(false); + expect( + guard.canActivate(ctx(r.agent, nutzer(Permission.MANAGE_SETTINGS))), + ).toBe(true); + expect( + guard.canActivate(ctx(r.nurApiKey, nutzer(Permission.MANAGE_ALL))), + ).toBe(false); + }); + }); +}); diff --git a/paperless-backend/src/auth/permissions.guard.ts b/paperless-backend/src/auth/permissions.guard.ts index 9be61d7..2f28883 100644 --- a/paperless-backend/src/auth/permissions.guard.ts +++ b/paperless-backend/src/auth/permissions.guard.ts @@ -1,34 +1,77 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + Logger, +} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { PERMISSIONS_KEY } from './permissions.decorator'; +import { + ALLOW_ANY_AUTHENTICATED_KEY, + ALLOW_API_KEY_KEY, + PERMISSIONS_KEY, +} from './permissions.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; import { Permission } from './permissions.enum'; +interface GeprueftesRequest { + method: string; + url: string; + user?: { permissions?: Permission[] }; + apiKeyMetadata?: { id: string; name: string }; +} + +/** + * Prüft Berechtigungen – deny by default: + * - @Public: immer erlaubt + * - API-Key: nur auf Routen mit @AllowApiKey + * - Benutzer: @RequirePermissions (eine genügt, MANAGE_ALL immer) oder + * @AllowAnyAuthenticated; Routen ohne beides werden abgelehnt. + */ @Injectable() export class PermissionsGuard implements CanActivate { + private readonly logger = new Logger(PermissionsGuard.name); + constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride( - PERMISSIONS_KEY, - [context.getHandler(), context.getClass()], - ); + const targets = [context.getHandler(), context.getClass()]; + const meta = (key: string) => + this.reflector.getAllAndOverride(key, targets); - if (!requiredPermissions) { - return true; - } + if (meta(IS_PUBLIC_KEY)) return true; - const request = context.switchToHttp().getRequest(); - const { user } = request; + const request = context.switchToHttp().getRequest(); + const route = `[${request.method} ${request.url}]`; if (request.apiKeyMetadata) { - return true; + if (meta(ALLOW_API_KEY_KEY)) return true; + this.logger.warn( + `${route} API-Key "${request.apiKeyMetadata.name}" ist für diese Route nicht freigegeben`, + ); + throw new ForbiddenException( + 'API-Key ist für diese Route nicht freigegeben', + ); + } + + const { user } = request; + const requiredPermissions = meta(PERMISSIONS_KEY); + + if (!requiredPermissions) { + if (meta(ALLOW_ANY_AUTHENTICATED_KEY)) return !!user; + if (!meta(ALLOW_API_KEY_KEY)) { + this.logger.error( + `${route} Route ohne @RequirePermissions/@AllowAnyAuthenticated – abgelehnt`, + ); + } + return false; } if (!user || !user.permissions) { return false; } - const userPermissions = user.permissions as Permission[]; + const userPermissions = user.permissions; if (userPermissions.includes(Permission.MANAGE_ALL)) { return true; diff --git a/paperless-backend/src/auth/routen-rechte.spec.ts b/paperless-backend/src/auth/routen-rechte.spec.ts new file mode 100644 index 0000000..17d47de --- /dev/null +++ b/paperless-backend/src/auth/routen-rechte.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata'; +import { readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; +import { RequestMethod } from '@nestjs/common'; +import { + ALLOW_ANY_AUTHENTICATED_KEY, + ALLOW_API_KEY_KEY, + PERMISSIONS_KEY, +} from './permissions.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; + +// Der Agrarmonitor-Connector zieht jsdom (ESM) – für die reine Metadatenprüfung +// genügt ein Platzhalter. +jest.mock('agrarmonitor-connector', () => ({})); + +/** Alle *.controller.ts unter src/ (ohne Tests). */ +function controllerDateien(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const pfad = join(dir, name); + if (statSync(pfad).isDirectory()) return controllerDateien(pfad); + return name.endsWith('.controller.ts') ? [pfad] : []; + }); +} + +type Klasse = { name: string; prototype: Record }; + +function routen() { + const ergebnis: { + route: string; + meta: (key: string) => unknown; + }[] = []; + for (const datei of controllerDateien(join(__dirname, '..'))) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const modul = require(datei) as Record; + for (const exp of Object.values(modul)) { + if (typeof exp !== 'function') continue; + const klasse = exp as unknown as Klasse; + const basis = Reflect.getMetadata(PATH_METADATA, klasse) as + | string + | undefined; + if (basis === undefined) continue; + for (const name of Object.getOwnPropertyNames(klasse.prototype)) { + const handler = klasse.prototype[name]; + if (typeof handler !== 'function') continue; + const methode: unknown = Reflect.getMetadata(METHOD_METADATA, handler); + if (methode === undefined) continue; + const pfad = String(Reflect.getMetadata(PATH_METADATA, handler)); + ergebnis.push({ + route: `${RequestMethod[methode as number]} /${basis}/${pfad} (${klasse.name}.${name})`, + meta: (key) => + (Reflect.getMetadata(key, handler) as unknown) ?? + (Reflect.getMetadata(key, klasse) as unknown), + }); + } + } + } + return ergebnis; +} + +describe('Rechteangabe aller Routen', () => { + const alle = routen(); + + it('findet die Controller', () => { + expect(alle.length).toBeGreaterThan(100); + }); + + it('jede Route hat @RequirePermissions, @AllowAnyAuthenticated, @AllowApiKey oder @Public', () => { + const ohne = alle + .filter( + ({ meta }) => + !meta(PERMISSIONS_KEY) && + !meta(ALLOW_ANY_AUTHENTICATED_KEY) && + !meta(ALLOW_API_KEY_KEY) && + !meta(IS_PUBLIC_KEY), + ) + .map(({ route }) => route); + expect(ohne).toEqual([]); + }); + + it('API-Keys sind nur auf den bekannten Agent-/Integrationsrouten erlaubt', () => { + const mitApiKey = alle + .filter(({ meta }) => meta(ALLOW_API_KEY_KEY)) + .map(({ route }) => route.split(' (')[0]) + .sort(); + expect(mitApiKey).toEqual( + [ + 'GET /api/label-print-agent/events', + 'GET /api/label-print-agent/jobs/:id/image', + 'GET /api/label-print-agent/jobs/next', + 'POST /api/label-print-agent/jobs/:id/error', + 'POST /api/label-print-agent/jobs/:id/printed', + 'POST /api/paperless/checksum', + 'POST /api/paperless/external-upload', + 'POST /api/webhook/paperless', + ].sort(), + ); + }); +}); diff --git a/paperless-backend/src/barcode/page-cache.service.ts b/paperless-backend/src/barcode/page-cache.service.ts index 32c5c9a..500e2c9 100644 --- a/paperless-backend/src/barcode/page-cache.service.ts +++ b/paperless-backend/src/barcode/page-cache.service.ts @@ -1,10 +1,11 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; import sharp from 'sharp'; const THUMBNAIL_WIDTH = 180; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @Injectable() export class PageCacheService { @@ -18,7 +19,11 @@ export class PageCacheService { ); } + /** Scanner-Dokumente haben UUIDs – alles andere würde den Pfad verlassen. */ documentDir(documentId: string): string { + if (!UUID.test(documentId)) { + throw new BadRequestException('Ungültige Dokument-ID'); + } return path.join(this.inboxRoot, documentId); } diff --git a/paperless-backend/src/common/pdf-dekompression.util.spec.ts b/paperless-backend/src/common/pdf-dekompression.util.spec.ts new file mode 100644 index 0000000..62337da --- /dev/null +++ b/paperless-backend/src/common/pdf-dekompression.util.spec.ts @@ -0,0 +1,90 @@ +import * as zlib from 'zlib'; +import { PDFDocument } from 'pdf-lib'; +import { + istVerschluesselt, + ueberschreitetDekompressionsLimit, +} from './pdf-dekompression.util'; + +const MB = 1024 * 1024; + +/** Minimales PDF-Gerüst mit einem Stream – für den Scanner reicht das. */ +function pdfMitStream(daten: Buffer, filter = '/FlateDecode'): Buffer { + return Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${daten.length} /Filter ${filter} >>\nstream\n`, + 'latin1', + ), + daten, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); +} + +describe('ueberschreitetDekompressionsLimit', () => { + it('lässt ein normales PDF durch', async () => { + const doc = await PDFDocument.create(); + doc.addPage().drawText('Rechnung 2026-0001'); + const pdf = Buffer.from(await doc.save()); + expect(await ueberschreitetDekompressionsLimit(pdf, 1 * MB)).toBe(false); + }); + + it('erkennt eine Deflate-Bombe (wenige KB → viele MB)', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(20 * MB)); + expect(bombe.length).toBeLessThan(100 * 1024); + expect( + await ueberschreitetDekompressionsLimit(pdfMitStream(bombe), 5 * MB), + ).toBe(true); + }); + + it('erkennt verschachtelte Deflate-Ketten', async () => { + const innen = zlib.deflateSync(Buffer.alloc(20 * MB)); + const aussen = zlib.deflateSync(innen); + const pdf = pdfMitStream(aussen, '[/FlateDecode /FlateDecode]'); + expect(await ueberschreitetDekompressionsLimit(pdf, 5 * MB)).toBe(true); + }); + + it('summiert über mehrere Streams', async () => { + const teil = zlib.deflateSync(Buffer.alloc(2 * MB)); + const pdf = Buffer.concat([1, 2, 3, 4].map(() => pdfMitStream(teil))); + expect(await ueberschreitetDekompressionsLimit(pdf, 5 * MB)).toBe(true); + expect(await ueberschreitetDekompressionsLimit(pdf, 10 * MB)).toBe(false); + }); + + it('zählt auch abgeschnittene Streams (pdf.js liest sie trotzdem)', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(20 * MB)); + const abgeschnitten = bombe.subarray(0, bombe.length - 10); + expect( + await ueberschreitetDekompressionsLimit( + pdfMitStream(abgeschnitten), + 5 * MB, + ), + ).toBe(true); + }); + + it('wertet nicht komprimierte Streams mit ihrer Rohgröße', async () => { + const roh = Buffer.alloc(2 * MB, 0x41); + expect( + await ueberschreitetDekompressionsLimit( + pdfMitStream(roh, '/DCTDecode'), + 1 * MB, + ), + ).toBe(true); + }); +}); + +describe('istVerschluesselt', () => { + it('erkennt ein /Encrypt-Dictionary im Trailer', () => { + const pdf = Buffer.from( + '%PDF-1.7\ntrailer\n<< /Encrypt 5 0 R >>\n', + 'latin1', + ); + expect(istVerschluesselt(pdf)).toBe(true); + }); + + it('ignoriert ähnliche Namen', () => { + const pdf = Buffer.from( + '%PDF-1.7\n<< /EncryptMetadata false >>\n', + 'latin1', + ); + expect(istVerschluesselt(pdf)).toBe(false); + }); +}); diff --git a/paperless-backend/src/common/pdf-dekompression.util.ts b/paperless-backend/src/common/pdf-dekompression.util.ts new file mode 100644 index 0000000..0531cef --- /dev/null +++ b/paperless-backend/src/common/pdf-dekompression.util.ts @@ -0,0 +1,127 @@ +import * as zlib from 'zlib'; + +/** + * Obergrenze für die Summe aller entpackten Streams eines PDFs, bevor es an + * pdf.js (Fingerprint) oder pdf-lib (E-Rechnung) geht. Beide entpacken ohne + * eigenes Limit; eine Deflate-Bombe (≈ 1:1000) aus einem unauthentifiziert + * zugestellten Mailanhang bringt sonst den Backend-Prozess zum OOM-Absturz. + */ +export const STANDARD_DEKOMPRESSIONS_LIMIT = 256 * 1024 * 1024; + +/** Ausgaben bis zu dieser Größe werden behalten, um Deflate-Ketten zu prüfen. */ +const MAX_KETTENPUFFER = 16 * 1024 * 1024; +const MAX_KETTENTIEFE = 4; + +/** + * Schätzt konservativ, ob das Entpacken aller Streams des PDFs das Limit + * überschreitet. Bewusst ohne PDF-Parser: Jeder Bereich zwischen `stream` und + * `endstream` wird – soweit zlib-Daten – gezählt entpackt, sonst mit seiner + * Rohgröße gewertet. Der Speicherbedarf bleibt dabei klein (Streaming). + * + * Verschlüsselte Streams sind hier nicht prüfbar – Aufrufer, deren Parser + * entschlüsselt (pdf.js), müssen zusätzlich {@link istVerschluesselt} prüfen. + */ +export async function ueberschreitetDekompressionsLimit( + pdf: Buffer, + limit = STANDARD_DEKOMPRESSIONS_LIMIT, +): Promise { + const text = pdf.toString('latin1'); + + let rest = limit; + let pos = 0; + for (;;) { + const fund = text.indexOf('stream', pos); + if (fund < 0) return false; + pos = fund + 'stream'.length; + // `endstream` enthält ebenfalls `stream` + if (text.startsWith('end', fund - 3)) continue; + + let start = pos; + if (text[start] === '\r') start++; + if (text[start] === '\n') start++; + if (start === pos) continue; // kein Stream-Schlüsselwort + + const ende = text.indexOf('endstream', start); + const stop = ende < 0 ? pdf.length : ende; + rest -= await entpackteGroesse(pdf.subarray(start, stop), rest + 1, 0); + if (rest < 0) return true; + pos = stop; + } +} + +/** Hat das PDF ein /Encrypt-Dictionary? */ +export function istVerschluesselt(pdf: Buffer): boolean { + return /\/Encrypt\b/.test(pdf.toString('latin1')); +} + +/** Entpackte Größe, höchstens `max` (dann gilt das Limit als gerissen). */ +async function entpackteGroesse( + daten: Buffer, + max: number, + tiefe: number, +): Promise { + if (!istZlib(daten)) return daten.length; + + const { groesse, ausgabe, ausgabeIstZlib } = await zaehleInflate(daten, max); + if (groesse >= max) return max; + if (tiefe >= MAX_KETTENTIEFE) return groesse; + + // Deflate-Kette (/Filter [/FlateDecode /FlateDecode]): innere Stufe prüfen. + if (ausgabe && istZlib(ausgabe)) { + return Math.max(groesse, await entpackteGroesse(ausgabe, max, tiefe + 1)); + } + // Zu große Ausgabe, die selbst wieder zlib ist – nicht weiter prüfbar. + if (ausgabeIstZlib) return max; + return groesse; +} + +function zaehleInflate( + daten: Buffer, + max: number, +): Promise<{ + groesse: number; + ausgabe: Buffer | null; + ausgabeIstZlib: boolean; +}> { + return new Promise((resolve) => { + const inflate = zlib.createInflate({ + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }); + const teile: Buffer[] = []; + let groesse = 0; + let behalten = true; + let erledigt = false; + let ausgabeIstZlib = false; + + const fertig = () => { + if (erledigt) return; + erledigt = true; + inflate.destroy(); + resolve({ + groesse, + ausgabe: behalten ? Buffer.concat(teile) : null, + ausgabeIstZlib, + }); + }; + + inflate.on('data', (chunk: Buffer) => { + if (groesse === 0) ausgabeIstZlib = istZlib(chunk); + groesse += chunk.length; + if (behalten && groesse <= MAX_KETTENPUFFER) teile.push(chunk); + else { + behalten = false; + teile.length = 0; + } + if (groesse >= max) fertig(); + }); + // Beschädigte Daten: pdf.js liest bis zum Fehler – genau so zählen. + inflate.on('error', fertig); + inflate.on('end', fertig); + inflate.end(daten); + }); +} + +/** zlib-Header: CMF 0x78, und (CMF·256 + FLG) durch 31 teilbar. */ +function istZlib(b: Buffer): boolean { + return b.length >= 2 && b[0] === 0x78 && ((b[0] << 8) | b[1]) % 31 === 0; +} diff --git a/paperless-backend/src/common/pfad-sicherheit.util.spec.ts b/paperless-backend/src/common/pfad-sicherheit.util.spec.ts new file mode 100644 index 0000000..6b9b8f5 --- /dev/null +++ b/paperless-backend/src/common/pfad-sicherheit.util.spec.ts @@ -0,0 +1,121 @@ +import { + mkdtemp, + mkdir, + writeFile, + symlink, + readFile, + lstat, +} from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + ersetzeUrlPlatzhalter, + sichererDateiname, + uebernehmeRegulaereDatei, +} from './pfad-sicherheit.util'; + +describe('sichererDateiname', () => { + it('lässt normale Namen unverändert', () => { + expect(sichererDateiname('2026-0042_Rechnung Müller.pdf')).toBe( + '2026-0042_Rechnung Müller.pdf', + ); + }); + + it.each([ + ['../../etc/cron.d/x.pdf', '.._.._etc_cron.d_x.pdf'], + ['a/b\\c.pdf', 'a_b_c.pdf'], + ['..', '_'], + ['.', '_'], + ['', '_'], + ['x\u0000y\r\n.pdf', 'x_y__.pdf'], + ])('macht %p zu einem einzelnen Pfadsegment', (eingabe, erwartet) => { + expect(sichererDateiname(eingabe)).toBe(erwartet); + }); +}); + +describe('ersetzeUrlPlatzhalter', () => { + it('setzt Werte URL-kodiert ein', () => { + expect( + ersetzeUrlPlatzhalter('https://beleg.example/{Jahr}/set/{Nummer}', { + Jahr: '2026', + Nummer: '42', + }), + ).toBe('https://beleg.example/2026/set/42'); + }); + + it('verhindert Pfad- und Query-Manipulation', () => { + expect( + ersetzeUrlPlatzhalter('https://beleg.example/set/{Nummer}?k=1', { + Nummer: '../../admin/delete?all=1#', + }), + ).toBe( + 'https://beleg.example/set/..%2F..%2Fadmin%2Fdelete%3Fall%3D1%23?k=1', + ); + }); + + it('ersetzt jedes Vorkommen', () => { + expect(ersetzeUrlPlatzhalter('/{Nummer}/{Nummer}', { Nummer: 'a b' })).toBe( + '/a%20b/a%20b', + ); + }); +}); + +describe('uebernehmeRegulaereDatei', () => { + let wurzel: string; + let quelle: string; + let ziel: string; + + beforeEach(async () => { + wurzel = await mkdtemp(join(tmpdir(), 'scan-')); + await mkdir(join(wurzel, 'scans', 'bob'), { recursive: true }); + await mkdir(join(wurzel, 'data')); + await writeFile(join(wurzel, 'geheim.txt'), 'GEHEIM'); + quelle = join(wurzel, 'scans', 'bob', 'scan.pdf'); + ziel = join(wurzel, 'data', 'document.pdf'); + }); + + // Wie in Produktion: /mnt/scans und /mnt/data sind verschiedene Mounts. + const crossDevice = () => + Promise.reject(Object.assign(new Error('EXDEV'), { code: 'EXDEV' })); + + it.each([ + ['rename', undefined], + ['copy (EXDEV)', crossDevice], + ])('übernimmt reguläre Dateien per %s', async (_n, rename) => { + await writeFile(quelle, '%PDF-1.7'); + await uebernehmeRegulaereDatei(quelle, ziel, { rename }); + expect(await readFile(ziel, 'utf8')).toBe('%PDF-1.7'); + await expect(lstat(quelle)).rejects.toThrow(); + }); + + it.each([ + ['rename', undefined], + ['copy (EXDEV)', crossDevice], + ])('lehnt Symlinks ab (%s)', async (_n, rename) => { + await symlink(join(wurzel, 'geheim.txt'), quelle); + await expect( + uebernehmeRegulaereDatei(quelle, ziel, { rename }), + ).rejects.toThrow(/keine reguläre Datei/); + await expect(lstat(ziel)).rejects.toThrow(); + }); + + it('lehnt Dateien in symverlinkten Verzeichnissen ab', async () => { + await mkdir(join(wurzel, 'fremd')); + await writeFile(join(wurzel, 'fremd', 'x.pdf'), 'FREMD'); + await symlink(join(wurzel, 'fremd'), join(wurzel, 'scans', 'eve')); + await expect( + uebernehmeRegulaereDatei(join(wurzel, 'scans', 'eve', 'x.pdf'), ziel, { + rename: crossDevice, + }), + ).rejects.toThrow(/Verzeichnis/); + }); + + it('überschreibt beim Kopieren keine vorhandene Zieldatei', async () => { + await writeFile(quelle, 'NEU'); + await writeFile(ziel, 'ALT'); + await expect( + uebernehmeRegulaereDatei(quelle, ziel, { rename: crossDevice }), + ).rejects.toThrow(); + expect(await readFile(ziel, 'utf8')).toBe('ALT'); + }); +}); diff --git a/paperless-backend/src/common/pfad-sicherheit.util.ts b/paperless-backend/src/common/pfad-sicherheit.util.ts new file mode 100644 index 0000000..bfa6fd5 --- /dev/null +++ b/paperless-backend/src/common/pfad-sicherheit.util.ts @@ -0,0 +1,98 @@ +import { constants } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { pipeline } from 'stream/promises'; + +/** + * Macht aus einem (teils von außen stammenden) Namen genau ein Pfadsegment: + * Pfadtrenner und Steuerzeichen werden zu `_`, `.`/`..`/leer zu `_`. + * Verhindert, dass z.B. ein Barcode-Wert den Exportpfad verlässt. + */ +export function sichererDateiname(name: string): string { + // eslint-disable-next-line no-control-regex + const bereinigt = name.replace(/[/\\\u0000-\u001f\u007f]/g, '_'); + return bereinigt === '' || bereinigt === '.' || bereinigt === '..' + ? '_' + : bereinigt; +} + +/** + * Ersetzt `{Name}`-Platzhalter in einer URL-Vorlage URL-kodiert, damit ein + * Wert Pfad, Query oder Fragment der konfigurierten URL nicht ändern kann. + */ +export function ersetzeUrlPlatzhalter( + vorlage: string, + werte: Record, +): string { + return Object.entries(werte).reduce( + (url, [name, wert]) => + url.split(`{${name}}`).join(encodeURIComponent(wert)), + vorlage, + ); +} + +/** + * Verschiebt eine Datei von einer fremd beschreibbaren Freigabe (Scanner), + * aber nur, wenn sie eine reguläre Datei in einem echten Verzeichnis ist. + * Symlinks würden sonst beliebige Dateien des Servers in den Posteingang + * holen – beim Kopieren über Mount-Grenzen folgt `copyFile` ihnen. + */ +export async function uebernehmeRegulaereDatei( + quelle: string, + ziel: string, + optionen: { + rename?: (von: string, nach: string) => Promise; + } = {}, +): Promise { + const verzeichnis = await fs.lstat(path.dirname(quelle)); + if (!verzeichnis.isDirectory()) { + throw new Error(`${path.dirname(quelle)} ist kein echtes Verzeichnis`); + } + if (!(await fs.lstat(quelle)).isFile()) { + throw new Error(`${quelle} ist keine reguläre Datei`); + } + + const rename = optionen.rename ?? fs.rename; + try { + await rename(quelle, ziel); + // Zwischen Prüfung und rename könnte die Datei ersetzt worden sein. + if (!(await fs.lstat(ziel)).isFile()) { + await fs.unlink(ziel); + throw new Error(`${quelle} ist keine reguläre Datei`); + } + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + } + + // Über Mount-Grenzen: ohne Symlinks zu folgen öffnen, dann kopieren. + const lesen = await fs.open( + quelle, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + if (!(await lesen.stat()).isFile()) { + throw new Error(`${quelle} ist keine reguläre Datei`); + } + const schreiben = await fs.open(ziel, 'wx'); + try { + await pipeline(lesen.createReadStream(), schreiben.createWriteStream()); + } catch (err) { + await fs.unlink(ziel).catch(() => undefined); + throw err; + } finally { + await schreiben.close().catch(() => undefined); + } + } finally { + await lesen.close().catch(() => undefined); + } + + // Wenn das Löschen scheitert, Kopie zurückrollen, damit ein kaputter Mount + // nicht bei jedem Neustart Duplikate produziert. + try { + await fs.unlink(quelle); + } catch (err) { + await fs.unlink(ziel).catch(() => undefined); + throw err; + } +} diff --git a/paperless-backend/src/daily-digest/daily-digest.controller.ts b/paperless-backend/src/daily-digest/daily-digest.controller.ts index 936fe9d..d92e33b 100644 --- a/paperless-backend/src/daily-digest/daily-digest.controller.ts +++ b/paperless-backend/src/daily-digest/daily-digest.controller.ts @@ -1,11 +1,13 @@ import { Controller, Post, Request, HttpCode } from '@nestjs/common'; import { DailyDigestService } from './daily-digest.service'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/daily-digest') export class DailyDigestController { constructor(private readonly dailyDigestService: DailyDigestService) {} @Post('send-now') + @AllowAnyAuthenticated() @HttpCode(200) async sendNow(@Request() req: any) { const { userId, email, preferredUsername, groups } = req.user; diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 9802c63..03ecbbd 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -10,6 +10,7 @@ import { HttpStatus, Logger, Delete, + ParseIntPipe, } from '@nestjs/common'; import type { Response } from 'express'; import { EmailImportService } from './email-import.service'; @@ -91,8 +92,13 @@ export class EmailImportController { @Post('belegnummer/release') @RequirePermissions(Permission.VIEW_MAIL) - async releaseBelegnummer(@Body() body: { date: string; number: string }) { - if (!body.date || !body.number) + async releaseBelegnummer(@Body() body: { date: unknown; number: unknown }) { + if ( + typeof body?.date !== 'string' || + typeof body.number !== 'string' || + !body.date || + !body.number + ) throw new HttpException( 'Date and number required', HttpStatus.BAD_REQUEST, @@ -201,8 +207,8 @@ export class EmailImportController { @Get('attachments/:attachmentId/pages/:page/thumb') @RequirePermissions(Permission.VIEW_MAIL) async getPageThumbnail( - @Param('attachmentId') attachmentId: number, - @Param('page') page: number, + @Param('attachmentId', ParseIntPipe) attachmentId: number, + @Param('page', ParseIntPipe) page: number, @Res() res: Response, ) { const hasPreview = await this.pageCache.hasPreview(attachmentId, page); @@ -217,8 +223,8 @@ export class EmailImportController { @Get('attachments/:attachmentId/pages/:page/preview') @RequirePermissions(Permission.VIEW_MAIL) async getPagePreview( - @Param('attachmentId') attachmentId: number, - @Param('page') page: number, + @Param('attachmentId', ParseIntPipe) attachmentId: number, + @Param('page', ParseIntPipe) page: number, @Res() res: Response, ) { const hasPreview = await this.pageCache.hasPreview(attachmentId, page); diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index c0fd1b2..412b25e 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -28,6 +28,7 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; import * as crypto from 'crypto'; +import { ersetzeUrlPlatzhalter } from '../common/pfad-sicherheit.util'; function sanitizeFilename(name: string): string { return name @@ -152,7 +153,18 @@ export class EmailImportService { const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj) .getFullYear() .toString(); - return urlTemplate.replace('{Jahr}', year); + return ersetzeUrlPlatzhalter(urlTemplate, { Jahr: year }); + } + + /** URL mit Jahr und Belegnummer – die Nummer kommt u.a. aus Barcodes. */ + private buildNummerUrl( + urlTemplate: string, + dateStr: string, + nummer: string, + ): string { + return ersetzeUrlPlatzhalter(this.buildUrl(urlTemplate, dateStr), { + Nummer: nummer, + }); } async getBelegnummer(emailDate: string): Promise { @@ -207,8 +219,7 @@ export class EmailImportService { } const cleanNumber = number.replace(/^0+/, '') || '0'; - let url = this.buildUrl(urlTemplate, emailDate); - url = url.replace('{Nummer}', cleanNumber); + const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber); try { this.logger.log( @@ -231,8 +242,7 @@ export class EmailImportService { ); const cleanNumber = number.replace(/^0+/, '') || '0'; - let url = this.buildUrl(urlTemplate, emailDate); - url = url.replace('{Nummer}', cleanNumber); + const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber); try { this.logger.log( diff --git a/paperless-backend/src/email/email-page-cache.service.spec.ts b/paperless-backend/src/email/email-page-cache.service.spec.ts new file mode 100644 index 0000000..e82f718 --- /dev/null +++ b/paperless-backend/src/email/email-page-cache.service.spec.ts @@ -0,0 +1,51 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EmailPageCacheService } from './email-page-cache.service'; +import { PageCacheService } from '../barcode/page-cache.service'; + +const config = { + get: (key: string) => + key === 'MAILS_DATA_DIR' ? '/data/mails' : '/data/inbox', +} as unknown as ConfigService; + +describe('EmailPageCacheService – Pfade', () => { + const cache = new EmailPageCacheService(config); + + it('baut Pfade für numerische Anhang-IDs', () => { + expect(cache.previewPath(17, 2)).toBe('/data/mails/17/page-2.preview.png'); + expect(cache.thumbnailPath('17', 1)).toBe( + '/data/mails/17/page-1.thumb.png', + ); + }); + + it.each([ + '../inbox/0b6c2b6e-7c1e-4d8a-9a51-3f0c2d1e4b5a', + '..', + '17/../../etc', + '-1', + '1.5', + '', + ])('lehnt die Anhang-ID %p ab', (id) => { + expect(() => cache.previewPath(id, 1)).toThrow(BadRequestException); + }); + + it.each([0, -1, 1.5, NaN])('lehnt die Seite %p ab', (page) => { + expect(() => cache.thumbnailPath(17, page)).toThrow(BadRequestException); + }); +}); + +describe('PageCacheService (Scanner) – Pfade', () => { + const cache = new PageCacheService(config); + const uuid = '0b6c2b6e-7c1e-4d8a-9a51-3f0c2d1e4b5a'; + + it('baut Pfade für UUIDs', () => { + expect(cache.documentDir(uuid)).toBe(`/data/inbox/${uuid}`); + }); + + it.each(['../mails/17', '..', `${uuid}/..`, 'abc'])( + 'lehnt die Dokument-ID %p ab', + (id) => { + expect(() => cache.documentDir(id)).toThrow(BadRequestException); + }, + ); +}); diff --git a/paperless-backend/src/email/email-page-cache.service.ts b/paperless-backend/src/email/email-page-cache.service.ts index 80f8ff0..8d43231 100644 --- a/paperless-backend/src/email/email-page-cache.service.ts +++ b/paperless-backend/src/email/email-page-cache.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import * as fs from 'fs/promises'; @@ -6,6 +6,12 @@ import sharp from 'sharp'; const THUMBNAIL_WIDTH = 180; +function pruefeSeite(page: number): void { + if (!Number.isSafeInteger(page) || page < 1) { + throw new BadRequestException('Ungültige Seitenzahl'); + } +} + @Injectable() export class EmailPageCacheService { private readonly logger = new Logger(EmailPageCacheService.name); @@ -18,11 +24,20 @@ export class EmailPageCacheService { ); } + /** + * Die ID landet im Dateipfad – nur positive ganze Zahlen zulassen, sonst + * führt z.B. `..%2Finbox%2F` aus dem Mail-Cache in den Scanner-Cache. + */ attachmentDir(attachmentId: number | string): string { - return path.join(this.mailsRoot, attachmentId.toString()); + const id = String(attachmentId); + if (!/^[1-9]\d*$/.test(id)) { + throw new BadRequestException('Ungültige Anhang-ID'); + } + return path.join(this.mailsRoot, id); } previewPath(attachmentId: number | string, page: number): string { + pruefeSeite(page); return path.join( this.attachmentDir(attachmentId), `page-${page}.preview.png`, @@ -30,6 +45,7 @@ export class EmailPageCacheService { } thumbnailPath(attachmentId: number | string, page: number): string { + pruefeSeite(page); return path.join( this.attachmentDir(attachmentId), `page-${page}.thumb.png`, diff --git a/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts b/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts new file mode 100644 index 0000000..81241ff --- /dev/null +++ b/paperless-backend/src/email/pdf-fingerprint.bombe.spec.ts @@ -0,0 +1,31 @@ +import * as zlib from 'zlib'; +import { PDFParse } from 'pdf-parse'; +import { computeContentFingerprint } from './pdf-fingerprint.util'; + +jest.mock('pdf-parse', () => ({ PDFParse: jest.fn() })); + +describe('computeContentFingerprint – ungeprüfte Inhalte', () => { + it('übergibt eine Deflate-Bombe nicht an pdf.js', async () => { + const bombe = zlib.deflateSync(Buffer.alloc(300 * 1024 * 1024)); + const pdf = Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${bombe.length} /Filter /FlateDecode >>\nstream\n`, + 'latin1', + ), + bombe, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); + + await expect(computeContentFingerprint(pdf)).resolves.toBe(''); + expect(PDFParse).not.toHaveBeenCalled(); + }); + + it('übergibt verschlüsselte PDFs nicht an pdf.js', async () => { + const pdf = Buffer.from( + '%PDF-1.7\ntrailer\n<< /Encrypt 5 0 R >>\n%%EOF\n', + 'latin1', + ); + await expect(computeContentFingerprint(pdf)).resolves.toBe(''); + expect(PDFParse).not.toHaveBeenCalled(); + }); +}); diff --git a/paperless-backend/src/email/pdf-fingerprint.util.ts b/paperless-backend/src/email/pdf-fingerprint.util.ts index a5caf9f..4341a2f 100644 --- a/paperless-backend/src/email/pdf-fingerprint.util.ts +++ b/paperless-backend/src/email/pdf-fingerprint.util.ts @@ -1,5 +1,9 @@ import * as crypto from 'crypto'; import { PDFParse } from 'pdf-parse'; +import { + istVerschluesselt, + ueberschreitetDekompressionsLimit, +} from '../common/pdf-dekompression.util'; /** * Unterhalb dieser Textlänge gilt ein PDF als "ohne Textebene" (z.B. Scan) – @@ -15,11 +19,21 @@ const MIN_TEXT_LENGTH = 20; * * @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text * (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das - * PDF nicht gelesen werden konnte. + * PDF nicht gelesen werden konnte. Verschlüsselte PDFs und solche, + * die entpackt zu groß wären, liefern ebenfalls `''`. */ export async function computeContentFingerprint( pdf: Buffer, ): Promise { + // pdf.js entpackt ohne Limit – Dekompressionsbomben gar nicht erst laden. + // Verschlüsselte Streams entschlüsselt pdf.js erst intern; vorab sind sie + // nicht prüfbar. Beides gilt als "nicht vergleichbar" (''). + if ( + istVerschluesselt(pdf) || + (await ueberschreitetDekompressionsLimit(pdf)) + ) { + return ''; + } // pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben const parser = new PDFParse({ data: new Uint8Array(pdf) }); try { diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts index d104611..9f10cb6 100644 --- a/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts +++ b/paperless-backend/src/erechnung/pdf-attachment.util.spec.ts @@ -49,3 +49,24 @@ describe('extrahiereEingebetteteDateien', () => { expect(await extrahiereEingebetteteDateien(pdf)).toEqual([]); }); }); + +describe('extrahiereEingebetteteDateien – Dekompressionsbombe', () => { + it('lädt ein PDF mit Deflate-Bombe gar nicht erst', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const zlib = require('zlib') as typeof import('zlib'); + const bombe = zlib.deflateSync(Buffer.alloc(300 * 1024 * 1024)); + const pdf = Buffer.concat([ + Buffer.from( + `%PDF-1.7\n1 0 obj\n<< /Length ${bombe.length} /Filter /FlateDecode >>\nstream\n`, + 'latin1', + ), + bombe, + Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1'), + ]); + const load = jest.spyOn(PDFDocument, 'load'); + + await expect(extrahiereEingebetteteDateien(pdf)).resolves.toEqual([]); + expect(load).not.toHaveBeenCalled(); + load.mockRestore(); + }); +}); diff --git a/paperless-backend/src/erechnung/pdf-attachment.util.ts b/paperless-backend/src/erechnung/pdf-attachment.util.ts index 05c9c47..2ddcd7b 100644 --- a/paperless-backend/src/erechnung/pdf-attachment.util.ts +++ b/paperless-backend/src/erechnung/pdf-attachment.util.ts @@ -9,6 +9,7 @@ import { PDFString, decodePDFRawStream, } from 'pdf-lib'; +import { ueberschreitetDekompressionsLimit } from '../common/pdf-dekompression.util'; export interface EingebetteteDatei { name: string; @@ -30,6 +31,9 @@ const MAX_TIEFE = 32; export async function extrahiereEingebetteteDateien( pdf: Buffer, ): Promise { + // pdf-lib entpackt Objekt- und Datei-Streams ohne Limit. + if (await ueberschreitetDekompressionsLimit(pdf)) return []; + const doc = await PDFDocument.load(pdf, { ignoreEncryption: true, throwOnInvalidObject: false, diff --git a/paperless-backend/src/freigabe/freigabe.service.ts b/paperless-backend/src/freigabe/freigabe.service.ts index f0562a8..183b565 100644 --- a/paperless-backend/src/freigabe/freigabe.service.ts +++ b/paperless-backend/src/freigabe/freigabe.service.ts @@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from '../paperless/paperless.service'; - -const FREIGABE_FIELD_ID = 15; +import { FREIGABE_FIELD_ID } from '../paperless/status-felder.util'; @Injectable() export class FreigabeService { diff --git a/paperless-backend/src/inbox/clients.controller.ts b/paperless-backend/src/inbox/clients.controller.ts index 8baa58b..588cf2d 100644 --- a/paperless-backend/src/inbox/clients.controller.ts +++ b/paperless-backend/src/inbox/clients.controller.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Client } from '../database/entities/client.entity'; import { UserClient } from '../database/entities/user-client.entity'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/clients') export class ClientsController { @@ -15,6 +16,7 @@ export class ClientsController { ) {} @Get() + @AllowAnyAuthenticated() async getMyClients(@Request() req: any) { const userId = req.user.userId; const mappings = await this.userClientRepo.find({ diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 453cc99..6360e58 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -27,6 +27,7 @@ import { kennzahlenAusPdf, type VergleichKennzahlen, } from '../paperless/vergleich-kennzahlen.util'; +import { parseSendEmailBody } from './send-email-body.util'; @Controller('api/inbox') @RequirePermissions(Permission.VIEW_SCANNER) @@ -284,17 +285,10 @@ export class InboxController { @HttpCode(204) async sendEmail( @Param('id') id: string, - @Body() - body: { - to: string; - subject: string; - body: string; - html?: string; - segments: { pages: number[]; filename: string }[]; - sender?: string; - }, + @Body() rawBody: unknown, @Request() req: any, ): Promise { + const body = parseSendEmailBody(rawBody); const preferredUsername: string | null = req.user?.preferredUsername ?? null; const smtpOverride = diff --git a/paperless-backend/src/inbox/send-email-body.util.spec.ts b/paperless-backend/src/inbox/send-email-body.util.spec.ts new file mode 100644 index 0000000..a644a4e --- /dev/null +++ b/paperless-backend/src/inbox/send-email-body.util.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import { parseSendEmailBody } from './send-email-body.util'; + +describe('parseSendEmailBody', () => { + const gueltig = { + to: 'a@example.org', + subject: 'Betreff', + body: 'Text', + html: '

Text

', + segments: [{ pages: [1, 2], filename: 'beleg' }], + sender: 'user', + }; + + it('übernimmt einen gültigen Body', () => { + expect(parseSendEmailBody(gueltig)).toEqual(gueltig); + }); + + it('lässt html und sender weg, wenn sie fehlen', () => { + const ohne = { ...gueltig, html: undefined, sender: undefined }; + expect(parseSendEmailBody(ohne)).toEqual({ + to: gueltig.to, + subject: gueltig.subject, + body: gueltig.body, + segments: gueltig.segments, + }); + }); + + it.each([ + ['html als Objekt mit path', { html: { path: '/proc/self/environ' } }], + ['body als Objekt mit href', { body: { href: 'http://169.254.169.254/' } }], + ['to als Objekt', { to: { address: 'a@example.org' } }], + ['to als Array', { to: ['a@example.org'] }], + ['subject fehlt', { subject: undefined }], + ['segments kein Array', { segments: 'x' }], + ['Seite keine Zahl', { segments: [{ pages: ['1'], filename: 'x' }] }], + [ + 'filename kein String', + { segments: [{ pages: [1], filename: { path: '/etc/passwd' } }] }, + ], + ['sender kein String', { sender: { x: 1 } }], + ])('lehnt ab: %s', (_name, patch) => { + expect(() => parseSendEmailBody({ ...gueltig, ...patch })).toThrow( + BadRequestException, + ); + }); + + it('lehnt einen fehlenden Body ab', () => { + expect(() => parseSendEmailBody(undefined)).toThrow(BadRequestException); + }); +}); diff --git a/paperless-backend/src/inbox/send-email-body.util.ts b/paperless-backend/src/inbox/send-email-body.util.ts new file mode 100644 index 0000000..da3ece8 --- /dev/null +++ b/paperless-backend/src/inbox/send-email-body.util.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; + +export interface SendEmailBody { + to: string; + subject: string; + body: string; + html?: string; + segments: { pages: number[]; filename: string }[]; + sender?: string; +} + +/** + * Prüft den Body von `POST inbox/:id/send-email` streng auf Strings bzw. Zahlen. + * Ohne diese Prüfung landen Objekte wie `{ path: '/proc/self/environ' }` direkt + * bei nodemailer, das sie als Datei- bzw. URL-Quelle interpretiert. + */ +export function parseSendEmailBody(raw: unknown): SendEmailBody { + if (!isObject(raw)) throw new BadRequestException('Ungültiger Request-Body'); + const { to, subject, body, html, segments, sender } = raw; + + for (const [name, wert] of [ + ['to', to], + ['subject', subject], + ['body', body], + ] as const) { + if (typeof wert !== 'string') { + throw new BadRequestException(`"${name}" muss ein Text sein`); + } + } + if (html !== undefined && typeof html !== 'string') { + throw new BadRequestException('"html" muss ein Text sein'); + } + if (sender !== undefined && typeof sender !== 'string') { + throw new BadRequestException('"sender" muss ein Text sein'); + } + if (!Array.isArray(segments)) { + throw new BadRequestException('"segments" muss eine Liste sein'); + } + const geprueft = segments.map((seg) => { + if ( + !isObject(seg) || + typeof seg.filename !== 'string' || + !Array.isArray(seg.pages) || + !seg.pages.every((p) => Number.isInteger(p)) + ) { + throw new BadRequestException( + 'Jedes Segment braucht "filename" (Text) und "pages" (ganze Zahlen)', + ); + } + return { pages: seg.pages as number[], filename: seg.filename }; + }); + + const ergebnis: SendEmailBody = { + to: to as string, + subject: subject as string, + body: body as string, + segments: geprueft, + }; + if (html !== undefined) ergebnis.html = html; + if (sender !== undefined) ergebnis.sender = sender; + return ergebnis; +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/paperless-backend/src/kontonummern/kontonummern.controller.ts b/paperless-backend/src/kontonummern/kontonummern.controller.ts index 256db68..6bfb2db 100644 --- a/paperless-backend/src/kontonummern/kontonummern.controller.ts +++ b/paperless-backend/src/kontonummern/kontonummern.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { KontonummernService } from './kontonummern.service'; -import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; export class CreateKontonummerDto { correspondentId: number; @@ -8,7 +9,8 @@ export class CreateKontonummerDto { } @Controller('api/kontonummern') -@UseGuards(JwtOrApiKeyGuard) +// Bankverbindungen: nur für die Dokumentbearbeitung (Posteingang, manuell) +@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) export class KontonummernController { constructor(private readonly kontonummernService: KontonummernService) {} diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts index 47ffeff..9e0288a 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -17,7 +17,7 @@ import { import type { Response } from 'express'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; import { LabelPrintAgentService } from './label-print-agent.service'; @@ -57,6 +57,8 @@ export class LabelPrintAgentController { // Agent: SSE-Stream für neue Druckaufträge @Sse('events') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) sseEvents( @Res({ passthrough: true }) res: Response, ): Observable { @@ -68,6 +70,8 @@ export class LabelPrintAgentController { // Agent: nächsten Job abholen (Polling) @Get('jobs/next') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) { const job = await this.service.claimNextJob(agentId ?? 'unknown'); if (!job) { @@ -87,6 +91,8 @@ export class LabelPrintAgentController { // Agent: Bild separat abrufen @Get('jobs/:id/image') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async getImage( @Param('id', ParseIntPipe) id: number, @Res({ passthrough: true }) res: Response, @@ -100,6 +106,8 @@ export class LabelPrintAgentController { // Agent: Druck erfolgreich @Post('jobs/:id/printed') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) @HttpCode(HttpStatus.OK) async markPrinted( @Param('id', ParseIntPipe) id: number, @@ -115,6 +123,8 @@ export class LabelPrintAgentController { // Agent: Druckfehler @Post('jobs/:id/error') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) @HttpCode(HttpStatus.OK) async markError( @Param('id', ParseIntPipe) id: number, diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts new file mode 100644 index 0000000..18f7475 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.spec.ts @@ -0,0 +1,135 @@ +import { ConflictException } from '@nestjs/common'; +import { LabelPrintAgentService } from './label-print-agent.service'; +import type { LabelPrintJob } from '../database/entities/label-print-job.entity'; +import type { BarcodeTemplate } from '../database/entities/barcode-template.entity'; +import type { LabelRendererService } from './label-renderer.service'; + +const TEMPLATE = { + Id: 7, + LabelEnabled: true, + LabelWidthMm: 57, + LabelHeightMm: 32, + LabelInputFields: [{ name: 'kunde', type: 'text' }], + LabelGetUrl: 'https://beleg.example/next?kunde={kunde}', + LabelPrintedUrl: 'https://beleg.example/printed/{number}?kunde={kunde}', + LabelReleaseUrl: 'https://beleg.example/release/{number}', +} as unknown as BarcodeTemplate; + +function aufbau(job?: Partial) { + const gespeichert: Partial[] = []; + const jobRepo = { + findOne: jest.fn().mockResolvedValue(job ? { ...job } : null), + create: jest.fn((j: Partial) => j), + save: jest.fn((j: Partial) => { + gespeichert.push({ ...j }); + return Promise.resolve(j); + }), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const templateRepo = { findOne: jest.fn().mockResolvedValue(TEMPLATE) }; + const service = new LabelPrintAgentService( + jobRepo as never, + templateRepo as never, + {} as LabelRendererService, + ); + return { service, jobRepo, gespeichert }; +} + +const fetchMock = jest.fn(); +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ text: () => Promise.resolve('2026-0042') }); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +const BASIS = { + Id: 1, + BarcodeTemplateId: 7, + LabelVariables: { number: '2026-0042', kunde: 'Hof' }, +}; +const gesperrt = { ...BASIS, Status: 'pending' as const, LockedAt: new Date() }; + +describe('LabelPrintAgentService – Statusübergänge', () => { + it('printed: nur für abgeholte, offene Jobs – ruft dann die Printed-URL auf', async () => { + const { service, jobRepo } = aufbau(gesperrt); + await service.markPrinted(1, 'agent', 'drucker'); + expect(jobRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ Id: 1, Status: 'pending' }), + expect.objectContaining({ Status: 'printed' }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('printed: erneute Meldung ist ein No-op ohne zweiten Callback', async () => { + const { service, jobRepo } = aufbau({ ...BASIS, Status: 'printed' }); + await service.markPrinted(1, 'agent', 'drucker'); + expect(jobRepo.update).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('error nach printed wird abgelehnt – keine Freigabe der vergebenen Nummer', async () => { + const { service } = aufbau({ ...BASIS, Status: 'printed' }); + await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow( + ConflictException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('printed/error für noch nicht abgeholte Jobs werden abgelehnt', async () => { + const offen = { ...BASIS, Status: 'pending' as const, LockedAt: null }; + await expect( + aufbau(offen).service.markPrinted(1, 'a', 'd'), + ).rejects.toThrow(ConflictException); + await expect( + aufbau(offen).service.markError(1, 'a', 'd', 'x'), + ).rejects.toThrow(ConflictException); + }); + + it('gleichzeitige Meldung: verliert das bedingte Update, gibt es keinen Callback', async () => { + const { service, jobRepo } = aufbau(gesperrt); + jobRepo.update.mockResolvedValue({ affected: 0 }); + await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow( + ConflictException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('error für abgeholte Jobs gibt die Nummer frei', async () => { + const { service } = aufbau(gesperrt); + await service.markError(1, 'a', 'd', 'Papierstau'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/release/2026-0042', + { method: 'POST' }, + ); + }); +}); + +describe('LabelPrintAgentService.createJob – Feldwerte', () => { + it('übernimmt nur Felder der Vorlage – "number" kann nicht vorgegeben werden', async () => { + fetchMock.mockResolvedValue({ text: () => Promise.resolve('') }); + const { service, gespeichert } = aufbau(); + await service.createJob(7, { kunde: 'Hof', number: '1234', fremd: 'x' }); + const vars = gespeichert[0].LabelVariables as Record; + expect(vars).toEqual({ kunde: 'Hof', number: '' }); + }); + + it('kodiert Feldwerte in URLs', async () => { + const { service } = aufbau(); + await service.createJob(7, { kunde: 'a/../admin?x=1#' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/next?kunde=a%2F..%2Fadmin%3Fx%3D1%23', + ); + }); + + it('kodiert auch die Nummer im Printed-Callback', async () => { + const { service } = aufbau({ + ...gesperrt, + LabelVariables: { number: '../../x', kunde: 'A B' }, + }); + await service.markPrinted(1, 'a', 'd'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://beleg.example/printed/..%2F..%2Fx?kunde=A%20B', + { method: 'POST' }, + ); + }); +}); diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts index 617bba2..dc371f4 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.service.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -3,9 +3,10 @@ import { Logger, NotFoundException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, LessThan, IsNull } from 'typeorm'; +import { Repository, LessThan, IsNull, Not } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { LabelPrintJob } from '../database/entities/label-print-job.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; @@ -20,17 +21,22 @@ function isSafeUrl(url: string): boolean { } } -function applyVars(template: string, vars: Record): string { +/** + * Setzt `{name}` bzw. `{name:breite}` in eine URL-Vorlage ein. Die Werte + * stammen teils vom Benutzer bzw. aus einer externen Antwort und werden + * deshalb URL-kodiert – sonst ließe sich Pfad oder Query der Admin-URL ändern. + */ +function applyUrlVars(template: string, vars: Record): string { return template.replace(/\{([^}]+)\}/g, (_, key: string) => { const colonIdx = key.indexOf(':'); if (colonIdx !== -1) { const varName = key.slice(0, colonIdx); const width = parseInt(key.slice(colonIdx + 1), 10); if (!isNaN(width) && varName in vars) { - return vars[varName].padStart(width, '0'); + return encodeURIComponent(vars[varName].padStart(width, '0')); } } - return vars[key] ?? ''; + return encodeURIComponent(vars[key] ?? ''); }); } @@ -70,13 +76,20 @@ export class LabelPrintAgentService { 'Etikett-Druck für dieses Template nicht aktiviert', ); - // Variablen aufbauen - const vars: Record = { ...fieldValues }; + // Variablen aufbauen – nur Eingabefelder der Vorlage. Abgeleitete Werte + // wie {number} (aus der GET-URL) darf der Aufrufer nicht vorgeben. + const vars: Record = {}; + for (const field of template.LabelInputFields ?? []) { + const wert: unknown = fieldValues?.[field.name]; + if (typeof wert === 'string' || typeof wert === 'number') { + vars[field.name] = String(wert); + } + } // Datum-Felder: year/month/day separat ablegen for (const field of template.LabelInputFields ?? []) { - if (field.type === 'date' && fieldValues[field.name]) { - const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD + if (field.type === 'date' && vars[field.name]) { + const raw = vars[field.name]; // erwartet YYYY-MM-DD const parts = raw.split('-'); if (parts.length === 3) { vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy @@ -89,7 +102,7 @@ export class LabelPrintAgentService { // GET-URL aufrufen → {number} if (template.LabelGetUrl) { - const url = applyVars(template.LabelGetUrl, vars); + const url = applyUrlVars(template.LabelGetUrl, vars); if (isSafeUrl(url)) { try { const res = await fetch(url); @@ -170,15 +183,15 @@ export class LabelPrintAgentService { agentId: string, printerName: string, ): Promise { - const job = await this.jobRepo.findOne({ where: { Id: jobId } }); - if (!job) throw new NotFoundException('Job nicht gefunden'); - - job.Status = 'printed'; - job.PrintedAt = new Date(); - job.PrintedByAgent = agentId; - job.PrinterName = printerName; - await this.jobRepo.save(job); - + const job = await this.ladeJob(jobId); + // Wiederholte Meldung (z.B. Retry nach Netzfehler): nichts mehr tun. + if (job.Status === 'printed') return; + await this.schliesseJobAb(job, { + Status: 'printed', + PrintedAt: new Date(), + PrintedByAgent: agentId, + PrinterName: printerName, + }); await this.callUrl('PRINTED', job); } @@ -188,16 +201,45 @@ export class LabelPrintAgentService { printerName: string, errorMessage: string, ): Promise { + const job = await this.ladeJob(jobId); + if (job.Status === 'error') return; + await this.schliesseJobAb(job, { + Status: 'error', + PrintedByAgent: agentId, + PrinterName: printerName, + ErrorMessage: errorMessage, + }); + // Gibt die reservierte Nummer frei – darf nur für nie gedruckte Jobs passieren. + await this.callUrl('RELEASE', job); + } + + private async ladeJob(jobId: number): Promise { const job = await this.jobRepo.findOne({ where: { Id: jobId } }); if (!job) throw new NotFoundException('Job nicht gefunden'); + return job; + } - job.Status = 'error'; - job.PrintedByAgent = agentId; - job.PrinterName = printerName; - job.ErrorMessage = errorMessage; - await this.jobRepo.save(job); - - await this.callUrl('RELEASE', job); + /** + * Abschluss nur für abgeholte, noch offene Jobs – bedingt in der DB, damit + * bei gleichzeitigen Meldungen genau eine gewinnt und nur ein Callback läuft. + */ + private async schliesseJobAb( + job: LabelPrintJob, + aenderung: Partial, + ): Promise { + if (job.Status !== 'pending' || !job.LockedAt) { + throw new ConflictException( + `Job ${job.Id} ist nicht in Bearbeitung (Status: ${job.Status})`, + ); + } + const { affected } = await this.jobRepo.update( + { Id: job.Id, Status: 'pending', LockedAt: Not(IsNull()) }, + aenderung, + ); + if (!affected) { + throw new ConflictException(`Job ${job.Id} wurde bereits abgeschlossen`); + } + Object.assign(job, aenderung); } async renderPreview( @@ -248,7 +290,7 @@ export class LabelPrintAgentService { type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; if (!urlTemplate) return; - const url = applyVars(urlTemplate, job.LabelVariables ?? {}); + const url = applyUrlVars(urlTemplate, job.LabelVariables ?? {}); if (!isSafeUrl(url)) { this.logger.warn( `${type}-URL übersprungen (ungültiges Protokoll): ${url}`, diff --git a/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts b/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts new file mode 100644 index 0000000..225d9ac --- /dev/null +++ b/paperless-backend/src/paperless/dokument-zugriff.util.spec.ts @@ -0,0 +1,67 @@ +import { Permission } from '../auth/permissions.enum'; +import { + POSTEINGANG_TAG_ID, + darfDokumentBearbeiten, + dokumentKurzinfo, +} from './dokument-zugriff.util'; + +const MANUELL = 6; + +describe('darfDokumentBearbeiten', () => { + it('Posteingang-Rolle: nur Dokumente mit Posteingang-Tag', () => { + const rechte = [Permission.VIEW_INBOX]; + expect( + darfDokumentBearbeiten([POSTEINGANG_TAG_ID, 9], rechte, MANUELL), + ).toBe(true); + expect(darfDokumentBearbeiten([9], rechte, MANUELL)).toBe(false); + expect(darfDokumentBearbeiten([MANUELL], rechte, MANUELL)).toBe(false); + }); + + it('Buchhaltung: Dokumente mit Manuell-Tag', () => { + const rechte = [Permission.PROCESS_MANUALLY]; + expect(darfDokumentBearbeiten([MANUELL], rechte, MANUELL)).toBe(true); + expect(darfDokumentBearbeiten([POSTEINGANG_TAG_ID], rechte, MANUELL)).toBe( + false, + ); + }); + + it('Admins dürfen alles', () => { + expect(darfDokumentBearbeiten([], [Permission.MANAGE_ALL], MANUELL)).toBe( + true, + ); + }); + + it('andere Rollen und fehlende Tags: nein', () => { + expect( + darfDokumentBearbeiten( + [POSTEINGANG_TAG_ID], + [Permission.VIEW_FREIGABE], + MANUELL, + ), + ).toBe(false); + expect( + darfDokumentBearbeiten(undefined, [Permission.VIEW_INBOX], MANUELL), + ).toBe(false); + }); +}); + +describe('dokumentKurzinfo', () => { + it('liefert nur Kennung, Titel, ASN und Datum – keinen OCR-Volltext', () => { + const doc = { + id: 5, + title: 'Rechnung', + archive_serial_number: 42, + created: '2026-01-02', + content: 'IBAN DE00 …', + owner: 3, + custom_fields: [{ field: 15, value: 'x' }], + notes: [{ note: 'intern' }], + }; + expect(dokumentKurzinfo(doc)).toEqual({ + id: 5, + title: 'Rechnung', + archive_serial_number: 42, + created: '2026-01-02', + }); + }); +}); diff --git a/paperless-backend/src/paperless/dokument-zugriff.util.ts b/paperless-backend/src/paperless/dokument-zugriff.util.ts new file mode 100644 index 0000000..61bb758 --- /dev/null +++ b/paperless-backend/src/paperless/dokument-zugriff.util.ts @@ -0,0 +1,51 @@ +import { Permission } from '../auth/permissions.enum'; + +/** Paperless-Tag, der ein Dokument als „im Posteingang“ kennzeichnet. */ +export const POSTEINGANG_TAG_ID = 1; + +/** + * Darf der Benutzer dieses Paperless-Dokument über die Posteingangs-Maske + * bearbeiten? Der Backend-Service-Token umgeht die Paperless-Rechte, deshalb + * wird hier pro Dokument geprüft: Posteingang-Rolle nur für Dokumente mit + * Posteingang-Tag, Buchhaltung nur für Dokumente mit Manuell-Tag. + */ +export function darfDokumentBearbeiten( + tags: number[] | undefined, + permissions: Permission[], + manuellTagId: number, +): boolean { + if (permissions.includes(Permission.MANAGE_ALL)) return true; + const vorhanden = new Set((tags ?? []).map(Number)); + return ( + (vorhanden.has(POSTEINGANG_TAG_ID) && + permissions.includes(Permission.VIEW_INBOX)) || + (vorhanden.has(manuellTagId) && + permissions.includes(Permission.PROCESS_MANUALLY)) + ); +} + +export interface DokumentKurzinfo { + id: number; + title: string; + archive_serial_number: number | null; + created: string; +} + +/** + * Für Suche und Verknüpfungsanzeige genügen Kennung, Titel, ASN und Datum. + * Das vollständige Paperless-Dokument enthält u.a. den OCR-Volltext und + * Statusfelder – die sollen über diese Routen nicht abrufbar sein. + */ +export function dokumentKurzinfo(doc: { + id: number; + title: string; + archive_serial_number?: number | null; + created: string; +}): DokumentKurzinfo { + return { + id: doc.id, + title: doc.title, + archive_serial_number: doc.archive_serial_number ?? null, + created: doc.created, + }; +} diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index d62d796..14e7ee0 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -17,10 +17,17 @@ import { Res, Query, Request, + ForbiddenException, + ParseIntPipe, } from '@nestjs/common'; import type { Response } from 'express'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; +import { entferneGeschuetzteStatusfelder } from './status-felder.util'; +import { + darfDokumentBearbeiten, + dokumentKurzinfo, +} from './dokument-zugriff.util'; import { FileInterceptor } from '@nestjs/platform-express'; import { InjectRepository } from '@nestjs/typeorm'; @@ -39,6 +46,15 @@ import { ErechnungService } from '../erechnung/erechnung.service'; /** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */ export const STEUERTAG_SETTING_KEY = 'steuertag_ids'; +/** Von JwtStrategy gesetzt; die globalen Guards lassen nur Angemeldete durch. */ +interface BenutzerRequest { + user: { + userId: string; + permissions?: Permission[]; + preferredUsername?: string; + }; +} + @Controller('api/paperless') export class PaperlessController { private readonly logger = new Logger(PaperlessController.name); @@ -71,11 +87,14 @@ export class PaperlessController { } @Get('steuertags') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getSteuertags() { return { ids: await this.getSteuertagIds() }; } @Post('checksum') + @AllowApiKey() + @RequirePermissions(Permission.MANAGE_SETTINGS) async checksumExists(@Body('checksum') checksum: string) { const exists = await this.paperlessService.checksumExists(checksum); return { exists }; @@ -88,7 +107,9 @@ export class PaperlessController { return this.paperlessService.pruefeBelegnummer(belegnummer ?? ''); } + /** Dokumentsuche für Verknüpfungsfelder im Bearbeiten-Dialog. */ @Get('documents') + @RequirePermissions(Permission.VIEW_INBOX) async getDocuments( @Query('search') search?: string, @Query('page') page?: string, @@ -101,20 +122,28 @@ export class PaperlessController { if (search) { params.query = search; // Global search in Paperless } - return this.paperlessService.getDocuments(params); + const ergebnis = await this.paperlessService.getDocuments(params); + return { + count: ergebnis.count, + results: (ergebnis.results ?? []).map(dokumentKurzinfo), + }; } + /** Titel verknüpfter Dokumente – bewusst ohne Prüfung pro Dokument. */ @Get('documents/:id') - async getDocument(@Param('id') id: string) { - return this.paperlessService.getDocument(parseInt(id, 10)); + @RequirePermissions(Permission.VIEW_INBOX) + async getDocument(@Param('id', ParseIntPipe) id: number) { + return dokumentKurzinfo(await this.paperlessService.getDocument(id)); } @Get('tags') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getTags() { return this.paperlessService.getTags(); } @Get('tags/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getTag(@Param('id') id: string) { // If the service doesn't have getTag(id), I should add it or just fetch all and find const tags = await this.paperlessService.getTags(); @@ -122,11 +151,18 @@ export class PaperlessController { } @Get('document-types') + @RequirePermissions( + Permission.VIEW_INBOX, + Permission.PROCESS_MANUALLY, + Permission.VIEW_FREIGABE, + Permission.VIEW_ZAHLUNG, + ) async getDocumentTypes() { return this.paperlessService.getDocumentTypes(); } @Get('custom-fields') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getCustomFields() { return this.paperlessService.getCustomFields(); } @@ -138,6 +174,13 @@ export class PaperlessController { } @Get('correspondents') + @RequirePermissions( + Permission.VIEW_INBOX, + Permission.PROCESS_MANUALLY, + Permission.VIEW_MAIL, + Permission.VIEW_FREIGABE, + Permission.VIEW_ZAHLUNG, + ) async getCorrespondents(@Query('search') search?: string) { const params: any = { page_size: 9999 }; if (search) { @@ -148,6 +191,7 @@ export class PaperlessController { } @Get('correspondents/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getCorrespondent(@Param('id') id: string) { return this.paperlessService.getCorrespondent(parseInt(id, 10)); } @@ -201,9 +245,13 @@ export class PaperlessController { })); } + /** + * Vorschaubild – auch für Suchergebnisse außerhalb des Posteingangs, + * deshalb ohne Prüfung pro Dokument. + */ + @Get('documents/:id/preview') @RequirePermissions(Permission.VIEW_INBOX) - @Get('inbox/preview/:id') - async getInboxPreview(@Param('id') id: string, @Res() res: Response) { + async getDocumentPreview(@Param('id') id: string, @Res() res: Response) { try { const stream = await this.paperlessService.getDocumentPreviewStream( parseInt(id, 10), @@ -227,9 +275,18 @@ export class PaperlessController { } } - @RequirePermissions(Permission.VIEW_INBOX) - @Get('inbox/pdf/:id') - async getInboxPdf(@Param('id') id: string, @Res() res: Response) { + /** + * Original-PDF – für den Bearbeiten-Dialog und den Duplikatvergleich in + * Scanner und Mail-Import (dort ein beliebiges vorhandenes Dokument), + * deshalb ohne Prüfung pro Dokument. + */ + @Get('documents/:id/pdf') + @RequirePermissions( + Permission.VIEW_INBOX, + Permission.VIEW_SCANNER, + Permission.VIEW_MAIL, + ) + async getDocumentPdf(@Param('id') id: string, @Res() res: Response) { try { const stream = await this.paperlessService.getDocumentPdfStream( parseInt(id, 10), @@ -252,6 +309,7 @@ export class PaperlessController { } @Get('requirements/:id') + @RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY) async getRequirements( @Param('id') id: string, @Query('Posteingang') posteingang: string, @@ -336,21 +394,36 @@ export class PaperlessController { * Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument * keine auswertbare E-Rechnung enthält. */ - @Get('inbox/:id/erechnung') + @Get('documents/:id/erechnung') @RequirePermissions(Permission.VIEW_INBOX) - async getInboxErechnung(@Param('id') id: string, @Request() req: any) { - return this.erechnungService.analysiereDokument( - parseInt(id, 10), - req.user?.userId, - ); + async getDocumentErechnung( + @Param('id', ParseIntPipe) id: number, + @Request() req: BenutzerRequest, + ) { + await this.ladeBearbeitbaresDokument(id, req); + return this.erechnungService.analysiereDokument(id, req.user.userId); } - @Put('inbox/:id') + @Put('documents/:id') @RequirePermissions(Permission.VIEW_INBOX) - async putInboxDocument(@Param('id') id: string, @Body() body: any) { + async putDocument( + @Param('id') id: string, + @Body() body: any, + @Request() req: BenutzerRequest, + ) { const documentId = parseInt(id, 10); - // Fetch from paperless - const oldDocument = await this.paperlessService.getDocument(documentId); + + const { felder, entfernt } = entferneGeschuetzteStatusfelder( + body.customFields as Record | undefined, + req.user?.permissions ?? [], + ); + if (entfernt.length) { + this.logger.warn( + `Dokument ${documentId}: Statusfeld(er) ${entfernt.join(', ')} ohne Berechtigung ignoriert (${req.user?.preferredUsername ?? 'unbekannt'})`, + ); + } + body.customFields = felder; + const oldDocument = await this.ladeBearbeitbaresDokument(documentId, req); oldDocument.document_type = body.documentType ?? oldDocument.document_type; oldDocument.owner = body.mandant ?? oldDocument.owner; @@ -515,6 +588,33 @@ export class PaperlessController { return { success: true }; } + /** + * Lädt ein Dokument, das der Benutzer bearbeiten darf: Posteingang-Rolle nur + * mit Posteingang-Tag, Buchhaltung zusätzlich mit Manuell-Tag. Das Backend + * arbeitet mit dem Service-Token, Paperless prüft hier also nichts. + */ + private async ladeBearbeitbaresDokument( + documentId: number, + req: BenutzerRequest, + ): Promise { + const dokument = await this.paperlessService.getDocument(documentId); + if ( + !darfDokumentBearbeiten( + dokument.tags as number[] | undefined, + req.user?.permissions ?? [], + this.paperlessService.getManuellTagId(), + ) + ) { + this.logger.warn( + `Dokument ${documentId}: Zugriff abgelehnt – nicht im Posteingang/Manuell-Bereich des Benutzers (${req.user?.preferredUsername ?? 'unbekannt'})`, + ); + throw new ForbiddenException( + 'Dieses Dokument liegt nicht in Ihrem Posteingang bzw. Ihrer Bearbeitungsliste', + ); + } + return dokument; + } + @Get('tasks') @RequirePermissions(Permission.MANAGE_SETTINGS) async getAllTasks() { @@ -539,6 +639,7 @@ export class PaperlessController { } @Post('external-upload') + @AllowApiKey() @UseGuards(ApiKeyGuard) @UseInterceptors(FileInterceptor('document')) async externalUpload( diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 9a882bc..fcae76b 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -215,11 +215,15 @@ export class PaperlessService { return response.data.results; } - async getManuellDocuments(): Promise { - const errorTag = this.configService.get( - 'MANUELL_BEARBEITEN_TAG', - 6, + /** Tag der Liste „Manuell bearbeiten“ (Env-Werte kommen als String). */ + getManuellTagId(): number { + return Number( + this.configService.get('MANUELL_BEARBEITEN_TAG', 6), ); + } + + async getManuellDocuments(): Promise { + const errorTag = this.getManuellTagId(); const response = await this.client.get('/documents/', { params: { page: 1, diff --git a/paperless-backend/src/paperless/status-felder.util.spec.ts b/paperless-backend/src/paperless/status-felder.util.spec.ts new file mode 100644 index 0000000..60ed179 --- /dev/null +++ b/paperless-backend/src/paperless/status-felder.util.spec.ts @@ -0,0 +1,45 @@ +import { Permission } from '../auth/permissions.enum'; +import { + FREIGABE_FIELD_ID, + ZAHLUNG_FIELD_ID, + entferneGeschuetzteStatusfelder, +} from './status-felder.util'; + +describe('entferneGeschuetzteStatusfelder', () => { + const felder = { + [FREIGABE_FIELD_ID]: 'freigegeben', + [ZAHLUNG_FIELD_ID]: 'bezahlt', + 3: 'Rechnungsnummer', + }; + + it('entfernt Freigabe und Zahlung für die Posteingangs-Rolle', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.VIEW_INBOX]), + ).toEqual({ felder: { 3: 'Rechnungsnummer' }, entfernt: [15, 16] }); + }); + + it('lässt jeder Rolle nur ihr eigenes Statusfeld', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [ + Permission.VIEW_INBOX, + Permission.VIEW_FREIGABE, + ]).felder, + ).toEqual({ 15: 'freigegeben', 3: 'Rechnungsnummer' }); + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.VIEW_ZAHLUNG]).felder, + ).toEqual({ 16: 'bezahlt', 3: 'Rechnungsnummer' }); + }); + + it('lässt Admins alles', () => { + expect( + entferneGeschuetzteStatusfelder(felder, [Permission.MANAGE_ALL]), + ).toEqual({ felder, entfernt: [] }); + }); + + it('kommt mit fehlenden Feldern klar', () => { + expect(entferneGeschuetzteStatusfelder(undefined, [])).toEqual({ + felder: undefined, + entfernt: [], + }); + }); +}); diff --git a/paperless-backend/src/paperless/status-felder.util.ts b/paperless-backend/src/paperless/status-felder.util.ts new file mode 100644 index 0000000..cb2e230 --- /dev/null +++ b/paperless-backend/src/paperless/status-felder.util.ts @@ -0,0 +1,34 @@ +import { Permission } from '../auth/permissions.enum'; + +/** Paperless-Custom-Field „Freigabe“ – setzen darf es nur die Freigabe-Rolle. */ +export const FREIGABE_FIELD_ID = 15; +/** Paperless-Custom-Field „Zahlung“ – setzen darf es nur die Zahlungs-Rolle. */ +export const ZAHLUNG_FIELD_ID = 16; + +const SCHUTZ: [number, Permission][] = [ + [FREIGABE_FIELD_ID, Permission.VIEW_FREIGABE], + [ZAHLUNG_FIELD_ID, Permission.VIEW_ZAHLUNG], +]; + +/** + * Entfernt Freigabe-/Zahlungsfeld aus einer Custom-Field-Änderung, wenn der + * Benutzer die zugehörige Rolle nicht hat (Funktionstrennung: wer Belege im + * Posteingang bearbeitet, darf sie nicht zugleich freigeben oder bezahlen). + */ +export function entferneGeschuetzteStatusfelder( + felder: Record | undefined, + permissions: Permission[], +): { felder: Record | undefined; entfernt: number[] } { + if (!felder || permissions.includes(Permission.MANAGE_ALL)) { + return { felder, entfernt: [] }; + } + const ergebnis = { ...felder }; + const entfernt: number[] = []; + for (const [feldId, recht] of SCHUTZ) { + if (String(feldId) in ergebnis && !permissions.includes(recht)) { + delete ergebnis[String(feldId)]; + entfernt.push(feldId); + } + } + return { felder: ergebnis, entfernt }; +} diff --git a/paperless-backend/src/postprocessing/export.service.ts b/paperless-backend/src/postprocessing/export.service.ts index 8ffdd8d..0ccb813 100644 --- a/paperless-backend/src/postprocessing/export.service.ts +++ b/paperless-backend/src/postprocessing/export.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ExportTarget } from '../database/entities/export-target.entity'; import * as ftp from 'basic-ftp'; +import { sichererDateiname } from '../common/pfad-sicherheit.util'; @Injectable() export class ExportService { @@ -24,9 +25,17 @@ export class ExportService { throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`); } + // Der Name enthält u.a. Barcode-Werte – er darf den Zielordner nicht verlassen. + const dateiname = sichererDateiname(filename); + if (dateiname !== filename) { + this.logger.warn( + `Export-Dateiname bereinigt: "${filename}" → "${dateiname}"`, + ); + } + switch (target.Protocol) { case 'ftp': - await this.uploadFtp(target, filename, content); + await this.uploadFtp(target, dateiname, content); break; default: throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`); diff --git a/paperless-backend/src/postprocessing/mail.service.spec.ts b/paperless-backend/src/postprocessing/mail.service.spec.ts new file mode 100644 index 0000000..bd27ca8 --- /dev/null +++ b/paperless-backend/src/postprocessing/mail.service.spec.ts @@ -0,0 +1,51 @@ +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { MailService } from './mail.service'; + +jest.mock('nodemailer'); + +describe('MailService', () => { + const sendMail = jest.fn().mockResolvedValue({ + messageId: 'x', + accepted: [], + rejected: [], + response: 'ok', + }); + + beforeEach(() => { + sendMail.mockClear(); + (nodemailer.createTransport as jest.Mock).mockReturnValue({ sendMail }); + }); + + const config = { + get: (_k: string, d?: unknown) => d, + } as unknown as ConfigService; + + it.each([false, true])( + 'verbietet nodemailer Datei- und URL-Zugriffe (smtpOverride=%s)', + async (override) => { + const service = new MailService(config); + await service.sendMail({ + to: 'a@example.org', + subject: 's', + body: 'b', + smtpOverride: override + ? { + host: 'h', + port: 25, + secure: false, + user: 'u', + pass: 'p', + from: 'f', + } + : undefined, + }); + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + disableFileAccess: true, + disableUrlAccess: true, + }), + ); + }, + ); +}); diff --git a/paperless-backend/src/postprocessing/mail.service.ts b/paperless-backend/src/postprocessing/mail.service.ts index e2c27be..d748077 100644 --- a/paperless-backend/src/postprocessing/mail.service.ts +++ b/paperless-backend/src/postprocessing/mail.service.ts @@ -69,6 +69,10 @@ export class MailService { filename: a.filename, content: a.content, })), + // Inhalte kommen teils vom Aufrufer: nodemailer darf nie Dateien vom + // Server lesen oder URLs abrufen (`{ path }` / `{ href }`). + disableFileAccess: true, + disableUrlAccess: true, }); this.logger.log( diff --git a/paperless-backend/src/preprocessing/pdf.service.ts b/paperless-backend/src/preprocessing/pdf.service.ts index 2baf0f0..c40ad48 100644 --- a/paperless-backend/src/preprocessing/pdf.service.ts +++ b/paperless-backend/src/preprocessing/pdf.service.ts @@ -7,6 +7,27 @@ import * as os from 'os'; const execFileAsync = promisify(execFile); +/** + * Ghostscript verarbeitet PDFs aus Mails und vom Scanner – also Eingaben + * Unbekannter. Deshalb immer mit -dSAFER (kein Dateisystem-/Prozesszugriff aus + * PostScript heraus) und mit harter Zeit- und Seitengrenze. + */ +const GS_BASIS_ARGS = ['-dSAFER', '-dNOPAUSE', '-dBATCH', '-q']; + +/** Höchstens so viele Seiten werden gerendert (Schutz vor Seitenbomben). */ +export const MAX_RENDER_SEITEN = 500; + +const GS_TIMEOUT_EINE_SEITE_MS = 60_000; +const GS_TIMEOUT_DOKUMENT_MS = 10 * 60_000; + +function gs(args: string[], timeoutMs: number) { + return execFileAsync('gs', [...GS_BASIS_ARGS, ...args], { + timeout: timeoutMs, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }); +} + @Injectable() export class PdfService { private readonly logger = new Logger(PdfService.name); @@ -19,17 +40,22 @@ export class PdfService { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); const outputPath = path.join(tmpDir, `page-${page}.png`); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=png16m', - `-dFirstPage=${page}`, - `-dLastPage=${page}`, - `-r${dpi}`, - `-sOutputFile=${outputPath}`, - pdfPath, - ]); + try { + await gs( + [ + '-sDEVICE=png16m', + `-dFirstPage=${page}`, + `-dLastPage=${page}`, + `-r${dpi}`, + `-sOutputFile=${outputPath}`, + pdfPath, + ], + GS_TIMEOUT_EINE_SEITE_MS, + ); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }); + throw err; + } this.logger.debug(`PDF Seite ${page} konvertiert: ${outputPath}`); return outputPath; @@ -48,15 +74,21 @@ export class PdfService { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-')); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=png16m', - `-r${dpi}`, - `-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`, - pdfPath, - ]); + try { + await gs( + [ + '-sDEVICE=png16m', + `-dLastPage=${MAX_RENDER_SEITEN}`, + `-r${dpi}`, + `-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`, + pdfPath, + ], + GS_TIMEOUT_DOKUMENT_MS, + ); + } catch (err) { + await fs.rm(tmpDir, { recursive: true, force: true }); + throw err; + } const entries = await fs.readdir(tmpDir); const images = entries @@ -68,6 +100,12 @@ export class PdfService { }) .map((f) => path.join(tmpDir, f)); + if (images.length >= MAX_RENDER_SEITEN) { + this.logger.warn( + `PDF hat mehr als ${MAX_RENDER_SEITEN} Seiten, nur die ersten werden gerendert: ${pdfPath}`, + ); + } + if (images.length === 0) { this.logger.warn( `Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`, @@ -81,34 +119,21 @@ export class PdfService { return images; } - /** - * Ermittelt die Seitenanzahl einer PDF via Ghostscript. - */ - async getPageCount(pdfPath: string): Promise { - const { stdout } = await execFileAsync('gs', [ - '-q', - '-dNODISPLAY', - '-dNOSAFER', - `-c`, - `(${pdfPath.replace(/\\/g, '/')}) (r) file runpdfbegin pdfpagecount = quit`, - ]); - return parseInt(stdout.trim(), 10) || 1; - } - /** * Bereinigt eine PDF (entschlüsselt sie ggf. wenn nur Owner-Passwort gesetzt ist) * via Ghostscript pdfwrite. */ async sanitizePdf(inputPath: string): Promise { const outputPath = path.join(os.tmpdir(), `sanitized-${Date.now()}.pdf`); - await execFileAsync('gs', [ - '-dNOPAUSE', - '-dBATCH', - '-dNOSAFER', - '-sDEVICE=pdfwrite', - `-sOutputFile=${outputPath}`, - inputPath, - ]); + try { + await gs( + ['-sDEVICE=pdfwrite', `-sOutputFile=${outputPath}`, inputPath], + GS_TIMEOUT_DOKUMENT_MS, + ); + } catch (err) { + await fs.rm(outputPath, { force: true }); + throw err; + } return outputPath; } diff --git a/paperless-backend/src/scanner/scanner-watcher.service.ts b/paperless-backend/src/scanner/scanner-watcher.service.ts index fb59965..fa90da5 100644 --- a/paperless-backend/src/scanner/scanner-watcher.service.ts +++ b/paperless-backend/src/scanner/scanner-watcher.service.ts @@ -18,6 +18,7 @@ import { InboxDocument, type InboxSource, } from '../database/entities/inbox-document.entity'; +import { uebernehmeRegulaereDatei } from '../common/pfad-sicherheit.util'; const STABILITY_MS = 5000; @@ -71,6 +72,8 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { pollInterval: 500, }, depth: 1, + // Symlinks auf der Scan-Freigabe nicht verfolgen (siehe uebernehmeRegulaereDatei) + followSymlinks: false, }); this.watcher @@ -184,7 +187,11 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { if (this.processing.has(filePath)) return; try { - const stat = await fs.stat(filePath); + const stat = await fs.lstat(filePath); + if (!stat.isFile()) { + this.logger.warn(`Überspringe (keine reguläre Datei): ${filePath}`); + return; + } if (stat.size === 0) { this.logger.debug(`Überspringe leere Datei: ${filePath}`); return; @@ -204,7 +211,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { const targetPdf = this.pageCache.documentPdfPath(id); await fs.mkdir(targetDir, { recursive: true }); - await this.move(filePath, targetPdf); + await uebernehmeRegulaereDatei(filePath, targetPdf); const doc = this.documentRepo.create({ Id: id, @@ -262,22 +269,4 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy { this.logger.log('Backfill: alle Dateien bereits gescannt'); } } - - private async move(src: string, dest: string): Promise { - try { - await fs.rename(src, dest); - return; - } catch (err: any) { - if (err.code !== 'EXDEV') throw err; - } - // Cross-device: copy + unlink. Wenn unlink scheitert, Kopie zurückrollen, - // damit ein kaputter Mount nicht bei jedem Neustart Duplikate produziert. - await fs.copyFile(src, dest); - try { - await fs.unlink(src); - } catch (err) { - await fs.unlink(dest).catch(() => undefined); - throw err; - } - } } diff --git a/paperless-backend/src/stats/stats.controller.ts b/paperless-backend/src/stats/stats.controller.ts index 2074362..634da3a 100644 --- a/paperless-backend/src/stats/stats.controller.ts +++ b/paperless-backend/src/stats/stats.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get, Request } from '@nestjs/common'; import { StatsService } from './stats.service'; +import { AllowAnyAuthenticated } from '../auth/permissions.decorator'; @Controller('api/stats') export class StatsController { constructor(private readonly statsService: StatsService) {} @Get('counts') + @AllowAnyAuthenticated() async getCounts(@Request() req: any) { return this.statsService.getDashboardCounts(req.user?.preferredUsername); } diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index 145b3f2..41ac186 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -8,7 +8,10 @@ import { Request, } from '@nestjs/common'; import { UserSettingsService } from './user-settings.service'; -import { RequirePermissions } from '../auth/permissions.decorator'; +import { + AllowAnyAuthenticated, + RequirePermissions, +} from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @Controller('api/user-settings') @@ -16,16 +19,19 @@ export class UserSettingsController { constructor(private readonly userSettingsService: UserSettingsService) {} @Get() + @AllowAnyAuthenticated() async getSettings(@Request() req: any) { return this.userSettingsService.getSettings(req.user.userId); } @Put() + @AllowAnyAuthenticated() async updateSettings(@Request() req: any, @Body() body: any) { return this.userSettingsService.updateSettings(req.user.userId, body); } @Get('senders') + @AllowAnyAuthenticated() async getSenders(@Request() req: any) { return this.userSettingsService.getAvailableSenders(req.user.userId); } diff --git a/paperless-backend/src/webhook/webhook.controller.ts b/paperless-backend/src/webhook/webhook.controller.ts index 07eb00e..764b944 100644 --- a/paperless-backend/src/webhook/webhook.controller.ts +++ b/paperless-backend/src/webhook/webhook.controller.ts @@ -11,6 +11,8 @@ import { } from '@nestjs/common'; import { ApiKeyGuard } from '../auth/api-key.guard'; import { WebhookQueueService } from './webhook-queue.service'; +import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; export interface PaperlessWebhookPayload { doc_url?: string; @@ -27,6 +29,7 @@ export class WebhookController { @UseGuards(ApiKeyGuard) @Post('paperless') + @AllowApiKey() @HttpCode(HttpStatus.OK) async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) { const documentId = this.extractDocumentId(payload); @@ -59,6 +62,7 @@ export class WebhookController { * per JWT oder API-Key zugänglich. */ @Get('status') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> { return this.webhookQueue.getStatus(); } diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts index aeb8fbc..a5b48c4 100644 --- a/paperless-backend/src/zahlung/zahlung.service.ts +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from '../paperless/paperless.service'; +import { + FREIGABE_FIELD_ID, + ZAHLUNG_FIELD_ID, +} from '../paperless/status-felder.util'; -const FREIGABE_FIELD_ID = 15; -const ZAHLUNG_FIELD_ID = 16; const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; diff --git a/paperless-frontend/src/api/paperless.ts b/paperless-frontend/src/api/paperless.ts index e4c8f3f..11821eb 100644 --- a/paperless-frontend/src/api/paperless.ts +++ b/paperless-frontend/src/api/paperless.ts @@ -81,7 +81,7 @@ export const paperlessApi = { checksumExists: (checksum: string) => api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists), getDocumentPdfBlob: (id: number) => - api.get(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data), + api.get(`/api/paperless/documents/${id}/pdf`, { responseType: 'blob' }).then(r => r.data), pruefeBelegnummer: (belegnummer: string) => api.get('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data), }; diff --git a/paperless-frontend/src/api/posteingang.ts b/paperless-frontend/src/api/posteingang.ts index ebc6634..e734df7 100644 --- a/paperless-frontend/src/api/posteingang.ts +++ b/paperless-frontend/src/api/posteingang.ts @@ -64,7 +64,7 @@ export const posteingangApi = { .then((r) => r.data), updateDocument: (id: number, data: any) => - api.put(`/api/paperless/inbox/${id}`, data).then((r) => r.data), + api.put(`/api/paperless/documents/${id}`, data).then((r) => r.data), getKontonummern: (correspondentId: number) => api.get(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data), @@ -73,5 +73,5 @@ export const posteingangApi = { api.post('/api/kontonummern', data).then(r => r.data), getErechnung: (id: number) => - api.get(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data), + api.get(`/api/paperless/documents/${id}/erechnung`).then(r => r.data), }; diff --git a/paperless-frontend/src/auth/permissions.ts b/paperless-frontend/src/auth/permissions.ts index 518f0e6..4a39f36 100644 --- a/paperless-frontend/src/auth/permissions.ts +++ b/paperless-frontend/src/auth/permissions.ts @@ -31,8 +31,11 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per return Array.from(permissions); } + // Der Posteingang ist eine abgespeckte Buchhaltung – PM_Buchhaltung umfasst ihn. + // Muss identisch zu paperless-backend/src/auth/permissions.enum.ts bleiben. if (groups.includes('PM_Buchhaltung')) { permissions.add(Permission.PROCESS_MANUALLY); + permissions.add(Permission.VIEW_INBOX); } if (groups.includes('PM_Maileingang')) { permissions.add(Permission.VIEW_MAIL); diff --git a/paperless-frontend/src/components/DocumentEditModal.tsx b/paperless-frontend/src/components/DocumentEditModal.tsx index c7467e1..f5b4f48 100644 --- a/paperless-frontend/src/components/DocumentEditModal.tsx +++ b/paperless-frontend/src/components/DocumentEditModal.tsx @@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose, icon={} onClick={(e) => { e.stopPropagation(); - openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`); + openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${currentId}/pdf`); }} /> )} @@ -679,13 +679,13 @@ export default function DocumentEditModal({ documentId, document, open, onClose, icon={} block style={{ marginBottom: 8 }} - onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)} + onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf`)} > PDF in neuem Tab öffnen )} diff --git a/paperless-frontend/src/components/DocumentSearchModal.tsx b/paperless-frontend/src/components/DocumentSearchModal.tsx index 57d804e..1d4c795 100644 --- a/paperless-frontend/src/components/DocumentSearchModal.tsx +++ b/paperless-frontend/src/components/DocumentSearchModal.tsx @@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) ( ); @@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() { render: (_: any, record: PosteingangDocument) => ( @@ -172,7 +172,7 @@ export default function ManuellBearbeitenPage() {
diff --git a/paperless-frontend/src/pages/PosteingangPage.tsx b/paperless-frontend/src/pages/PosteingangPage.tsx index 4188ee1..7a3171c 100644 --- a/paperless-frontend/src/pages/PosteingangPage.tsx +++ b/paperless-frontend/src/pages/PosteingangPage.tsx @@ -62,7 +62,7 @@ export default function PosteingangPage() { const popoverContent = (id: number) => ( ); @@ -80,7 +80,7 @@ export default function PosteingangPage() { render: (_: any, record: PosteingangDocument) => ( @@ -136,7 +136,7 @@ export default function PosteingangPage() {