From aa4c181b0cfaf6f2d05426af1b8d2cef6059dfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Sun, 10 May 2026 22:01:06 +0200 Subject: [PATCH] perf: add database indexes, implement caching, enforce permission guards, and sanitize external URLs --- .../src/auth/api-keys.service.ts | 6 ++-- .../src/auth/permissions.guard.ts | 9 +++--- .../src/database/database.module.ts | 2 +- .../entities/postprocessing-log.entity.ts | 5 +++- .../email-download.controller.ts | 1 + .../src/email/email-import.service.ts | 11 +++---- .../src/email/email.controller.ts | 4 +++ .../label-print-agent.service.ts | 29 +++++++++++++++---- .../src/paperless/paperless.controller.ts | 11 ++++--- .../postprocessing/postprocessing.service.ts | 25 ++++++++++++++-- .../src/settings/settings.controller.ts | 13 ++++----- .../user-settings/user-settings.controller.ts | 3 ++ .../src/components/BarcodePositioner.tsx | 6 +++- .../src/components/PdfSplitViewer.tsx | 9 +++--- 14 files changed, 94 insertions(+), 40 deletions(-) diff --git a/paperless-backend/src/auth/api-keys.service.ts b/paperless-backend/src/auth/api-keys.service.ts index 0e3b30f..3b1cf68 100644 --- a/paperless-backend/src/auth/api-keys.service.ts +++ b/paperless-backend/src/auth/api-keys.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ApiKey } from '../database/entities/api-key.entity'; @@ -6,6 +6,8 @@ import * as crypto from 'crypto'; @Injectable() export class ApiKeysService { + private readonly logger = new Logger(ApiKeysService.name); + constructor( @InjectRepository(ApiKey) private readonly apiKeyRepo: Repository, @@ -50,7 +52,7 @@ export class ApiKeysService { // Update last used timestamp (async, don't wait for it to return response faster) apiKey.lastUsedAt = new Date(); - this.apiKeyRepo.save(apiKey).catch(err => console.error('Error updating lastUsedAt:', err)); + this.apiKeyRepo.save(apiKey).catch(err => this.logger.error('Fehler beim Aktualisieren von lastUsedAt', err)); return apiKey; } diff --git a/paperless-backend/src/auth/permissions.guard.ts b/paperless-backend/src/auth/permissions.guard.ts index c414ea3..5b2b937 100644 --- a/paperless-backend/src/auth/permissions.guard.ts +++ b/paperless-backend/src/auth/permissions.guard.ts @@ -17,11 +17,10 @@ export class PermissionsGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); - - // Let API Key requests bypass the permissions check for now, unless explicitly denied. - // Usually API keys have different scopes, but assuming they act as Admins for automated uploads. - if (user && user.apiKey) { + const request = context.switchToHttp().getRequest(); + const { user } = request; + + if (request.apiKeyMetadata) { return true; } diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index 8c9838b..a3d730b 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -66,7 +66,7 @@ const entities = [ password: config.get('DB_PASSWORD', ''), database: config.get('DB_DATABASE', 'paperlessadd'), entities, - synchronize: true, + synchronize: config.get('NODE_ENV') !== 'production', charset: 'utf8mb4', }), }), diff --git a/paperless-backend/src/database/entities/postprocessing-log.entity.ts b/paperless-backend/src/database/entities/postprocessing-log.entity.ts index 1576af1..03886b1 100644 --- a/paperless-backend/src/database/entities/postprocessing-log.entity.ts +++ b/paperless-backend/src/database/entities/postprocessing-log.entity.ts @@ -1,16 +1,18 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'; @Entity('PostprocessingLogs') export class PostprocessingLog { @PrimaryGeneratedColumn() Id!: number; + @Index() @Column({ type: 'int' }) PostprocessingId!: number; @Column({ type: 'int', nullable: true }) ActionId!: number | null; + @Index() @Column({ type: 'int' }) DocumentId!: number; @@ -20,6 +22,7 @@ export class PostprocessingLog { @Column({ type: 'text', nullable: true }) Message!: string | null; + @Index() @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) CreatedAt!: Date; } diff --git a/paperless-backend/src/email-download/email-download.controller.ts b/paperless-backend/src/email-download/email-download.controller.ts index 151a569..82386e8 100644 --- a/paperless-backend/src/email-download/email-download.controller.ts +++ b/paperless-backend/src/email-download/email-download.controller.ts @@ -11,6 +11,7 @@ export class EmailDownloadController { @Post('fetch') @HttpCode(HttpStatus.OK) + @RequirePermissions(Permission.VIEW_MAIL) async triggerFetch() { this.logger.log('Manueller E-Mail-Abruf wurde ausgelöst.'); await this.emailDownloadService.handleCron(); diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index ec0e0bc..5e3c414 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -44,20 +44,21 @@ export class EmailImportService { const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1); if (!hasPreview && attachment.Content?.Content1) { this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`); + const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`); try { - const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`); await fs.writeFile(tempPdfPath, attachment.Content.Content1); - + const images = await this.pdfService.pdfToImages(tempPdfPath, 400); await this.pageCache.generate(attachment.Id, images); - + attachment.PageCount = images.length; await this.attachmentRepo.save(attachment); - + await this.pdfService.cleanup(images); - await fs.unlink(tempPdfPath).catch(() => {}); } catch (err: any) { this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`); + } finally { + await fs.unlink(tempPdfPath).catch(() => {}); } } } diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index 630189b..d933d86 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -21,6 +21,7 @@ export class EmailController { ) {} @Get() + @RequirePermissions(Permission.VIEW_MAIL) async getEmails( @Query('status') status?: string, @Query('limit') limit?: string, @@ -38,6 +39,7 @@ export class EmailController { } @Get(':id') + @RequirePermissions(Permission.VIEW_MAIL) async getEmail(@Param('id') id: string) { return this.emailRepo.findOneOrFail({ where: { Id: parseInt(id, 10) }, @@ -46,6 +48,7 @@ export class EmailController { } @Get(':id/attachments') + @RequirePermissions(Permission.VIEW_MAIL) async getAttachments(@Param('id') id: string) { return this.attachmentRepo.find({ where: { EmailMessageId: parseInt(id, 10) }, @@ -54,6 +57,7 @@ export class EmailController { } @Get('attachments/:attachmentId/content') + @RequirePermissions(Permission.VIEW_MAIL) async getAttachmentContent( @Param('attachmentId') attachmentId: string, @Res() res: Response, diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts index 78481f4..800ecdf 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.service.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -6,6 +6,15 @@ import { LabelPrintJob } from '../database/entities/label-print-job.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { LabelRendererService } from './label-renderer.service'; +function isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + function applyVars(template: string, vars: Record): string { return template.replace(/\{([^}]+)\}/g, (_, key: string) => { const colonIdx = key.indexOf(':'); @@ -71,12 +80,16 @@ export class LabelPrintAgentService { // GET-URL aufrufen → {number} if (template.LabelGetUrl) { const url = applyVars(template.LabelGetUrl, vars); - try { - const res = await fetch(url); - const text = (await res.text()).trim(); - vars['number'] = text; - } catch (err: any) { - this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`); + if (isSafeUrl(url)) { + try { + const res = await fetch(url); + const text = (await res.text()).trim(); + vars['number'] = text; + } catch (err: any) { + this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`); + } + } else { + this.logger.warn(`GET-URL übersprungen (ungültiges Protokoll): ${url}`); } } @@ -201,6 +214,10 @@ export class LabelPrintAgentService { if (!urlTemplate) return; const url = applyVars(urlTemplate, job.LabelVariables ?? {}); + if (!isSafeUrl(url)) { + this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`); + return; + } try { await fetch(url, { method: 'POST' }); } catch (err: any) { diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index 1f8c8cd..6c57123 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -1,6 +1,5 @@ import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common'; import type { Response } from 'express'; -import { Public } from '../auth/public.decorator'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -46,7 +45,7 @@ export class PaperlessController { ) { const params: any = { page: page || '1', - page_size: pageSize || '20', + page_size: String(Math.min(parseInt(pageSize ?? '20', 10), 500)), }; if (search) { params.query = search; // Global search in Paperless @@ -82,6 +81,7 @@ export class PaperlessController { } @Get('users') + @RequirePermissions(Permission.MANAGE_SETTINGS) async getUsers() { return this.paperlessService.getUsers(); } @@ -142,7 +142,7 @@ export class PaperlessController { })); } - @Public() + @RequirePermissions(Permission.VIEW_INBOX) @Get('inbox/preview/:id') async getInboxPreview(@Param('id') id: string, @Res() res: Response) { try { @@ -151,6 +151,7 @@ export class PaperlessController { 'Content-Type': 'image/png', 'Content-Disposition': `inline; filename="${id}preview.png"`, }); + stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); }); stream.pipe(res); } catch (error) { if (!res.headersSent) { @@ -159,7 +160,7 @@ export class PaperlessController { } } - @Public() + @RequirePermissions(Permission.VIEW_INBOX) @Get('inbox/pdf/:id') async getInboxPdf(@Param('id') id: string, @Res() res: Response) { try { @@ -168,6 +169,7 @@ export class PaperlessController { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${id}.pdf"`, }); + stream.on('error', () => { if (!res.headersSent) res.status(HttpStatus.INTERNAL_SERVER_ERROR).end(); else res.end(); }); stream.pipe(res); } catch (error) { if (!res.headersSent) { @@ -249,6 +251,7 @@ export class PaperlessController { } @Put('inbox/:id') + @RequirePermissions(Permission.VIEW_INBOX) async putInboxDocument(@Param('id') id: string, @Body() body: any) { const documentId = parseInt(id, 10); // Fetch from paperless diff --git a/paperless-backend/src/postprocessing/postprocessing.service.ts b/paperless-backend/src/postprocessing/postprocessing.service.ts index 38a0009..5fe8689 100644 --- a/paperless-backend/src/postprocessing/postprocessing.service.ts +++ b/paperless-backend/src/postprocessing/postprocessing.service.ts @@ -10,10 +10,14 @@ import { MailService } from './mail.service'; import { ExportService } from './export.service'; import axios from 'axios'; +const CACHE_TTL_MS = 5 * 60 * 1000; + @Injectable() export class PostprocessingService { private readonly logger = new Logger(PostprocessingService.name); private readonly errorTagId: number; + private correspondentsCache: { data: any[]; expires: number } | null = null; + private documentTypesCache: { data: any[]; expires: number } | null = null; constructor( @InjectRepository(Postprocessing) private readonly ppRepo: Repository, @@ -228,16 +232,31 @@ export class PostprocessingService { } } + private async getCachedCorrespondents(): Promise { + if (!this.correspondentsCache || Date.now() > this.correspondentsCache.expires) { + const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); + this.correspondentsCache = { data: response.results, expires: Date.now() + CACHE_TTL_MS }; + } + return this.correspondentsCache.data; + } + + private async getCachedDocumentTypes(): Promise { + if (!this.documentTypesCache || Date.now() > this.documentTypesCache.expires) { + const data = await this.paperlessService.getDocumentTypes(); + this.documentTypesCache = { data, expires: Date.now() + CACHE_TTL_MS }; + } + return this.documentTypesCache.data; + } + private async enrichDocWithNames(doc: any): Promise { try { if (doc.correspondent && !doc._correspondentName) { - const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); - const correspondents = response.results; + const correspondents = await this.getCachedCorrespondents(); const c = correspondents.find((x: any) => x.id === doc.correspondent); doc._correspondentName = c?.name ?? ''; } if (doc.document_type && !doc._documentTypeName) { - const docTypes = await this.paperlessService.getDocumentTypes(); + const docTypes = await this.getCachedDocumentTypes(); const dt = docTypes.find((x: any) => x.id === doc.document_type); doc._documentTypeName = dt?.name ?? ''; } diff --git a/paperless-backend/src/settings/settings.controller.ts b/paperless-backend/src/settings/settings.controller.ts index 17de799..c2cea5c 100644 --- a/paperless-backend/src/settings/settings.controller.ts +++ b/paperless-backend/src/settings/settings.controller.ts @@ -327,14 +327,11 @@ export class SettingsController { const settings = await this.corrSettingRepo.find(); const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s])); - for (const pc of paperlessCorrs) { - if (!settingsMap.has(pc.id)) { - const newSetting = this.corrSettingRepo.create({ - CorrespondentId: pc.id, - AgrarmonitorId: null, - }); - await this.corrSettingRepo.save(newSetting); - } + const newSettings = paperlessCorrs + .filter((pc: any) => !settingsMap.has(pc.id)) + .map((pc: any) => this.corrSettingRepo.create({ CorrespondentId: pc.id, AgrarmonitorId: null })); + if (newSettings.length > 0) { + await this.corrSettingRepo.insert(newSettings); } // Re-fetch merged diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index 49d8ee8..c2cb7ba 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common'; import { UserSettingsService } from './user-settings.service'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; @Controller('api/user-settings') export class UserSettingsController { @@ -22,6 +24,7 @@ export class UserSettingsController { @Post('test-smtp') @HttpCode(200) + @RequirePermissions(Permission.MANAGE_SETTINGS) async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) { return this.userSettingsService.testSmtp(body); } diff --git a/paperless-frontend/src/components/BarcodePositioner.tsx b/paperless-frontend/src/components/BarcodePositioner.tsx index 5461437..702caf2 100644 --- a/paperless-frontend/src/components/BarcodePositioner.tsx +++ b/paperless-frontend/src/components/BarcodePositioner.tsx @@ -52,17 +52,20 @@ export default function BarcodePositioner({ // Load first page preview image useEffect(() => { - let objectUrl: string; + let cancelled = false; + let objectUrl: string | undefined; const page = startPage || 1; const url = `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/preview`; getAccessToken().then(token => { + if (cancelled) return; fetch(url, { headers: { Authorization: `Bearer ${token}` } }) .then(res => { if (!res.ok) throw new Error('Not found'); return res.blob(); }) .then(blob => { + if (cancelled) return; objectUrl = URL.createObjectURL(blob); setImgSrc(objectUrl); }) @@ -70,6 +73,7 @@ export default function BarcodePositioner({ }); return () => { + cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [attachmentId, startPage]); diff --git a/paperless-frontend/src/components/PdfSplitViewer.tsx b/paperless-frontend/src/components/PdfSplitViewer.tsx index 541e284..473cdf8 100644 --- a/paperless-frontend/src/components/PdfSplitViewer.tsx +++ b/paperless-frontend/src/components/PdfSplitViewer.tsx @@ -79,21 +79,22 @@ function ImageWithAuth({ url, token }: { url: string; token: string }) { const [imgSrc, setImgSrc] = useState(null); useEffect(() => { - let objectUrl: string; + let cancelled = false; + let objectUrl: string | undefined; fetch(url, { headers: { Authorization: `Bearer ${token}` } }) .then(res => { if (!res.ok) throw new Error('Network response was not ok'); return res.blob(); }) .then(blob => { + if (cancelled) return; objectUrl = URL.createObjectURL(blob); setImgSrc(objectUrl); }) - .catch(err => { - console.error('Error loading image', err); - }); + .catch(() => {}); return () => { + cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [url, token]);