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
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:
commit
333507df7a
60 files changed
+1943
-181
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user