fix(security): Befunde aus dem Security-Audit beheben #7

Merged
bjoernpoettker merged 10 commits from fix/security-audit into main 2026-11-04 10:03:36 +00:00
21 changed files with 699 additions and 51 deletions
Showing only changes of commit 3f98dfd98f - Show all commits

No files matched your search

+9
View File
@@ -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
+3 -1
View File
@@ -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).
--- ---
+2
View File
@@ -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();
} }
@@ -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);
@@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/unbound-method -- Handler dienen nur als Metadaten-Schlüssel */
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
import {
AllowAnyAuthenticated,
AllowApiKey,
RequirePermissions,
} from './permissions.decorator';
import { Public } from './public.decorator';
import { Permission } from './permissions.enum';
class Routen {
@RequirePermissions(Permission.VIEW_MAIL)
mail() {}
@AllowAnyAuthenticated()
eigeneEinstellungen() {}
ohneAngabe() {}
@Public()
oeffentlich() {}
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
agent() {}
@AllowApiKey()
nurApiKey() {}
}
@RequirePermissions(Permission.VIEW_INBOX)
class KlasseMitRecht {
route() {}
}
function ctx(
handler: (...args: unknown[]) => unknown,
request: Record<string, unknown>,
cls: object = Routen,
): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => cls,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
const r = Routen.prototype;
const nutzer = (...permissions: Permission[]) => ({
method: 'GET',
url: '/x',
user: { permissions },
});
const apiKey = {
method: 'GET',
url: '/x',
apiKeyMetadata: { id: '1', name: 'Agent' },
};
describe('PermissionsGuard', () => {
const guard = new PermissionsGuard(new Reflector());
it('erlaubt Benutzer mit passender Berechtigung', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_MAIL)))).toBe(
true,
);
});
it('verweigert Benutzer ohne passende Berechtigung', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_INBOX)))).toBe(
false,
);
});
it('erlaubt Admins (MANAGE_ALL) überall mit Rechteangabe', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.MANAGE_ALL)))).toBe(
true,
);
});
it('übernimmt Rechte von der Klasse', () => {
const k = KlasseMitRecht.prototype;
expect(
guard.canActivate(
ctx(k.route, nutzer(Permission.VIEW_INBOX), KlasseMitRecht),
),
).toBe(true);
expect(
guard.canActivate(
ctx(k.route, nutzer(Permission.VIEW_MAIL), KlasseMitRecht),
),
).toBe(false);
});
it('verweigert Routen ohne Rechteangabe (deny by default) – auch Admins', () => {
expect(
guard.canActivate(ctx(r.ohneAngabe, nutzer(Permission.MANAGE_ALL))),
).toBe(false);
});
it('erlaubt @AllowAnyAuthenticated jedem angemeldeten Benutzer, auch ohne PM-Gruppe', () => {
expect(guard.canActivate(ctx(r.eigeneEinstellungen, nutzer()))).toBe(true);
expect(
guard.canActivate(
ctx(r.eigeneEinstellungen, { method: 'GET', url: '/x' }),
),
).toBe(false);
});
it('lässt @Public-Routen immer durch', () => {
expect(
guard.canActivate(ctx(r.oeffentlich, { method: 'GET', url: '/x' })),
).toBe(true);
});
describe('API-Keys', () => {
it('gelten nur auf Routen mit @AllowApiKey', () => {
expect(guard.canActivate(ctx(r.agent, apiKey))).toBe(true);
expect(guard.canActivate(ctx(r.nurApiKey, apiKey))).toBe(true);
});
it('umgehen keine Rechteprüfung mehr auf anderen Routen', () => {
expect(() => guard.canActivate(ctx(r.mail, apiKey))).toThrow(
ForbiddenException,
);
expect(() =>
guard.canActivate(ctx(r.eigeneEinstellungen, apiKey)),
).toThrow(ForbiddenException);
expect(() => guard.canActivate(ctx(r.ohneAngabe, apiKey))).toThrow(
ForbiddenException,
);
});
it('Benutzer brauchen auf @AllowApiKey-Routen weiterhin die Berechtigung', () => {
expect(
guard.canActivate(ctx(r.agent, nutzer(Permission.VIEW_SCANNER))),
).toBe(false);
expect(
guard.canActivate(ctx(r.agent, nutzer(Permission.MANAGE_SETTINGS))),
).toBe(true);
expect(
guard.canActivate(ctx(r.nurApiKey, nutzer(Permission.MANAGE_ALL))),
).toBe(false);
});
});
});
+56 -13
View File
@@ -1,34 +1,77 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { 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,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;
@@ -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({
@@ -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}`,
@@ -19,8 +19,9 @@ import {
Request, Request,
} 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 { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -71,11 +72,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 };
@@ -89,6 +93,7 @@ export class PaperlessController {
} }
@Get('documents') @Get('documents')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getDocuments( async getDocuments(
@Query('search') search?: string, @Query('search') search?: string,
@Query('page') page?: string, @Query('page') page?: string,
@@ -105,16 +110,19 @@ export class PaperlessController {
} }
@Get('documents/:id') @Get('documents/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getDocument(@Param('id') id: string) { async getDocument(@Param('id') id: string) {
return this.paperlessService.getDocument(parseInt(id, 10)); return this.paperlessService.getDocument(parseInt(id, 10));
} }
@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 +130,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 +153,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 +170,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));
} }
@@ -252,6 +275,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,
@@ -347,8 +371,24 @@ export class PaperlessController {
@Put('inbox/:id') @Put('inbox/:id')
@RequirePermissions(Permission.VIEW_INBOX) @RequirePermissions(Permission.VIEW_INBOX)
async putInboxDocument(@Param('id') id: string, @Body() body: any) { async putInboxDocument(
@Param('id') id: string,
@Body() body: any,
@Request()
req: { user?: { permissions?: Permission[]; preferredUsername?: string } },
) {
const documentId = parseInt(id, 10); const documentId = parseInt(id, 10);
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;
// Fetch from paperless // Fetch from paperless
const oldDocument = await this.paperlessService.getDocument(documentId); const oldDocument = await this.paperlessService.getDocument(documentId);
@@ -539,6 +579,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(
@@ -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 };
}
@@ -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';