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:
1 parent
de9cb09bcd
commit
3f98dfd98f
21 files changed
+699
-51
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user