fix(security): Rechteprüfung deny by default, API-Keys nur auf freigegebenen Routen

- PermissionsGuard lehnt Routen ohne @RequirePermissions/@AllowAnyAuthenticated
  ab. API-Keys umgehen keine Rechteprüfung mehr, sondern gelten nur auf
  Routen mit @AllowApiKey (Paperless-Webhook, externer Upload, checksum,
  Agent-Routen des Etikettendrucks).
- Rechte für 24 bisher offene Routen ergänzt, u.a. paperless/documents
  (nur Admin), paperless/documents/:id und Kontonummern (Posteingang/
  manuell), webhook/status (Admin). routen-rechte.spec.ts prüft alle Routen.
- Etiketten-Agent: printed/error nur für abgeholte, offene Jobs (bedingtes
  Update, sonst 409) – ein gedruckter Job kann seine Nummer nicht mehr
  freigeben. Feldwerte nur aus den Eingabefeldern der Vorlage ({number}
  nicht vorgebbar) und URL-kodiert in GET-/Callback-URLs.
- PUT paperless/inbox/:id ignoriert Freigabe (CF15) und Zahlung (CF16)
  ohne die jeweilige Rolle (Funktionstrennung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-04 10:46:43 +01:00
1 parent de9cb09bcd
commit 3f98dfd98f
21 files changed
+699 -51

No files matched your search

+2
View File
@@ -1,11 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { AllowAnyAuthenticated } from './auth/permissions.decorator';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@AllowAnyAuthenticated()
getHello(): string {
return this.appService.getHello();
}
@@ -2,5 +2,24 @@ import { SetMetadata } from '@nestjs/common';
import { Permission } from './permissions.enum';
export const PERMISSIONS_KEY = 'permissions';
export const ALLOW_ANY_AUTHENTICATED_KEY = 'allowAnyAuthenticated';
export const ALLOW_API_KEY_KEY = 'allowApiKey';
/** Route nur für Benutzer mit mindestens einer der Berechtigungen. */
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
/**
* Route für jeden angemeldeten Benutzer – auch ohne PM-Gruppe (z.B. eigene
* Benutzereinstellungen). Muss ausdrücklich gesetzt werden: Routen ohne
* Rechteangabe lehnt der PermissionsGuard ab.
*/
export const AllowAnyAuthenticated = () =>
SetMetadata(ALLOW_ANY_AUTHENTICATED_KEY, true);
/**
* Route darf (auch) mit API-Key aufgerufen werden. API-Keys haben keine
* eigenen Rechte; ohne diese Markierung lehnt der PermissionsGuard sie ab.
* Für Benutzer (JWT) gilt zusätzlich @RequirePermissions.
*/
export const AllowApiKey = () => SetMetadata(ALLOW_API_KEY_KEY, true);
@@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/unbound-method -- Handler dienen nur als Metadaten-Schlüssel */
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
import {
AllowAnyAuthenticated,
AllowApiKey,
RequirePermissions,
} from './permissions.decorator';
import { Public } from './public.decorator';
import { Permission } from './permissions.enum';
class Routen {
@RequirePermissions(Permission.VIEW_MAIL)
mail() {}
@AllowAnyAuthenticated()
eigeneEinstellungen() {}
ohneAngabe() {}
@Public()
oeffentlich() {}
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
agent() {}
@AllowApiKey()
nurApiKey() {}
}
@RequirePermissions(Permission.VIEW_INBOX)
class KlasseMitRecht {
route() {}
}
function ctx(
handler: (...args: unknown[]) => unknown,
request: Record<string, unknown>,
cls: object = Routen,
): ExecutionContext {
return {
getHandler: () => handler,
getClass: () => cls,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
const r = Routen.prototype;
const nutzer = (...permissions: Permission[]) => ({
method: 'GET',
url: '/x',
user: { permissions },
});
const apiKey = {
method: 'GET',
url: '/x',
apiKeyMetadata: { id: '1', name: 'Agent' },
};
describe('PermissionsGuard', () => {
const guard = new PermissionsGuard(new Reflector());
it('erlaubt Benutzer mit passender Berechtigung', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_MAIL)))).toBe(
true,
);
});
it('verweigert Benutzer ohne passende Berechtigung', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.VIEW_INBOX)))).toBe(
false,
);
});
it('erlaubt Admins (MANAGE_ALL) überall mit Rechteangabe', () => {
expect(guard.canActivate(ctx(r.mail, nutzer(Permission.MANAGE_ALL)))).toBe(
true,
);
});
it('übernimmt Rechte von der Klasse', () => {
const k = KlasseMitRecht.prototype;
expect(
guard.canActivate(
ctx(k.route, nutzer(Permission.VIEW_INBOX), KlasseMitRecht),
),
).toBe(true);
expect(
guard.canActivate(
ctx(k.route, nutzer(Permission.VIEW_MAIL), KlasseMitRecht),
),
).toBe(false);
});
it('verweigert Routen ohne Rechteangabe (deny by default) – auch Admins', () => {
expect(
guard.canActivate(ctx(r.ohneAngabe, nutzer(Permission.MANAGE_ALL))),
).toBe(false);
});
it('erlaubt @AllowAnyAuthenticated jedem angemeldeten Benutzer, auch ohne PM-Gruppe', () => {
expect(guard.canActivate(ctx(r.eigeneEinstellungen, nutzer()))).toBe(true);
expect(
guard.canActivate(
ctx(r.eigeneEinstellungen, { method: 'GET', url: '/x' }),
),
).toBe(false);
});
it('lässt @Public-Routen immer durch', () => {
expect(
guard.canActivate(ctx(r.oeffentlich, { method: 'GET', url: '/x' })),
).toBe(true);
});
describe('API-Keys', () => {
it('gelten nur auf Routen mit @AllowApiKey', () => {
expect(guard.canActivate(ctx(r.agent, apiKey))).toBe(true);
expect(guard.canActivate(ctx(r.nurApiKey, apiKey))).toBe(true);
});
it('umgehen keine Rechteprüfung mehr auf anderen Routen', () => {
expect(() => guard.canActivate(ctx(r.mail, apiKey))).toThrow(
ForbiddenException,
);
expect(() =>
guard.canActivate(ctx(r.eigeneEinstellungen, apiKey)),
).toThrow(ForbiddenException);
expect(() => guard.canActivate(ctx(r.ohneAngabe, apiKey))).toThrow(
ForbiddenException,
);
});
it('Benutzer brauchen auf @AllowApiKey-Routen weiterhin die Berechtigung', () => {
expect(
guard.canActivate(ctx(r.agent, nutzer(Permission.VIEW_SCANNER))),
).toBe(false);
expect(
guard.canActivate(ctx(r.agent, nutzer(Permission.MANAGE_SETTINGS))),
).toBe(true);
expect(
guard.canActivate(ctx(r.nurApiKey, nutzer(Permission.MANAGE_ALL))),
).toBe(false);
});
});
});
+56 -13
View File
@@ -1,34 +1,77 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from './permissions.decorator';
import {
ALLOW_ANY_AUTHENTICATED_KEY,
ALLOW_API_KEY_KEY,
PERMISSIONS_KEY,
} from './permissions.decorator';
import { IS_PUBLIC_KEY } from './public.decorator';
import { Permission } from './permissions.enum';
interface GeprueftesRequest {
method: string;
url: string;
user?: { permissions?: Permission[] };
apiKeyMetadata?: { id: string; name: string };
}
/**
* Prüft Berechtigungen – deny by default:
* - @Public: immer erlaubt
* - API-Key: nur auf Routen mit @AllowApiKey
* - Benutzer: @RequirePermissions (eine genügt, MANAGE_ALL immer) oder
* @AllowAnyAuthenticated; Routen ohne beides werden abgelehnt.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name);
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
const targets = [context.getHandler(), context.getClass()];
const meta = <T>(key: string) =>
this.reflector.getAllAndOverride<T | undefined>(key, targets);
if (!requiredPermissions) {
return true;
}
if (meta<boolean>(IS_PUBLIC_KEY)) return true;
const request = context.switchToHttp().getRequest();
const { user } = request;
const request = context.switchToHttp().getRequest<GeprueftesRequest>();
const route = `[${request.method} ${request.url}]`;
if (request.apiKeyMetadata) {
return true;
if (meta<boolean>(ALLOW_API_KEY_KEY)) return true;
this.logger.warn(
`${route} API-Key "${request.apiKeyMetadata.name}" ist für diese Route nicht freigegeben`,
);
throw new ForbiddenException(
'API-Key ist für diese Route nicht freigegeben',
);
}
const { user } = request;
const requiredPermissions = meta<Permission[]>(PERMISSIONS_KEY);
if (!requiredPermissions) {
if (meta<boolean>(ALLOW_ANY_AUTHENTICATED_KEY)) return !!user;
if (!meta<boolean>(ALLOW_API_KEY_KEY)) {
this.logger.error(
`${route} Route ohne @RequirePermissions/@AllowAnyAuthenticated – abgelehnt`,
);
}
return false;
}
if (!user || !user.permissions) {
return false;
}
const userPermissions = user.permissions as Permission[];
const userPermissions = user.permissions;
if (userPermissions.includes(Permission.MANAGE_ALL)) {
return true;
@@ -0,0 +1,99 @@
import 'reflect-metadata';
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { RequestMethod } from '@nestjs/common';
import {
ALLOW_ANY_AUTHENTICATED_KEY,
ALLOW_API_KEY_KEY,
PERMISSIONS_KEY,
} from './permissions.decorator';
import { IS_PUBLIC_KEY } from './public.decorator';
// Der Agrarmonitor-Connector zieht jsdom (ESM) – für die reine Metadatenprüfung
// genügt ein Platzhalter.
jest.mock('agrarmonitor-connector', () => ({}));
/** Alle *.controller.ts unter src/ (ohne Tests). */
function controllerDateien(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const pfad = join(dir, name);
if (statSync(pfad).isDirectory()) return controllerDateien(pfad);
return name.endsWith('.controller.ts') ? [pfad] : [];
});
}
type Klasse = { name: string; prototype: Record<string, unknown> };
function routen() {
const ergebnis: {
route: string;
meta: (key: string) => unknown;
}[] = [];
for (const datei of controllerDateien(join(__dirname, '..'))) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const modul = require(datei) as Record<string, unknown>;
for (const exp of Object.values(modul)) {
if (typeof exp !== 'function') continue;
const klasse = exp as unknown as Klasse;
const basis = Reflect.getMetadata(PATH_METADATA, klasse) as
| string
| undefined;
if (basis === undefined) continue;
for (const name of Object.getOwnPropertyNames(klasse.prototype)) {
const handler = klasse.prototype[name];
if (typeof handler !== 'function') continue;
const methode: unknown = Reflect.getMetadata(METHOD_METADATA, handler);
if (methode === undefined) continue;
const pfad = String(Reflect.getMetadata(PATH_METADATA, handler));
ergebnis.push({
route: `${RequestMethod[methode as number]} /${basis}/${pfad} (${klasse.name}.${name})`,
meta: (key) =>
(Reflect.getMetadata(key, handler) as unknown) ??
(Reflect.getMetadata(key, klasse) as unknown),
});
}
}
}
return ergebnis;
}
describe('Rechteangabe aller Routen', () => {
const alle = routen();
it('findet die Controller', () => {
expect(alle.length).toBeGreaterThan(100);
});
it('jede Route hat @RequirePermissions, @AllowAnyAuthenticated, @AllowApiKey oder @Public', () => {
const ohne = alle
.filter(
({ meta }) =>
!meta(PERMISSIONS_KEY) &&
!meta(ALLOW_ANY_AUTHENTICATED_KEY) &&
!meta(ALLOW_API_KEY_KEY) &&
!meta(IS_PUBLIC_KEY),
)
.map(({ route }) => route);
expect(ohne).toEqual([]);
});
it('API-Keys sind nur auf den bekannten Agent-/Integrationsrouten erlaubt', () => {
const mitApiKey = alle
.filter(({ meta }) => meta(ALLOW_API_KEY_KEY))
.map(({ route }) => route.split(' (')[0])
.sort();
expect(mitApiKey).toEqual(
[
'GET /api/label-print-agent/events',
'GET /api/label-print-agent/jobs/:id/image',
'GET /api/label-print-agent/jobs/next',
'POST /api/label-print-agent/jobs/:id/error',
'POST /api/label-print-agent/jobs/:id/printed',
'POST /api/paperless/checksum',
'POST /api/paperless/external-upload',
'POST /api/webhook/paperless',
].sort(),
);
});
});
@@ -1,11 +1,13 @@
import { Controller, Post, Request, HttpCode } from '@nestjs/common';
import { DailyDigestService } from './daily-digest.service';
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
@Controller('api/daily-digest')
export class DailyDigestController {
constructor(private readonly dailyDigestService: DailyDigestService) {}
@Post('send-now')
@AllowAnyAuthenticated()
@HttpCode(200)
async sendNow(@Request() req: any) {
const { userId, email, preferredUsername, groups } = req.user;
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from '../paperless/paperless.service';
const FREIGABE_FIELD_ID = 15;
import { FREIGABE_FIELD_ID } from '../paperless/status-felder.util';
@Injectable()
export class FreigabeService {
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Client } from '../database/entities/client.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
@Controller('api/clients')
export class ClientsController {
@@ -15,6 +16,7 @@ export class ClientsController {
) {}
@Get()
@AllowAnyAuthenticated()
async getMyClients(@Request() req: any) {
const userId = req.user.userId;
const mappings = await this.userClientRepo.find({
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { KontonummernService } from './kontonummern.service';
import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
export class CreateKontonummerDto {
correspondentId: number;
@@ -8,7 +9,8 @@ export class CreateKontonummerDto {
}
@Controller('api/kontonummern')
@UseGuards(JwtOrApiKeyGuard)
// Bankverbindungen: nur für die Dokumentbearbeitung (Posteingang, manuell)
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
export class KontonummernController {
constructor(private readonly kontonummernService: KontonummernService) {}
@@ -17,7 +17,7 @@ import {
import type { Response } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { RequirePermissions } from '../auth/permissions.decorator';
import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { LabelPrintAgentService } from './label-print-agent.service';
@@ -57,6 +57,8 @@ export class LabelPrintAgentController {
// Agent: SSE-Stream für neue Druckaufträge
@Sse('events')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
sseEvents(
@Res({ passthrough: true }) res: Response,
): Observable<MessageEvent> {
@@ -68,6 +70,8 @@ export class LabelPrintAgentController {
// Agent: nächsten Job abholen (Polling)
@Get('jobs/next')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) {
const job = await this.service.claimNextJob(agentId ?? 'unknown');
if (!job) {
@@ -87,6 +91,8 @@ export class LabelPrintAgentController {
// Agent: Bild separat abrufen
@Get('jobs/:id/image')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getImage(
@Param('id', ParseIntPipe) id: number,
@Res({ passthrough: true }) res: Response,
@@ -100,6 +106,8 @@ export class LabelPrintAgentController {
// Agent: Druck erfolgreich
@Post('jobs/:id/printed')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
@HttpCode(HttpStatus.OK)
async markPrinted(
@Param('id', ParseIntPipe) id: number,
@@ -115,6 +123,8 @@ export class LabelPrintAgentController {
// Agent: Druckfehler
@Post('jobs/:id/error')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
@HttpCode(HttpStatus.OK)
async markError(
@Param('id', ParseIntPipe) id: number,
@@ -0,0 +1,135 @@
import { ConflictException } from '@nestjs/common';
import { LabelPrintAgentService } from './label-print-agent.service';
import type { LabelPrintJob } from '../database/entities/label-print-job.entity';
import type { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import type { LabelRendererService } from './label-renderer.service';
const TEMPLATE = {
Id: 7,
LabelEnabled: true,
LabelWidthMm: 57,
LabelHeightMm: 32,
LabelInputFields: [{ name: 'kunde', type: 'text' }],
LabelGetUrl: 'https://beleg.example/next?kunde={kunde}',
LabelPrintedUrl: 'https://beleg.example/printed/{number}?kunde={kunde}',
LabelReleaseUrl: 'https://beleg.example/release/{number}',
} as unknown as BarcodeTemplate;
function aufbau(job?: Partial<LabelPrintJob>) {
const gespeichert: Partial<LabelPrintJob>[] = [];
const jobRepo = {
findOne: jest.fn().mockResolvedValue(job ? { ...job } : null),
create: jest.fn((j: Partial<LabelPrintJob>) => j),
save: jest.fn((j: Partial<LabelPrintJob>) => {
gespeichert.push({ ...j });
return Promise.resolve(j);
}),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const templateRepo = { findOne: jest.fn().mockResolvedValue(TEMPLATE) };
const service = new LabelPrintAgentService(
jobRepo as never,
templateRepo as never,
{} as LabelRendererService,
);
return { service, jobRepo, gespeichert };
}
const fetchMock = jest.fn();
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({ text: () => Promise.resolve('2026-0042') });
global.fetch = fetchMock as unknown as typeof fetch;
});
const BASIS = {
Id: 1,
BarcodeTemplateId: 7,
LabelVariables: { number: '2026-0042', kunde: 'Hof' },
};
const gesperrt = { ...BASIS, Status: 'pending' as const, LockedAt: new Date() };
describe('LabelPrintAgentService – Statusübergänge', () => {
it('printed: nur für abgeholte, offene Jobs – ruft dann die Printed-URL auf', async () => {
const { service, jobRepo } = aufbau(gesperrt);
await service.markPrinted(1, 'agent', 'drucker');
expect(jobRepo.update).toHaveBeenCalledWith(
expect.objectContaining({ Id: 1, Status: 'pending' }),
expect.objectContaining({ Status: 'printed' }),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('printed: erneute Meldung ist ein No-op ohne zweiten Callback', async () => {
const { service, jobRepo } = aufbau({ ...BASIS, Status: 'printed' });
await service.markPrinted(1, 'agent', 'drucker');
expect(jobRepo.update).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it('error nach printed wird abgelehnt – keine Freigabe der vergebenen Nummer', async () => {
const { service } = aufbau({ ...BASIS, Status: 'printed' });
await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow(
ConflictException,
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('printed/error für noch nicht abgeholte Jobs werden abgelehnt', async () => {
const offen = { ...BASIS, Status: 'pending' as const, LockedAt: null };
await expect(
aufbau(offen).service.markPrinted(1, 'a', 'd'),
).rejects.toThrow(ConflictException);
await expect(
aufbau(offen).service.markError(1, 'a', 'd', 'x'),
).rejects.toThrow(ConflictException);
});
it('gleichzeitige Meldung: verliert das bedingte Update, gibt es keinen Callback', async () => {
const { service, jobRepo } = aufbau(gesperrt);
jobRepo.update.mockResolvedValue({ affected: 0 });
await expect(service.markError(1, 'a', 'd', 'x')).rejects.toThrow(
ConflictException,
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('error für abgeholte Jobs gibt die Nummer frei', async () => {
const { service } = aufbau(gesperrt);
await service.markError(1, 'a', 'd', 'Papierstau');
expect(fetchMock).toHaveBeenCalledWith(
'https://beleg.example/release/2026-0042',
{ method: 'POST' },
);
});
});
describe('LabelPrintAgentService.createJob – Feldwerte', () => {
it('übernimmt nur Felder der Vorlage – "number" kann nicht vorgegeben werden', async () => {
fetchMock.mockResolvedValue({ text: () => Promise.resolve('') });
const { service, gespeichert } = aufbau();
await service.createJob(7, { kunde: 'Hof', number: '1234', fremd: 'x' });
const vars = gespeichert[0].LabelVariables as Record<string, string>;
expect(vars).toEqual({ kunde: 'Hof', number: '' });
});
it('kodiert Feldwerte in URLs', async () => {
const { service } = aufbau();
await service.createJob(7, { kunde: 'a/../admin?x=1#' });
expect(fetchMock).toHaveBeenCalledWith(
'https://beleg.example/next?kunde=a%2F..%2Fadmin%3Fx%3D1%23',
);
});
it('kodiert auch die Nummer im Printed-Callback', async () => {
const { service } = aufbau({
...gesperrt,
LabelVariables: { number: '../../x', kunde: 'A B' },
});
await service.markPrinted(1, 'a', 'd');
expect(fetchMock).toHaveBeenCalledWith(
'https://beleg.example/printed/..%2F..%2Fx?kunde=A%20B',
{ method: 'POST' },
);
});
});
@@ -3,9 +3,10 @@ import {
Logger,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, IsNull } from 'typeorm';
import { Repository, LessThan, IsNull, Not } from 'typeorm';
import { Subject, Observable } from 'rxjs';
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
@@ -20,17 +21,22 @@ function isSafeUrl(url: string): boolean {
}
}
function applyVars(template: string, vars: Record<string, string>): string {
/**
* Setzt `{name}` bzw. `{name:breite}` in eine URL-Vorlage ein. Die Werte
* stammen teils vom Benutzer bzw. aus einer externen Antwort und werden
* deshalb URL-kodiert – sonst ließe sich Pfad oder Query der Admin-URL ändern.
*/
function applyUrlVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
const varName = key.slice(0, colonIdx);
const width = parseInt(key.slice(colonIdx + 1), 10);
if (!isNaN(width) && varName in vars) {
return vars[varName].padStart(width, '0');
return encodeURIComponent(vars[varName].padStart(width, '0'));
}
}
return vars[key] ?? '';
return encodeURIComponent(vars[key] ?? '');
});
}
@@ -70,13 +76,20 @@ export class LabelPrintAgentService {
'Etikett-Druck für dieses Template nicht aktiviert',
);
// Variablen aufbauen
const vars: Record<string, string> = { ...fieldValues };
// Variablen aufbauen – nur Eingabefelder der Vorlage. Abgeleitete Werte
// wie {number} (aus der GET-URL) darf der Aufrufer nicht vorgeben.
const vars: Record<string, string> = {};
for (const field of template.LabelInputFields ?? []) {
const wert: unknown = fieldValues?.[field.name];
if (typeof wert === 'string' || typeof wert === 'number') {
vars[field.name] = String(wert);
}
}
// Datum-Felder: year/month/day separat ablegen
for (const field of template.LabelInputFields ?? []) {
if (field.type === 'date' && fieldValues[field.name]) {
const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD
if (field.type === 'date' && vars[field.name]) {
const raw = vars[field.name]; // erwartet YYYY-MM-DD
const parts = raw.split('-');
if (parts.length === 3) {
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`; // dd.MM.yyyy
@@ -89,7 +102,7 @@ export class LabelPrintAgentService {
// GET-URL aufrufen → {number}
if (template.LabelGetUrl) {
const url = applyVars(template.LabelGetUrl, vars);
const url = applyUrlVars(template.LabelGetUrl, vars);
if (isSafeUrl(url)) {
try {
const res = await fetch(url);
@@ -170,15 +183,15 @@ export class LabelPrintAgentService {
agentId: string,
printerName: string,
): Promise<void> {
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
if (!job) throw new NotFoundException('Job nicht gefunden');
job.Status = 'printed';
job.PrintedAt = new Date();
job.PrintedByAgent = agentId;
job.PrinterName = printerName;
await this.jobRepo.save(job);
const job = await this.ladeJob(jobId);
// Wiederholte Meldung (z.B. Retry nach Netzfehler): nichts mehr tun.
if (job.Status === 'printed') return;
await this.schliesseJobAb(job, {
Status: 'printed',
PrintedAt: new Date(),
PrintedByAgent: agentId,
PrinterName: printerName,
});
await this.callUrl('PRINTED', job);
}
@@ -188,16 +201,45 @@ export class LabelPrintAgentService {
printerName: string,
errorMessage: string,
): Promise<void> {
const job = await this.ladeJob(jobId);
if (job.Status === 'error') return;
await this.schliesseJobAb(job, {
Status: 'error',
PrintedByAgent: agentId,
PrinterName: printerName,
ErrorMessage: errorMessage,
});
// Gibt die reservierte Nummer frei – darf nur für nie gedruckte Jobs passieren.
await this.callUrl('RELEASE', job);
}
private async ladeJob(jobId: number): Promise<LabelPrintJob> {
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
if (!job) throw new NotFoundException('Job nicht gefunden');
return job;
}
job.Status = 'error';
job.PrintedByAgent = agentId;
job.PrinterName = printerName;
job.ErrorMessage = errorMessage;
await this.jobRepo.save(job);
await this.callUrl('RELEASE', job);
/**
* Abschluss nur für abgeholte, noch offene Jobs – bedingt in der DB, damit
* bei gleichzeitigen Meldungen genau eine gewinnt und nur ein Callback läuft.
*/
private async schliesseJobAb(
job: LabelPrintJob,
aenderung: Partial<LabelPrintJob>,
): Promise<void> {
if (job.Status !== 'pending' || !job.LockedAt) {
throw new ConflictException(
`Job ${job.Id} ist nicht in Bearbeitung (Status: ${job.Status})`,
);
}
const { affected } = await this.jobRepo.update(
{ Id: job.Id, Status: 'pending', LockedAt: Not(IsNull()) },
aenderung,
);
if (!affected) {
throw new ConflictException(`Job ${job.Id} wurde bereits abgeschlossen`);
}
Object.assign(job, aenderung);
}
async renderPreview(
@@ -248,7 +290,7 @@ export class LabelPrintAgentService {
type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
if (!urlTemplate) return;
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
const url = applyUrlVars(urlTemplate, job.LabelVariables ?? {});
if (!isSafeUrl(url)) {
this.logger.warn(
`${type}-URL übersprungen (ungültiges Protokoll): ${url}`,
@@ -19,8 +19,9 @@ import {
Request,
} from '@nestjs/common';
import type { Response } from 'express';
import { RequirePermissions } from '../auth/permissions.decorator';
import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { entferneGeschuetzteStatusfelder } from './status-felder.util';
import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm';
@@ -71,11 +72,14 @@ export class PaperlessController {
}
@Get('steuertags')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getSteuertags() {
return { ids: await this.getSteuertagIds() };
}
@Post('checksum')
@AllowApiKey()
@RequirePermissions(Permission.MANAGE_SETTINGS)
async checksumExists(@Body('checksum') checksum: string) {
const exists = await this.paperlessService.checksumExists(checksum);
return { exists };
@@ -89,6 +93,7 @@ export class PaperlessController {
}
@Get('documents')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getDocuments(
@Query('search') search?: string,
@Query('page') page?: string,
@@ -105,16 +110,19 @@ export class PaperlessController {
}
@Get('documents/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getDocument(@Param('id') id: string) {
return this.paperlessService.getDocument(parseInt(id, 10));
}
@Get('tags')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getTags() {
return this.paperlessService.getTags();
}
@Get('tags/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getTag(@Param('id') id: string) {
// If the service doesn't have getTag(id), I should add it or just fetch all and find
const tags = await this.paperlessService.getTags();
@@ -122,11 +130,18 @@ export class PaperlessController {
}
@Get('document-types')
@RequirePermissions(
Permission.VIEW_INBOX,
Permission.PROCESS_MANUALLY,
Permission.VIEW_FREIGABE,
Permission.VIEW_ZAHLUNG,
)
async getDocumentTypes() {
return this.paperlessService.getDocumentTypes();
}
@Get('custom-fields')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getCustomFields() {
return this.paperlessService.getCustomFields();
}
@@ -138,6 +153,13 @@ export class PaperlessController {
}
@Get('correspondents')
@RequirePermissions(
Permission.VIEW_INBOX,
Permission.PROCESS_MANUALLY,
Permission.VIEW_MAIL,
Permission.VIEW_FREIGABE,
Permission.VIEW_ZAHLUNG,
)
async getCorrespondents(@Query('search') search?: string) {
const params: any = { page_size: 9999 };
if (search) {
@@ -148,6 +170,7 @@ export class PaperlessController {
}
@Get('correspondents/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getCorrespondent(@Param('id') id: string) {
return this.paperlessService.getCorrespondent(parseInt(id, 10));
}
@@ -252,6 +275,7 @@ export class PaperlessController {
}
@Get('requirements/:id')
@RequirePermissions(Permission.VIEW_INBOX, Permission.PROCESS_MANUALLY)
async getRequirements(
@Param('id') id: string,
@Query('Posteingang') posteingang: string,
@@ -347,8 +371,24 @@ export class PaperlessController {
@Put('inbox/:id')
@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 { 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
const oldDocument = await this.paperlessService.getDocument(documentId);
@@ -539,6 +579,7 @@ export class PaperlessController {
}
@Post('external-upload')
@AllowApiKey()
@UseGuards(ApiKeyGuard)
@UseInterceptors(FileInterceptor('document'))
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 { StatsService } from './stats.service';
import { AllowAnyAuthenticated } from '../auth/permissions.decorator';
@Controller('api/stats')
export class StatsController {
constructor(private readonly statsService: StatsService) {}
@Get('counts')
@AllowAnyAuthenticated()
async getCounts(@Request() req: any) {
return this.statsService.getDashboardCounts(req.user?.preferredUsername);
}
@@ -8,7 +8,10 @@ import {
Request,
} from '@nestjs/common';
import { UserSettingsService } from './user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import {
AllowAnyAuthenticated,
RequirePermissions,
} from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/user-settings')
@@ -16,16 +19,19 @@ export class UserSettingsController {
constructor(private readonly userSettingsService: UserSettingsService) {}
@Get()
@AllowAnyAuthenticated()
async getSettings(@Request() req: any) {
return this.userSettingsService.getSettings(req.user.userId);
}
@Put()
@AllowAnyAuthenticated()
async updateSettings(@Request() req: any, @Body() body: any) {
return this.userSettingsService.updateSettings(req.user.userId, body);
}
@Get('senders')
@AllowAnyAuthenticated()
async getSenders(@Request() req: any) {
return this.userSettingsService.getAvailableSenders(req.user.userId);
}
@@ -11,6 +11,8 @@ import {
} from '@nestjs/common';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { WebhookQueueService } from './webhook-queue.service';
import { AllowApiKey, RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
export interface PaperlessWebhookPayload {
doc_url?: string;
@@ -27,6 +29,7 @@ export class WebhookController {
@UseGuards(ApiKeyGuard)
@Post('paperless')
@AllowApiKey()
@HttpCode(HttpStatus.OK)
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
const documentId = this.extractDocumentId(payload);
@@ -59,6 +62,7 @@ export class WebhookController {
* per JWT oder API-Key zugänglich.
*/
@Get('status')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
return this.webhookQueue.getStatus();
}
@@ -3,9 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from '../paperless/paperless.service';
import {
FREIGABE_FIELD_ID,
ZAHLUNG_FIELD_ID,
} from '../paperless/status-felder.util';
const FREIGABE_FIELD_ID = 15;
const ZAHLUNG_FIELD_ID = 16;
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';