fix(security): Befunde aus dem Security-Audit beheben #7
No files matched your search
@@ -78,6 +78,12 @@ Decorate controllers/handlers with:
|
|||||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
@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
|
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
|
`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** —
|
`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.
|
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
|
### Database
|
||||||
|
|
||||||
- TypeORM with MySQL/MariaDB, UTF8MB4
|
- TypeORM with MySQL/MariaDB, UTF8MB4
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ services:
|
|||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llava}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-llava}
|
||||||
- SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive}
|
- SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive}
|
||||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
- 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_HOST=${SMTP_HOST:-}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_SECURE=${SMTP_SECURE:-false}
|
- SMTP_SECURE=${SMTP_SECURE:-false}
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,9 @@ Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key):
|
|||||||
Authorization: Bearer {token}
|
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 { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { AllowAnyAuthenticated } from './auth/permissions.decorator';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
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';
|
} from '@nestjs/common';
|
||||||
import { ApiKeysService } from './api-keys.service';
|
import { ApiKeysService } from './api-keys.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { RequirePermissions } from './permissions.decorator';
|
||||||
|
import { Permission } from './permissions.enum';
|
||||||
|
|
||||||
@Controller('api/api-keys')
|
@Controller('api/api-keys')
|
||||||
@UseGuards(JwtAuthGuard)
|
@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 {
|
export class ApiKeysController {
|
||||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
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 { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { passportJwtSecret } from 'jwks-rsa';
|
import { passportJwtSecret } from 'jwks-rsa';
|
||||||
import { mapGroupsToPermissions } from './permissions.enum';
|
import { mapGroupsToPermissions } from './permissions.enum';
|
||||||
|
import { jwtPruefOptionen } from './jwt-optionen';
|
||||||
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
|
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,20 +13,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private readonly identitySync: UserIdentitySyncService,
|
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({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
issuer,
|
issuer: optionen.issuer,
|
||||||
algorithms: ['RS256'],
|
audience: optionen.audience,
|
||||||
|
algorithms: optionen.algorithms,
|
||||||
secretOrKeyProvider: passportJwtSecret({
|
secretOrKeyProvider: passportJwtSecret({
|
||||||
cache: true,
|
cache: true,
|
||||||
rateLimit: true,
|
rateLimit: true,
|
||||||
jwksRequestsPerMinute: 5,
|
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): {
|
validate(payload: any): {
|
||||||
|
|||||||
@@ -2,5 +2,24 @@ import { SetMetadata } from '@nestjs/common';
|
|||||||
import { Permission } from './permissions.enum';
|
import { Permission } from './permissions.enum';
|
||||||
|
|
||||||
export const PERMISSIONS_KEY = 'permissions';
|
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[]) =>
|
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
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', () => {
|
it('PM_Buchhaltung enthält den Posteingang (abgespeckte Buchhaltung)', () => {
|
||||||
expect(mapGroupsToPermissions(['PM_Buchhaltung'])).toEqual([
|
expect(mapGroupsToPermissions(['PM_Buchhaltung']).sort()).toEqual(
|
||||||
Permission.PROCESS_MANUALLY,
|
[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'));
|
const frontend = groupNames(readFileSync(frontendFile, 'utf8'));
|
||||||
expect(backend).toEqual(frontend);
|
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.
|
// Gruppe wurde in Authentik von 'PM_Belege' zu 'PM_Buchhaltung' umbenannt.
|
||||||
// Muss identisch zu paperless-frontend/src/auth/permissions.ts bleiben.
|
// 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.PROCESS_MANUALLY);
|
||||||
|
permissions.add(Permission.VIEW_INBOX);
|
||||||
|
}
|
||||||
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL);
|
||||||
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
||||||
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
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 { 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';
|
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()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(PermissionsGuard.name);
|
||||||
|
|
||||||
constructor(private reflector: Reflector) {}
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
|
const targets = [context.getHandler(), context.getClass()];
|
||||||
PERMISSIONS_KEY,
|
const meta = <T>(key: string) =>
|
||||||
[context.getHandler(), context.getClass()],
|
this.reflector.getAllAndOverride<T | undefined>(key, targets);
|
||||||
);
|
|
||||||
|
|
||||||
if (!requiredPermissions) {
|
if (meta<boolean>(IS_PUBLIC_KEY)) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest<GeprueftesRequest>();
|
||||||
const { user } = request;
|
const route = `[${request.method} ${request.url}]`;
|
||||||
|
|
||||||
if (request.apiKeyMetadata) {
|
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) {
|
if (!user || !user.permissions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermissions = user.permissions as Permission[];
|
const userPermissions = user.permissions;
|
||||||
|
|
||||||
if (userPermissions.includes(Permission.MANAGE_ALL)) {
|
if (userPermissions.includes(Permission.MANAGE_ALL)) {
|
||||||
return true;
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 180;
|
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()
|
@Injectable()
|
||||||
export class PageCacheService {
|
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 {
|
documentDir(documentId: string): string {
|
||||||
|
if (!UUID.test(documentId)) {
|
||||||
|
throw new BadRequestException('Ungültige Dokument-ID');
|
||||||
|
}
|
||||||
return path.join(this.inboxRoot, documentId);
|
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 { Controller, Post, Request, HttpCode } from '@nestjs/common';
|
||||||
import { DailyDigestService } from './daily-digest.service';
|
import { DailyDigestService } from './daily-digest.service';
|
||||||
|
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
|
||||||
|
|
||||||
@Controller('api/daily-digest')
|
@Controller('api/daily-digest')
|
||||||
export class DailyDigestController {
|
export class DailyDigestController {
|
||||||
constructor(private readonly dailyDigestService: DailyDigestService) {}
|
constructor(private readonly dailyDigestService: DailyDigestService) {}
|
||||||
|
|
||||||
@Post('send-now')
|
@Post('send-now')
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async sendNow(@Request() req: any) {
|
async sendNow(@Request() req: any) {
|
||||||
const { userId, email, preferredUsername, groups } = req.user;
|
const { userId, email, preferredUsername, groups } = req.user;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
Delete,
|
Delete,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { EmailImportService } from './email-import.service';
|
import { EmailImportService } from './email-import.service';
|
||||||
@@ -91,8 +92,13 @@ export class EmailImportController {
|
|||||||
|
|
||||||
@Post('belegnummer/release')
|
@Post('belegnummer/release')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
async releaseBelegnummer(@Body() body: { date: string; number: string }) {
|
async releaseBelegnummer(@Body() body: { date: unknown; number: unknown }) {
|
||||||
if (!body.date || !body.number)
|
if (
|
||||||
|
typeof body?.date !== 'string' ||
|
||||||
|
typeof body.number !== 'string' ||
|
||||||
|
!body.date ||
|
||||||
|
!body.number
|
||||||
|
)
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
'Date and number required',
|
'Date and number required',
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
@@ -201,8 +207,8 @@ export class EmailImportController {
|
|||||||
@Get('attachments/:attachmentId/pages/:page/thumb')
|
@Get('attachments/:attachmentId/pages/:page/thumb')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
async getPageThumbnail(
|
async getPageThumbnail(
|
||||||
@Param('attachmentId') attachmentId: number,
|
@Param('attachmentId', ParseIntPipe) attachmentId: number,
|
||||||
@Param('page') page: number,
|
@Param('page', ParseIntPipe) page: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
|
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
|
||||||
@@ -217,8 +223,8 @@ export class EmailImportController {
|
|||||||
@Get('attachments/:attachmentId/pages/:page/preview')
|
@Get('attachments/:attachmentId/pages/:page/preview')
|
||||||
@RequirePermissions(Permission.VIEW_MAIL)
|
@RequirePermissions(Permission.VIEW_MAIL)
|
||||||
async getPagePreview(
|
async getPagePreview(
|
||||||
@Param('attachmentId') attachmentId: number,
|
@Param('attachmentId', ParseIntPipe) attachmentId: number,
|
||||||
@Param('page') page: number,
|
@Param('page', ParseIntPipe) page: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
|
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { ersetzeUrlPlatzhalter } from '../common/pfad-sicherheit.util';
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
return name
|
return name
|
||||||
@@ -152,7 +153,18 @@ export class EmailImportService {
|
|||||||
const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj)
|
const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj)
|
||||||
.getFullYear()
|
.getFullYear()
|
||||||
.toString();
|
.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> {
|
async getBelegnummer(emailDate: string): Promise<string> {
|
||||||
@@ -207,8 +219,7 @@ export class EmailImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanNumber = number.replace(/^0+/, '') || '0';
|
const cleanNumber = number.replace(/^0+/, '') || '0';
|
||||||
let url = this.buildUrl(urlTemplate, emailDate);
|
const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber);
|
||||||
url = url.replace('{Nummer}', cleanNumber);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -231,8 +242,7 @@ export class EmailImportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cleanNumber = number.replace(/^0+/, '') || '0';
|
const cleanNumber = number.replace(/^0+/, '') || '0';
|
||||||
let url = this.buildUrl(urlTemplate, emailDate);
|
const url = this.buildNummerUrl(urlTemplate, emailDate, cleanNumber);
|
||||||
url = url.replace('{Nummer}', cleanNumber);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
@@ -6,6 +6,12 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
const THUMBNAIL_WIDTH = 180;
|
const THUMBNAIL_WIDTH = 180;
|
||||||
|
|
||||||
|
function pruefeSeite(page: number): void {
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1) {
|
||||||
|
throw new BadRequestException('Ungültige Seitenzahl');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailPageCacheService {
|
export class EmailPageCacheService {
|
||||||
private readonly logger = new Logger(EmailPageCacheService.name);
|
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 {
|
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 {
|
previewPath(attachmentId: number | string, page: number): string {
|
||||||
|
pruefeSeite(page);
|
||||||
return path.join(
|
return path.join(
|
||||||
this.attachmentDir(attachmentId),
|
this.attachmentDir(attachmentId),
|
||||||
`page-${page}.preview.png`,
|
`page-${page}.preview.png`,
|
||||||
@@ -30,6 +45,7 @@ export class EmailPageCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thumbnailPath(attachmentId: number | string, page: number): string {
|
thumbnailPath(attachmentId: number | string, page: number): string {
|
||||||
|
pruefeSeite(page);
|
||||||
return path.join(
|
return path.join(
|
||||||
this.attachmentDir(attachmentId),
|
this.attachmentDir(attachmentId),
|
||||||
`page-${page}.thumb.png`,
|
`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 * as crypto from 'crypto';
|
||||||
import { PDFParse } from 'pdf-parse';
|
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) –
|
* 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
|
* @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text
|
||||||
* (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das
|
* (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(
|
export async function computeContentFingerprint(
|
||||||
pdf: Buffer,
|
pdf: Buffer,
|
||||||
): Promise<string | null> {
|
): 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
|
// pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben
|
||||||
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -49,3 +49,24 @@ describe('extrahiereEingebetteteDateien', () => {
|
|||||||
expect(await extrahiereEingebetteteDateien(pdf)).toEqual([]);
|
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,
|
PDFString,
|
||||||
decodePDFRawStream,
|
decodePDFRawStream,
|
||||||
} from 'pdf-lib';
|
} from 'pdf-lib';
|
||||||
|
import { ueberschreitetDekompressionsLimit } from '../common/pdf-dekompression.util';
|
||||||
|
|
||||||
export interface EingebetteteDatei {
|
export interface EingebetteteDatei {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -30,6 +31,9 @@ const MAX_TIEFE = 32;
|
|||||||
export async function extrahiereEingebetteteDateien(
|
export async function extrahiereEingebetteteDateien(
|
||||||
pdf: Buffer,
|
pdf: Buffer,
|
||||||
): Promise<EingebetteteDatei[]> {
|
): Promise<EingebetteteDatei[]> {
|
||||||
|
// pdf-lib entpackt Objekt- und Datei-Streams ohne Limit.
|
||||||
|
if (await ueberschreitetDekompressionsLimit(pdf)) return [];
|
||||||
|
|
||||||
const doc = await PDFDocument.load(pdf, {
|
const doc = await PDFDocument.load(pdf, {
|
||||||
ignoreEncryption: true,
|
ignoreEncryption: true,
|
||||||
throwOnInvalidObject: false,
|
throwOnInvalidObject: false,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DocumentType } from '../database/entities/document-type.entity';
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
import { FREIGABE_FIELD_ID } from '../paperless/status-felder.util';
|
||||||
const FREIGABE_FIELD_ID = 15;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreigabeService {
|
export class FreigabeService {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import { Client } from '../database/entities/client.entity';
|
import { Client } from '../database/entities/client.entity';
|
||||||
import { UserClient } from '../database/entities/user-client.entity';
|
import { UserClient } from '../database/entities/user-client.entity';
|
||||||
|
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
|
||||||
|
|
||||||
@Controller('api/clients')
|
@Controller('api/clients')
|
||||||
export class ClientsController {
|
export class ClientsController {
|
||||||
@@ -15,6 +16,7 @@ export class ClientsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
async getMyClients(@Request() req: any) {
|
async getMyClients(@Request() req: any) {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const mappings = await this.userClientRepo.find({
|
const mappings = await this.userClientRepo.find({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
kennzahlenAusPdf,
|
kennzahlenAusPdf,
|
||||||
type VergleichKennzahlen,
|
type VergleichKennzahlen,
|
||||||
} from '../paperless/vergleich-kennzahlen.util';
|
} from '../paperless/vergleich-kennzahlen.util';
|
||||||
|
import { parseSendEmailBody } from './send-email-body.util';
|
||||||
|
|
||||||
@Controller('api/inbox')
|
@Controller('api/inbox')
|
||||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||||
@@ -284,17 +285,10 @@ export class InboxController {
|
|||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async sendEmail(
|
async sendEmail(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body()
|
@Body() rawBody: unknown,
|
||||||
body: {
|
|
||||||
to: string;
|
|
||||||
subject: string;
|
|
||||||
body: string;
|
|
||||||
html?: string;
|
|
||||||
segments: { pages: number[]; filename: string }[];
|
|
||||||
sender?: string;
|
|
||||||
},
|
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const body = parseSendEmailBody(rawBody);
|
||||||
const preferredUsername: string | null =
|
const preferredUsername: string | null =
|
||||||
req.user?.preferredUsername ?? null;
|
req.user?.preferredUsername ?? null;
|
||||||
const smtpOverride =
|
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 { 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 {
|
export class CreateKontonummerDto {
|
||||||
correspondentId: number;
|
correspondentId: number;
|
||||||
@@ -8,7 +9,8 @@ export class CreateKontonummerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('api/kontonummern')
|
@Controller('api/kontonummern')
|
||||||
@UseGuards(JwtOrApiKeyGuard)
|
// Bankverbindungen: nur für die Dokumentbearbeitung (Posteingang, manuell)
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
export class KontonummernController {
|
export class KontonummernController {
|
||||||
constructor(private readonly kontonummernService: KontonummernService) {}
|
constructor(private readonly kontonummernService: KontonummernService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
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 { Permission } from '../auth/permissions.enum';
|
||||||
import { LabelPrintAgentService } from './label-print-agent.service';
|
import { LabelPrintAgentService } from './label-print-agent.service';
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ export class LabelPrintAgentController {
|
|||||||
|
|
||||||
// Agent: SSE-Stream für neue Druckaufträge
|
// Agent: SSE-Stream für neue Druckaufträge
|
||||||
@Sse('events')
|
@Sse('events')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
sseEvents(
|
sseEvents(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Observable<MessageEvent> {
|
): Observable<MessageEvent> {
|
||||||
@@ -68,6 +70,8 @@ export class LabelPrintAgentController {
|
|||||||
|
|
||||||
// Agent: nächsten Job abholen (Polling)
|
// Agent: nächsten Job abholen (Polling)
|
||||||
@Get('jobs/next')
|
@Get('jobs/next')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) {
|
async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) {
|
||||||
const job = await this.service.claimNextJob(agentId ?? 'unknown');
|
const job = await this.service.claimNextJob(agentId ?? 'unknown');
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -87,6 +91,8 @@ export class LabelPrintAgentController {
|
|||||||
|
|
||||||
// Agent: Bild separat abrufen
|
// Agent: Bild separat abrufen
|
||||||
@Get('jobs/:id/image')
|
@Get('jobs/:id/image')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async getImage(
|
async getImage(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@@ -100,6 +106,8 @@ export class LabelPrintAgentController {
|
|||||||
|
|
||||||
// Agent: Druck erfolgreich
|
// Agent: Druck erfolgreich
|
||||||
@Post('jobs/:id/printed')
|
@Post('jobs/:id/printed')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async markPrinted(
|
async markPrinted(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@@ -115,6 +123,8 @@ export class LabelPrintAgentController {
|
|||||||
|
|
||||||
// Agent: Druckfehler
|
// Agent: Druckfehler
|
||||||
@Post('jobs/:id/error')
|
@Post('jobs/:id/error')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async markError(
|
async markError(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@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,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, LessThan, IsNull } from 'typeorm';
|
import { Repository, LessThan, IsNull, Not } from 'typeorm';
|
||||||
import { Subject, Observable } from 'rxjs';
|
import { Subject, Observable } from 'rxjs';
|
||||||
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
||||||
import { BarcodeTemplate } from '../database/entities/barcode-template.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) => {
|
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
||||||
const colonIdx = key.indexOf(':');
|
const colonIdx = key.indexOf(':');
|
||||||
if (colonIdx !== -1) {
|
if (colonIdx !== -1) {
|
||||||
const varName = key.slice(0, colonIdx);
|
const varName = key.slice(0, colonIdx);
|
||||||
const width = parseInt(key.slice(colonIdx + 1), 10);
|
const width = parseInt(key.slice(colonIdx + 1), 10);
|
||||||
if (!isNaN(width) && varName in vars) {
|
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',
|
'Etikett-Druck für dieses Template nicht aktiviert',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Variablen aufbauen
|
// Variablen aufbauen – nur Eingabefelder der Vorlage. Abgeleitete Werte
|
||||||
const vars: Record<string, string> = { ...fieldValues };
|
// 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
|
// Datum-Felder: year/month/day separat ablegen
|
||||||
for (const field of template.LabelInputFields ?? []) {
|
for (const field of template.LabelInputFields ?? []) {
|
||||||
if (field.type === 'date' && fieldValues[field.name]) {
|
if (field.type === 'date' && vars[field.name]) {
|
||||||
const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD
|
const raw = vars[field.name]; // erwartet YYYY-MM-DD
|
||||||
const parts = raw.split('-');
|
const parts = raw.split('-');
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy
|
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy
|
||||||
@@ -89,7 +102,7 @@ export class LabelPrintAgentService {
|
|||||||
|
|
||||||
// GET-URL aufrufen → {number}
|
// GET-URL aufrufen → {number}
|
||||||
if (template.LabelGetUrl) {
|
if (template.LabelGetUrl) {
|
||||||
const url = applyVars(template.LabelGetUrl, vars);
|
const url = applyUrlVars(template.LabelGetUrl, vars);
|
||||||
if (isSafeUrl(url)) {
|
if (isSafeUrl(url)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -170,15 +183,15 @@ export class LabelPrintAgentService {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
printerName: string,
|
printerName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
const job = await this.ladeJob(jobId);
|
||||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
// Wiederholte Meldung (z.B. Retry nach Netzfehler): nichts mehr tun.
|
||||||
|
if (job.Status === 'printed') return;
|
||||||
job.Status = 'printed';
|
await this.schliesseJobAb(job, {
|
||||||
job.PrintedAt = new Date();
|
Status: 'printed',
|
||||||
job.PrintedByAgent = agentId;
|
PrintedAt: new Date(),
|
||||||
job.PrinterName = printerName;
|
PrintedByAgent: agentId,
|
||||||
await this.jobRepo.save(job);
|
PrinterName: printerName,
|
||||||
|
});
|
||||||
await this.callUrl('PRINTED', job);
|
await this.callUrl('PRINTED', job);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,16 +201,45 @@ export class LabelPrintAgentService {
|
|||||||
printerName: string,
|
printerName: string,
|
||||||
errorMessage: string,
|
errorMessage: string,
|
||||||
): Promise<void> {
|
): 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 } });
|
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
job.Status = 'error';
|
/**
|
||||||
job.PrintedByAgent = agentId;
|
* Abschluss nur für abgeholte, noch offene Jobs – bedingt in der DB, damit
|
||||||
job.PrinterName = printerName;
|
* bei gleichzeitigen Meldungen genau eine gewinnt und nur ein Callback läuft.
|
||||||
job.ErrorMessage = errorMessage;
|
*/
|
||||||
await this.jobRepo.save(job);
|
private async schliesseJobAb(
|
||||||
|
job: LabelPrintJob,
|
||||||
await this.callUrl('RELEASE', job);
|
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(
|
async renderPreview(
|
||||||
@@ -248,7 +290,7 @@ export class LabelPrintAgentService {
|
|||||||
type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
||||||
if (!urlTemplate) return;
|
if (!urlTemplate) return;
|
||||||
|
|
||||||
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
|
const url = applyUrlVars(urlTemplate, job.LabelVariables ?? {});
|
||||||
if (!isSafeUrl(url)) {
|
if (!isSafeUrl(url)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${type}-URL übersprungen (ungültiges Protokoll): ${url}`,
|
`${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,
|
Res,
|
||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
|
ForbiddenException,
|
||||||
|
ParseIntPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
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 { 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 { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 */
|
/** Setting.Tag-Schlüssel für die manuell gepflegte Steuertag-Liste */
|
||||||
export const STEUERTAG_SETTING_KEY = 'steuertag_ids';
|
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')
|
@Controller('api/paperless')
|
||||||
export class PaperlessController {
|
export class PaperlessController {
|
||||||
private readonly logger = new Logger(PaperlessController.name);
|
private readonly logger = new Logger(PaperlessController.name);
|
||||||
@@ -71,11 +87,14 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('steuertags')
|
@Get('steuertags')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
async getSteuertags() {
|
async getSteuertags() {
|
||||||
return { ids: await this.getSteuertagIds() };
|
return { ids: await this.getSteuertagIds() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('checksum')
|
@Post('checksum')
|
||||||
|
@AllowApiKey()
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async checksumExists(@Body('checksum') checksum: string) {
|
async checksumExists(@Body('checksum') checksum: string) {
|
||||||
const exists = await this.paperlessService.checksumExists(checksum);
|
const exists = await this.paperlessService.checksumExists(checksum);
|
||||||
return { exists };
|
return { exists };
|
||||||
@@ -88,7 +107,9 @@ export class PaperlessController {
|
|||||||
return this.paperlessService.pruefeBelegnummer(belegnummer ?? '');
|
return this.paperlessService.pruefeBelegnummer(belegnummer ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dokumentsuche für Verknüpfungsfelder im Bearbeiten-Dialog. */
|
||||||
@Get('documents')
|
@Get('documents')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX)
|
||||||
async getDocuments(
|
async getDocuments(
|
||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@@ -101,20 +122,28 @@ export class PaperlessController {
|
|||||||
if (search) {
|
if (search) {
|
||||||
params.query = search; // Global search in Paperless
|
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')
|
@Get('documents/:id')
|
||||||
async getDocument(@Param('id') id: string) {
|
@RequirePermissions(Permission.VIEW_INBOX)
|
||||||
return this.paperlessService.getDocument(parseInt(id, 10));
|
async getDocument(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return dokumentKurzinfo(await this.paperlessService.getDocument(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tags')
|
@Get('tags')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
async getTags() {
|
async getTags() {
|
||||||
return this.paperlessService.getTags();
|
return this.paperlessService.getTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tags/:id')
|
@Get('tags/:id')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
async getTag(@Param('id') id: string) {
|
async getTag(@Param('id') id: string) {
|
||||||
// If the service doesn't have getTag(id), I should add it or just fetch all and find
|
// If the service doesn't have getTag(id), I should add it or just fetch all and find
|
||||||
const tags = await this.paperlessService.getTags();
|
const tags = await this.paperlessService.getTags();
|
||||||
@@ -122,11 +151,18 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('document-types')
|
@Get('document-types')
|
||||||
|
@RequirePermissions(
|
||||||
|
Permission.VIEW_INBOX,
|
||||||
|
Permission.PROCESS_MANUALLY,
|
||||||
|
Permission.VIEW_FREIGABE,
|
||||||
|
Permission.VIEW_ZAHLUNG,
|
||||||
|
)
|
||||||
async getDocumentTypes() {
|
async getDocumentTypes() {
|
||||||
return this.paperlessService.getDocumentTypes();
|
return this.paperlessService.getDocumentTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('custom-fields')
|
@Get('custom-fields')
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async getCustomFields() {
|
async getCustomFields() {
|
||||||
return this.paperlessService.getCustomFields();
|
return this.paperlessService.getCustomFields();
|
||||||
}
|
}
|
||||||
@@ -138,6 +174,13 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('correspondents')
|
@Get('correspondents')
|
||||||
|
@RequirePermissions(
|
||||||
|
Permission.VIEW_INBOX,
|
||||||
|
Permission.PROCESS_MANUALLY,
|
||||||
|
Permission.VIEW_MAIL,
|
||||||
|
Permission.VIEW_FREIGABE,
|
||||||
|
Permission.VIEW_ZAHLUNG,
|
||||||
|
)
|
||||||
async getCorrespondents(@Query('search') search?: string) {
|
async getCorrespondents(@Query('search') search?: string) {
|
||||||
const params: any = { page_size: 9999 };
|
const params: any = { page_size: 9999 };
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -148,6 +191,7 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('correspondents/:id')
|
@Get('correspondents/:id')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
async getCorrespondent(@Param('id') id: string) {
|
async getCorrespondent(@Param('id') id: string) {
|
||||||
return this.paperlessService.getCorrespondent(parseInt(id, 10));
|
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)
|
@RequirePermissions(Permission.VIEW_INBOX)
|
||||||
@Get('inbox/preview/:id')
|
async getDocumentPreview(@Param('id') id: string, @Res() res: Response) {
|
||||||
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
|
|
||||||
try {
|
try {
|
||||||
const stream = await this.paperlessService.getDocumentPreviewStream(
|
const stream = await this.paperlessService.getDocumentPreviewStream(
|
||||||
parseInt(id, 10),
|
parseInt(id, 10),
|
||||||
@@ -227,9 +275,18 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermissions(Permission.VIEW_INBOX)
|
/**
|
||||||
@Get('inbox/pdf/:id')
|
* Original-PDF – für den Bearbeiten-Dialog und den Duplikatvergleich in
|
||||||
async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
|
* 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 {
|
try {
|
||||||
const stream = await this.paperlessService.getDocumentPdfStream(
|
const stream = await this.paperlessService.getDocumentPdfStream(
|
||||||
parseInt(id, 10),
|
parseInt(id, 10),
|
||||||
@@ -252,6 +309,7 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('requirements/:id')
|
@Get('requirements/:id')
|
||||||
|
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
|
||||||
async getRequirements(
|
async getRequirements(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('Posteingang') posteingang: string,
|
@Query('Posteingang') posteingang: string,
|
||||||
@@ -336,21 +394,36 @@ export class PaperlessController {
|
|||||||
* Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument
|
* Mandant und Belegart vor. Liefert `erechnung: false`, wenn das Dokument
|
||||||
* keine auswertbare E-Rechnung enthält.
|
* keine auswertbare E-Rechnung enthält.
|
||||||
*/
|
*/
|
||||||
@Get('inbox/:id/erechnung')
|
@Get('documents/:id/erechnung')
|
||||||
@RequirePermissions(Permission.VIEW_INBOX)
|
@RequirePermissions(Permission.VIEW_INBOX)
|
||||||
async getInboxErechnung(@Param('id') id: string, @Request() req: any) {
|
async getDocumentErechnung(
|
||||||
return this.erechnungService.analysiereDokument(
|
@Param('id', ParseIntPipe) id: number,
|
||||||
parseInt(id, 10),
|
@Request() req: BenutzerRequest,
|
||||||
req.user?.userId,
|
) {
|
||||||
);
|
await this.ladeBearbeitbaresDokument(id, req);
|
||||||
|
return this.erechnungService.analysiereDokument(id, req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('inbox/:id')
|
@Put('documents/:id')
|
||||||
@RequirePermissions(Permission.VIEW_INBOX)
|
@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);
|
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.document_type = body.documentType ?? oldDocument.document_type;
|
||||||
oldDocument.owner = body.mandant ?? oldDocument.owner;
|
oldDocument.owner = body.mandant ?? oldDocument.owner;
|
||||||
@@ -515,6 +588,33 @@ export class PaperlessController {
|
|||||||
return { success: true };
|
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')
|
@Get('tasks')
|
||||||
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async getAllTasks() {
|
async getAllTasks() {
|
||||||
@@ -539,6 +639,7 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('external-upload')
|
@Post('external-upload')
|
||||||
|
@AllowApiKey()
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(ApiKeyGuard)
|
||||||
@UseInterceptors(FileInterceptor('document'))
|
@UseInterceptors(FileInterceptor('document'))
|
||||||
async externalUpload(
|
async externalUpload(
|
||||||
|
|||||||
@@ -215,11 +215,15 @@ export class PaperlessService {
|
|||||||
return response.data.results;
|
return response.data.results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManuellDocuments(): Promise<any[]> {
|
/** Tag der Liste „Manuell bearbeiten“ (Env-Werte kommen als String). */
|
||||||
const errorTag = this.configService.get<number>(
|
getManuellTagId(): number {
|
||||||
'MANUELL_BEARBEITEN_TAG',
|
return Number(
|
||||||
6,
|
this.configService.get<number | string>('MANUELL_BEARBEITEN_TAG', 6),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManuellDocuments(): Promise<any[]> {
|
||||||
|
const errorTag = this.getManuellTagId();
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
params: {
|
params: {
|
||||||
page: 1,
|
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 { Repository } from 'typeorm';
|
||||||
import { ExportTarget } from '../database/entities/export-target.entity';
|
import { ExportTarget } from '../database/entities/export-target.entity';
|
||||||
import * as ftp from 'basic-ftp';
|
import * as ftp from 'basic-ftp';
|
||||||
|
import { sichererDateiname } from '../common/pfad-sicherheit.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -24,9 +25,17 @@ export class ExportService {
|
|||||||
throw new Error(`Export-Ziel "${target.Name}" ist deaktiviert.`);
|
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) {
|
switch (target.Protocol) {
|
||||||
case 'ftp':
|
case 'ftp':
|
||||||
await this.uploadFtp(target, filename, content);
|
await this.uploadFtp(target, dateiname, content);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unbekanntes Protokoll: ${target.Protocol}`);
|
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,
|
filename: a.filename,
|
||||||
content: a.content,
|
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(
|
this.logger.log(
|
||||||
|
|||||||
@@ -7,6 +7,27 @@ import * as os from 'os';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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()
|
@Injectable()
|
||||||
export class PdfService {
|
export class PdfService {
|
||||||
private readonly logger = new Logger(PdfService.name);
|
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 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-'));
|
||||||
const outputPath = path.join(tmpDir, `page-${page}.png`);
|
const outputPath = path.join(tmpDir, `page-${page}.png`);
|
||||||
|
|
||||||
await execFileAsync('gs', [
|
try {
|
||||||
'-dNOPAUSE',
|
await gs(
|
||||||
'-dBATCH',
|
[
|
||||||
'-dNOSAFER',
|
'-sDEVICE=png16m',
|
||||||
'-sDEVICE=png16m',
|
`-dFirstPage=${page}`,
|
||||||
`-dFirstPage=${page}`,
|
`-dLastPage=${page}`,
|
||||||
`-dLastPage=${page}`,
|
`-r${dpi}`,
|
||||||
`-r${dpi}`,
|
`-sOutputFile=${outputPath}`,
|
||||||
`-sOutputFile=${outputPath}`,
|
pdfPath,
|
||||||
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}`);
|
this.logger.debug(`PDF Seite ${page} konvertiert: ${outputPath}`);
|
||||||
return outputPath;
|
return outputPath;
|
||||||
@@ -48,15 +74,21 @@ export class PdfService {
|
|||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-'));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-'));
|
||||||
|
|
||||||
await execFileAsync('gs', [
|
try {
|
||||||
'-dNOPAUSE',
|
await gs(
|
||||||
'-dBATCH',
|
[
|
||||||
'-dNOSAFER',
|
'-sDEVICE=png16m',
|
||||||
'-sDEVICE=png16m',
|
`-dLastPage=${MAX_RENDER_SEITEN}`,
|
||||||
`-r${dpi}`,
|
`-r${dpi}`,
|
||||||
`-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`,
|
`-sOutputFile=${path.join(tmpDir, 'page-%d.png')}`,
|
||||||
pdfPath,
|
pdfPath,
|
||||||
]);
|
],
|
||||||
|
GS_TIMEOUT_DOKUMENT_MS,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const entries = await fs.readdir(tmpDir);
|
const entries = await fs.readdir(tmpDir);
|
||||||
const images = entries
|
const images = entries
|
||||||
@@ -68,6 +100,12 @@ export class PdfService {
|
|||||||
})
|
})
|
||||||
.map((f) => path.join(tmpDir, f));
|
.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) {
|
if (images.length === 0) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`,
|
`Ghostscript hat keine Seiten erstellt: ${pdfPath} — Verzeichnisinhalt: [${entries.join(', ')}]`,
|
||||||
@@ -81,34 +119,21 @@ export class PdfService {
|
|||||||
return images;
|
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)
|
* Bereinigt eine PDF (entschlüsselt sie ggf. wenn nur Owner-Passwort gesetzt ist)
|
||||||
* via Ghostscript pdfwrite.
|
* via Ghostscript pdfwrite.
|
||||||
*/
|
*/
|
||||||
async sanitizePdf(inputPath: string): Promise<string> {
|
async sanitizePdf(inputPath: string): Promise<string> {
|
||||||
const outputPath = path.join(os.tmpdir(), `sanitized-${Date.now()}.pdf`);
|
const outputPath = path.join(os.tmpdir(), `sanitized-${Date.now()}.pdf`);
|
||||||
await execFileAsync('gs', [
|
try {
|
||||||
'-dNOPAUSE',
|
await gs(
|
||||||
'-dBATCH',
|
['-sDEVICE=pdfwrite', `-sOutputFile=${outputPath}`, inputPath],
|
||||||
'-dNOSAFER',
|
GS_TIMEOUT_DOKUMENT_MS,
|
||||||
'-sDEVICE=pdfwrite',
|
);
|
||||||
`-sOutputFile=${outputPath}`,
|
} catch (err) {
|
||||||
inputPath,
|
await fs.rm(outputPath, { force: true });
|
||||||
]);
|
throw err;
|
||||||
|
}
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
InboxDocument,
|
InboxDocument,
|
||||||
type InboxSource,
|
type InboxSource,
|
||||||
} from '../database/entities/inbox-document.entity';
|
} from '../database/entities/inbox-document.entity';
|
||||||
|
import { uebernehmeRegulaereDatei } from '../common/pfad-sicherheit.util';
|
||||||
|
|
||||||
const STABILITY_MS = 5000;
|
const STABILITY_MS = 5000;
|
||||||
|
|
||||||
@@ -71,6 +72,8 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
pollInterval: 500,
|
pollInterval: 500,
|
||||||
},
|
},
|
||||||
depth: 1,
|
depth: 1,
|
||||||
|
// Symlinks auf der Scan-Freigabe nicht verfolgen (siehe uebernehmeRegulaereDatei)
|
||||||
|
followSymlinks: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watcher
|
this.watcher
|
||||||
@@ -184,7 +187,11 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (this.processing.has(filePath)) return;
|
if (this.processing.has(filePath)) return;
|
||||||
|
|
||||||
try {
|
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) {
|
if (stat.size === 0) {
|
||||||
this.logger.debug(`Überspringe leere Datei: ${filePath}`);
|
this.logger.debug(`Überspringe leere Datei: ${filePath}`);
|
||||||
return;
|
return;
|
||||||
@@ -204,7 +211,7 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const targetPdf = this.pageCache.documentPdfPath(id);
|
const targetPdf = this.pageCache.documentPdfPath(id);
|
||||||
await fs.mkdir(targetDir, { recursive: true });
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
await this.move(filePath, targetPdf);
|
await uebernehmeRegulaereDatei(filePath, targetPdf);
|
||||||
|
|
||||||
const doc = this.documentRepo.create({
|
const doc = this.documentRepo.create({
|
||||||
Id: id,
|
Id: id,
|
||||||
@@ -262,22 +269,4 @@ export class ScannerWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.log('Backfill: alle Dateien bereits gescannt');
|
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 { Controller, Get, Request } from '@nestjs/common';
|
||||||
import { StatsService } from './stats.service';
|
import { StatsService } from './stats.service';
|
||||||
|
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
|
||||||
|
|
||||||
@Controller('api/stats')
|
@Controller('api/stats')
|
||||||
export class StatsController {
|
export class StatsController {
|
||||||
constructor(private readonly statsService: StatsService) {}
|
constructor(private readonly statsService: StatsService) {}
|
||||||
|
|
||||||
@Get('counts')
|
@Get('counts')
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
async getCounts(@Request() req: any) {
|
async getCounts(@Request() req: any) {
|
||||||
return this.statsService.getDashboardCounts(req.user?.preferredUsername);
|
return this.statsService.getDashboardCounts(req.user?.preferredUsername);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserSettingsService } from './user-settings.service';
|
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';
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
|
||||||
@Controller('api/user-settings')
|
@Controller('api/user-settings')
|
||||||
@@ -16,16 +19,19 @@ export class UserSettingsController {
|
|||||||
constructor(private readonly userSettingsService: UserSettingsService) {}
|
constructor(private readonly userSettingsService: UserSettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
async getSettings(@Request() req: any) {
|
async getSettings(@Request() req: any) {
|
||||||
return this.userSettingsService.getSettings(req.user.userId);
|
return this.userSettingsService.getSettings(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
async updateSettings(@Request() req: any, @Body() body: any) {
|
async updateSettings(@Request() req: any, @Body() body: any) {
|
||||||
return this.userSettingsService.updateSettings(req.user.userId, body);
|
return this.userSettingsService.updateSettings(req.user.userId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('senders')
|
@Get('senders')
|
||||||
|
@AllowAnyAuthenticated()
|
||||||
async getSenders(@Request() req: any) {
|
async getSenders(@Request() req: any) {
|
||||||
return this.userSettingsService.getAvailableSenders(req.user.userId);
|
return this.userSettingsService.getAvailableSenders(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
import { WebhookQueueService } from './webhook-queue.service';
|
import { WebhookQueueService } from './webhook-queue.service';
|
||||||
|
import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator';
|
||||||
|
import { Permission } from '../auth/permissions.enum';
|
||||||
|
|
||||||
export interface PaperlessWebhookPayload {
|
export interface PaperlessWebhookPayload {
|
||||||
doc_url?: string;
|
doc_url?: string;
|
||||||
@@ -27,6 +29,7 @@ export class WebhookController {
|
|||||||
|
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(ApiKeyGuard)
|
||||||
@Post('paperless')
|
@Post('paperless')
|
||||||
|
@AllowApiKey()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
|
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
|
||||||
const documentId = this.extractDocumentId(payload);
|
const documentId = this.extractDocumentId(payload);
|
||||||
@@ -59,6 +62,7 @@ export class WebhookController {
|
|||||||
* per JWT oder API-Key zugänglich.
|
* per JWT oder API-Key zugänglich.
|
||||||
*/
|
*/
|
||||||
@Get('status')
|
@Get('status')
|
||||||
|
@RequirePermissions(Permission.MANAGE_SETTINGS)
|
||||||
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
|
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
|
||||||
return this.webhookQueue.getStatus();
|
return this.webhookQueue.getStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { DocumentType } from '../database/entities/document-type.entity';
|
import { DocumentType } from '../database/entities/document-type.entity';
|
||||||
import { PaperlessService } from '../paperless/paperless.service';
|
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';
|
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||||
|
|
||||||
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const paperlessApi = {
|
|||||||
checksumExists: (checksum: string) =>
|
checksumExists: (checksum: string) =>
|
||||||
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
|
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
|
||||||
getDocumentPdfBlob: (id: number) =>
|
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) =>
|
pruefeBelegnummer: (belegnummer: string) =>
|
||||||
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
|
api.get<BelegnummerPruefung>('/api/paperless/belegnummer-pruefung', { params: { belegnummer } }).then(r => r.data),
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ export const posteingangApi = {
|
|||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
updateDocument: (id: number, data: any) =>
|
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) =>
|
getKontonummern: (correspondentId: number) =>
|
||||||
api.get<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data),
|
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),
|
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
|
||||||
|
|
||||||
getErechnung: (id: number) =>
|
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);
|
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')) {
|
if (groups.includes('PM_Buchhaltung')) {
|
||||||
permissions.add(Permission.PROCESS_MANUALLY);
|
permissions.add(Permission.PROCESS_MANUALLY);
|
||||||
|
permissions.add(Permission.VIEW_INBOX);
|
||||||
}
|
}
|
||||||
if (groups.includes('PM_Maileingang')) {
|
if (groups.includes('PM_Maileingang')) {
|
||||||
permissions.add(Permission.VIEW_MAIL);
|
permissions.add(Permission.VIEW_MAIL);
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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 />}
|
icon={<ExportOutlined />}
|
||||||
block
|
block
|
||||||
style={{ marginBottom: 8 }}
|
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
|
PDF in neuem Tab öffnen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<AuthIframe
|
<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%' }}
|
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
|||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<AuthImage
|
<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}
|
width={80}
|
||||||
height={110}
|
height={110}
|
||||||
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function ManuellBearbeitenPage() {
|
|||||||
|
|
||||||
const popoverContent = (id: number) => (
|
const popoverContent = (id: number) => (
|
||||||
<AuthImage
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
|
||||||
width={400}
|
width={400}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -108,7 +108,7 @@ export default function ManuellBearbeitenPage() {
|
|||||||
render: (_: any, record: PosteingangDocument) => (
|
render: (_: any, record: PosteingangDocument) => (
|
||||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||||
<AuthImage
|
<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}
|
width={100}
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
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', flexDirection: 'column', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<AuthImage
|
<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}
|
width={72}
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function PosteingangPage() {
|
|||||||
|
|
||||||
const popoverContent = (id: number) => (
|
const popoverContent = (id: number) => (
|
||||||
<AuthImage
|
<AuthImage
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/documents/${id}/preview`}
|
||||||
width={400}
|
width={400}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -80,7 +80,7 @@ export default function PosteingangPage() {
|
|||||||
render: (_: any, record: PosteingangDocument) => (
|
render: (_: any, record: PosteingangDocument) => (
|
||||||
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
|
||||||
<AuthImage
|
<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}
|
width={100}
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
|
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', flexDirection: 'column', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<AuthImage
|
<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}
|
width={72}
|
||||||
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user