perf: add database indexes, implement caching, enforce permission guards, and sanitize external URLs
Build and Push Multi-Platform Images / build-and-push (push) Successful in 48s

This commit is contained in:
2026-05-10 22:01:06 +02:00
parent 351938aa5c
commit aa4c181b0c
14 changed files with 94 additions and 40 deletions
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ApiKey } from '../database/entities/api-key.entity'; import { ApiKey } from '../database/entities/api-key.entity';
@@ -6,6 +6,8 @@ import * as crypto from 'crypto';
@Injectable() @Injectable()
export class ApiKeysService { export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor( constructor(
@InjectRepository(ApiKey) @InjectRepository(ApiKey)
private readonly apiKeyRepo: Repository<ApiKey>, private readonly apiKeyRepo: Repository<ApiKey>,
@@ -50,7 +52,7 @@ export class ApiKeysService {
// Update last used timestamp (async, don't wait for it to return response faster) // Update last used timestamp (async, don't wait for it to return response faster)
apiKey.lastUsedAt = new Date(); 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; return apiKey;
} }
@@ -17,11 +17,10 @@ export class PermissionsGuard implements CanActivate {
return true; return true;
} }
const { user } = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { user } = request;
// Let API Key requests bypass the permissions check for now, unless explicitly denied. if (request.apiKeyMetadata) {
// Usually API keys have different scopes, but assuming they act as Admins for automated uploads.
if (user && user.apiKey) {
return true; return true;
} }
@@ -66,7 +66,7 @@ const entities = [
password: config.get<string>('DB_PASSWORD', ''), password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'paperlessadd'), database: config.get<string>('DB_DATABASE', 'paperlessadd'),
entities, entities,
synchronize: true, synchronize: config.get<string>('NODE_ENV') !== 'production',
charset: 'utf8mb4', charset: 'utf8mb4',
}), }),
}), }),
@@ -1,16 +1,18 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
@Entity('PostprocessingLogs') @Entity('PostprocessingLogs')
export class PostprocessingLog { export class PostprocessingLog {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
Id!: number; Id!: number;
@Index()
@Column({ type: 'int' }) @Column({ type: 'int' })
PostprocessingId!: number; PostprocessingId!: number;
@Column({ type: 'int', nullable: true }) @Column({ type: 'int', nullable: true })
ActionId!: number | null; ActionId!: number | null;
@Index()
@Column({ type: 'int' }) @Column({ type: 'int' })
DocumentId!: number; DocumentId!: number;
@@ -20,6 +22,7 @@ export class PostprocessingLog {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
Message!: string | null; Message!: string | null;
@Index()
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
CreatedAt!: Date; CreatedAt!: Date;
} }
@@ -11,6 +11,7 @@ export class EmailDownloadController {
@Post('fetch') @Post('fetch')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@RequirePermissions(Permission.VIEW_MAIL)
async triggerFetch() { async triggerFetch() {
this.logger.log('Manueller E-Mail-Abruf wurde ausgelöst.'); this.logger.log('Manueller E-Mail-Abruf wurde ausgelöst.');
await this.emailDownloadService.handleCron(); await this.emailDownloadService.handleCron();
@@ -44,8 +44,8 @@ export class EmailImportService {
const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1); const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1);
if (!hasPreview && attachment.Content?.Content1) { if (!hasPreview && attachment.Content?.Content1) {
this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`); 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 { try {
const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`);
await fs.writeFile(tempPdfPath, attachment.Content.Content1); await fs.writeFile(tempPdfPath, attachment.Content.Content1);
const images = await this.pdfService.pdfToImages(tempPdfPath, 400); const images = await this.pdfService.pdfToImages(tempPdfPath, 400);
@@ -55,9 +55,10 @@ export class EmailImportService {
await this.attachmentRepo.save(attachment); await this.attachmentRepo.save(attachment);
await this.pdfService.cleanup(images); await this.pdfService.cleanup(images);
await fs.unlink(tempPdfPath).catch(() => {});
} catch (err: any) { } catch (err: any) {
this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`); this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`);
} finally {
await fs.unlink(tempPdfPath).catch(() => {});
} }
} }
} }
@@ -21,6 +21,7 @@ export class EmailController {
) {} ) {}
@Get() @Get()
@RequirePermissions(Permission.VIEW_MAIL)
async getEmails( async getEmails(
@Query('status') status?: string, @Query('status') status?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
@@ -38,6 +39,7 @@ export class EmailController {
} }
@Get(':id') @Get(':id')
@RequirePermissions(Permission.VIEW_MAIL)
async getEmail(@Param('id') id: string) { async getEmail(@Param('id') id: string) {
return this.emailRepo.findOneOrFail({ return this.emailRepo.findOneOrFail({
where: { Id: parseInt(id, 10) }, where: { Id: parseInt(id, 10) },
@@ -46,6 +48,7 @@ export class EmailController {
} }
@Get(':id/attachments') @Get(':id/attachments')
@RequirePermissions(Permission.VIEW_MAIL)
async getAttachments(@Param('id') id: string) { async getAttachments(@Param('id') id: string) {
return this.attachmentRepo.find({ return this.attachmentRepo.find({
where: { EmailMessageId: parseInt(id, 10) }, where: { EmailMessageId: parseInt(id, 10) },
@@ -54,6 +57,7 @@ export class EmailController {
} }
@Get('attachments/:attachmentId/content') @Get('attachments/:attachmentId/content')
@RequirePermissions(Permission.VIEW_MAIL)
async getAttachmentContent( async getAttachmentContent(
@Param('attachmentId') attachmentId: string, @Param('attachmentId') attachmentId: string,
@Res() res: Response, @Res() res: Response,
@@ -6,6 +6,15 @@ import { LabelPrintJob } from '../database/entities/label-print-job.entity';
import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
import { LabelRendererService } from './label-renderer.service'; 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, string>): string { function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => { return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':'); const colonIdx = key.indexOf(':');
@@ -71,12 +80,16 @@ export class LabelPrintAgentService {
// GET-URL aufrufen → {number} // GET-URL aufrufen → {number}
if (template.LabelGetUrl) { if (template.LabelGetUrl) {
const url = applyVars(template.LabelGetUrl, vars); const url = applyVars(template.LabelGetUrl, vars);
try { if (isSafeUrl(url)) {
const res = await fetch(url); try {
const text = (await res.text()).trim(); const res = await fetch(url);
vars['number'] = text; const text = (await res.text()).trim();
} catch (err: any) { vars['number'] = text;
this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`); } 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; if (!urlTemplate) return;
const url = applyVars(urlTemplate, job.LabelVariables ?? {}); const url = applyVars(urlTemplate, job.LabelVariables ?? {});
if (!isSafeUrl(url)) {
this.logger.warn(`${type}-URL übersprungen (ungültiges Protokoll): ${url}`);
return;
}
try { try {
await fetch(url, { method: 'POST' }); await fetch(url, { method: 'POST' });
} catch (err: any) { } catch (err: any) {
@@ -1,6 +1,5 @@
import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common'; 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 type { Response } from 'express';
import { Public } from '../auth/public.decorator';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -46,7 +45,7 @@ export class PaperlessController {
) { ) {
const params: any = { const params: any = {
page: page || '1', page: page || '1',
page_size: pageSize || '20', page_size: String(Math.min(parseInt(pageSize ?? '20', 10), 500)),
}; };
if (search) { if (search) {
params.query = search; // Global search in Paperless params.query = search; // Global search in Paperless
@@ -82,6 +81,7 @@ export class PaperlessController {
} }
@Get('users') @Get('users')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getUsers() { async getUsers() {
return this.paperlessService.getUsers(); return this.paperlessService.getUsers();
} }
@@ -142,7 +142,7 @@ export class PaperlessController {
})); }));
} }
@Public() @RequirePermissions(Permission.VIEW_INBOX)
@Get('inbox/preview/:id') @Get('inbox/preview/:id')
async getInboxPreview(@Param('id') id: string, @Res() res: Response) { async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
try { try {
@@ -151,6 +151,7 @@ export class PaperlessController {
'Content-Type': 'image/png', 'Content-Type': 'image/png',
'Content-Disposition': `inline; filename="${id}preview.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); stream.pipe(res);
} catch (error) { } catch (error) {
if (!res.headersSent) { if (!res.headersSent) {
@@ -159,7 +160,7 @@ export class PaperlessController {
} }
} }
@Public() @RequirePermissions(Permission.VIEW_INBOX)
@Get('inbox/pdf/:id') @Get('inbox/pdf/:id')
async getInboxPdf(@Param('id') id: string, @Res() res: Response) { async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
try { try {
@@ -168,6 +169,7 @@ export class PaperlessController {
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${id}.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); stream.pipe(res);
} catch (error) { } catch (error) {
if (!res.headersSent) { if (!res.headersSent) {
@@ -249,6 +251,7 @@ export class PaperlessController {
} }
@Put('inbox/:id') @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) {
const documentId = parseInt(id, 10); const documentId = parseInt(id, 10);
// Fetch from paperless // Fetch from paperless
@@ -10,10 +10,14 @@ import { MailService } from './mail.service';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import axios from 'axios'; import axios from 'axios';
const CACHE_TTL_MS = 5 * 60 * 1000;
@Injectable() @Injectable()
export class PostprocessingService { export class PostprocessingService {
private readonly logger = new Logger(PostprocessingService.name); private readonly logger = new Logger(PostprocessingService.name);
private readonly errorTagId: number; private readonly errorTagId: number;
private correspondentsCache: { data: any[]; expires: number } | null = null;
private documentTypesCache: { data: any[]; expires: number } | null = null;
constructor( constructor(
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>, @InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
@@ -228,16 +232,31 @@ export class PostprocessingService {
} }
} }
private async getCachedCorrespondents(): Promise<any[]> {
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<any[]> {
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<void> { private async enrichDocWithNames(doc: any): Promise<void> {
try { try {
if (doc.correspondent && !doc._correspondentName) { if (doc.correspondent && !doc._correspondentName) {
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 }); const correspondents = await this.getCachedCorrespondents();
const correspondents = response.results;
const c = correspondents.find((x: any) => x.id === doc.correspondent); const c = correspondents.find((x: any) => x.id === doc.correspondent);
doc._correspondentName = c?.name ?? ''; doc._correspondentName = c?.name ?? '';
} }
if (doc.document_type && !doc._documentTypeName) { 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); const dt = docTypes.find((x: any) => x.id === doc.document_type);
doc._documentTypeName = dt?.name ?? ''; doc._documentTypeName = dt?.name ?? '';
} }
@@ -327,14 +327,11 @@ export class SettingsController {
const settings = await this.corrSettingRepo.find(); const settings = await this.corrSettingRepo.find();
const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s])); const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s]));
for (const pc of paperlessCorrs) { const newSettings = paperlessCorrs
if (!settingsMap.has(pc.id)) { .filter((pc: any) => !settingsMap.has(pc.id))
const newSetting = this.corrSettingRepo.create({ .map((pc: any) => this.corrSettingRepo.create({ CorrespondentId: pc.id, AgrarmonitorId: null }));
CorrespondentId: pc.id, if (newSettings.length > 0) {
AgrarmonitorId: null, await this.corrSettingRepo.insert(newSettings);
});
await this.corrSettingRepo.save(newSetting);
}
} }
// Re-fetch merged // Re-fetch merged
@@ -1,5 +1,7 @@
import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, Post, Put, Request } from '@nestjs/common';
import { UserSettingsService } from './user-settings.service'; import { UserSettingsService } from './user-settings.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/user-settings') @Controller('api/user-settings')
export class UserSettingsController { export class UserSettingsController {
@@ -22,6 +24,7 @@ export class UserSettingsController {
@Post('test-smtp') @Post('test-smtp')
@HttpCode(200) @HttpCode(200)
@RequirePermissions(Permission.MANAGE_SETTINGS)
async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) { async testSmtp(@Body() body: { host: string; port: number; secure: boolean; user: string; pass: string }) {
return this.userSettingsService.testSmtp(body); return this.userSettingsService.testSmtp(body);
} }
@@ -52,17 +52,20 @@ export default function BarcodePositioner({
// Load first page preview image // Load first page preview image
useEffect(() => { useEffect(() => {
let objectUrl: string; let cancelled = false;
let objectUrl: string | undefined;
const page = startPage || 1; const page = startPage || 1;
const url = `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/preview`; const url = `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/preview`;
getAccessToken().then(token => { getAccessToken().then(token => {
if (cancelled) return;
fetch(url, { headers: { Authorization: `Bearer ${token}` } }) fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => { .then(res => {
if (!res.ok) throw new Error('Not found'); if (!res.ok) throw new Error('Not found');
return res.blob(); return res.blob();
}) })
.then(blob => { .then(blob => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setImgSrc(objectUrl); setImgSrc(objectUrl);
}) })
@@ -70,6 +73,7 @@ export default function BarcodePositioner({
}); });
return () => { return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [attachmentId, startPage]); }, [attachmentId, startPage]);
@@ -79,21 +79,22 @@ function ImageWithAuth({ url, token }: { url: string; token: string }) {
const [imgSrc, setImgSrc] = useState<string | null>(null); const [imgSrc, setImgSrc] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let objectUrl: string; let cancelled = false;
let objectUrl: string | undefined;
fetch(url, { headers: { Authorization: `Bearer ${token}` } }) fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => { .then(res => {
if (!res.ok) throw new Error('Network response was not ok'); if (!res.ok) throw new Error('Network response was not ok');
return res.blob(); return res.blob();
}) })
.then(blob => { .then(blob => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setImgSrc(objectUrl); setImgSrc(objectUrl);
}) })
.catch(err => { .catch(() => {});
console.error('Error loading image', err);
});
return () => { return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [url, token]); }, [url, token]);