Merge pull request 'fix(security): Befunde aus dem Security-Audit beheben' (#7) from fix/security-audit into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 9s

This commit was merged in pull request #7.
This commit is contained in:
bjoernpoettker committed 2026-11-04 10:03:36 +00:00
commit 333507df7a
60 files changed
+1943 -181

No files matched your search

+9
View File
@@ -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
+2
View File
@@ -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}
+3 -1
View File
@@ -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).
---
+2
View File
@@ -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();
}
@@ -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]);
});
});
@@ -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) {}
@@ -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/);
});
});
@@ -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/`,
};
}
+17 -5
View File
@@ -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<string>('OIDC_ISSUER', '');
const optionen = jwtPruefOptionen(
configService.get<string>('OIDC_ISSUER', ''),
configService.get<string>('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): {
@@ -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);
@@ -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<string, string[]> = {};
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);
});
});
@@ -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);
@@ -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<string, unknown>,
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);
});
});
});
+56 -13
View File
@@ -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<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
const targets = [context.getHandler(), context.getClass()];
const meta = <T>(key: string) =>
this.reflector.getAllAndOverride<T | undefined>(key, targets);
if (!requiredPermissions) {
return true;
}
if (meta<boolean>(IS_PUBLIC_KEY)) return true;
const request = context.switchToHttp().getRequest();
const { user } = request;
const request = context.switchToHttp().getRequest<GeprueftesRequest>();
const route = `[${request.method} ${request.url}]`;
if (request.apiKeyMetadata) {
return true;
if (meta<boolean>(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<Permission[]>(PERMISSIONS_KEY);
if (!requiredPermissions) {
if (meta<boolean>(ALLOW_ANY_AUTHENTICATED_KEY)) return !!user;
if (!meta<boolean>(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;
@@ -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<string, unknown> };
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<string, unknown>;
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(),
);
});
});
@@ -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);
}
@@ -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);
});
});
@@ -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<boolean> {
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<number> {
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;
}
@@ -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');
});
});
@@ -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, string>,
): 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<void>;
} = {},
): Promise<void> {
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;
}
}
@@ -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;
@@ -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);
@@ -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<string> {
@@ -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(
@@ -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);
},
);
});
@@ -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<uuid>` 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`,
@@ -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();
});
});
@@ -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<string | null> {
// 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 {
@@ -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();
});
});
@@ -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<EingebetteteDatei[]> {
// pdf-lib entpackt Objekt- und Datei-Streams ohne Limit.
if (await ueberschreitetDekompressionsLimit(pdf)) return [];
const doc = await PDFDocument.load(pdf, {
ignoreEncryption: true,
throwOnInvalidObject: false,
@@ -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 {
@@ -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({
@@ -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<void> {
const body = parseSendEmailBody(rawBody);
const preferredUsername: string | null =
req.user?.preferredUsername ?? null;
const smtpOverride =
@@ -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: '<p>Text</p>',
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);
});
});
@@ -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<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
@@ -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) {}
@@ -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<MessageEvent> {
@@ -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,
@@ -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<LabelPrintJob>) {
const gespeichert: Partial<LabelPrintJob>[] = [];
const jobRepo = {
findOne: jest.fn().mockResolvedValue(job ? { ...job } : null),
create: jest.fn((j: Partial<LabelPrintJob>) => j),
save: jest.fn((j: Partial<LabelPrintJob>) => {
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<string, string>;
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' },
);
});
});
@@ -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, string>): 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, string>): 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<string, string> = { ...fieldValues };
// Variablen aufbauen – nur Eingabefelder der Vorlage. Abgeleitete Werte
// wie {number} (aus der GET-URL) darf der Aufrufer nicht vorgeben.
const vars: Record<string, string> = {};
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<void> {
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<void> {
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<LabelPrintJob> {
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<LabelPrintJob>,
): Promise<void> {
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}`,
@@ -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',
});
});
});
@@ -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,
};
}
@@ -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<string, unknown> | 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<any> {
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(
@@ -215,11 +215,15 @@ export class PaperlessService {
return response.data.results;
}
async getManuellDocuments(): Promise<any[]> {
const errorTag = this.configService.get<number>(
'MANUELL_BEARBEITEN_TAG',
6,
/** Tag der Liste „Manuell bearbeiten“ (Env-Werte kommen als String). */
getManuellTagId(): number {
return Number(
this.configService.get<number | string>('MANUELL_BEARBEITEN_TAG', 6),
);
}
async getManuellDocuments(): Promise<any[]> {
const errorTag = this.getManuellTagId();
const response = await this.client.get('/documents/', {
params: {
page: 1,
@@ -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: [],
});
});
});
@@ -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<T>(
felder: Record<string, T> | undefined,
permissions: Permission[],
): { felder: Record<string, T> | 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 };
}
@@ -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}`);
@@ -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,
}),
);
},
);
});
@@ -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(
@@ -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<number> {
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<string> {
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;
}
@@ -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<void> {
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;
}
}
}
@@ -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);
}
@@ -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);
}
@@ -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();
}
@@ -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';
+1 -1
View File
@@ -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<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
api.get<Blob>(`/api/paperless/documents/${id}/pdf`, { responseType: 'blob' }).then(r => r.data),
pruefeBelegnummer: (belegnummer: string) =>
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
};
+2 -2
View File
@@ -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<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data),
@@ -73,5 +73,5 @@ export const posteingangApi = {
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
getErechnung: (id: number) =>
api.get<ERechnungVorschlag>(`/api/paperless/inbox/${id}/erechnung`).then(r => r.data),
api.get<ERechnungVorschlag>(`/api/paperless/documents/${id}/erechnung`).then(r => r.data),
};
@@ -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);
@@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
icon={<EyeOutlined />}
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={<ExportOutlined />}
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
</Button>
)}
<AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${documentId}/pdf#toolbar=0`}
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
title="PDF Preview"
/>
@@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
<List.Item.Meta
avatar={
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${doc.id}/preview`}
width={80}
height={110}
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
@@ -75,7 +75,7 @@ export default function ManuellBearbeitenPage() {
const popoverContent = (id: number) => (
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400}
/>
);
@@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() {
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
@@ -172,7 +172,7 @@ export default function ManuellBearbeitenPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
@@ -62,7 +62,7 @@ export default function PosteingangPage() {
const popoverContent = (id: number) => (
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
width={400}
/>
);
@@ -80,7 +80,7 @@ export default function PosteingangPage() {
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={100}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
@@ -136,7 +136,7 @@ export default function PosteingangPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${record.id}/preview`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>