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 { 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<ApiKey>,
@@ -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;
}
@@ -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;
}
@@ -66,7 +66,7 @@ const entities = [
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'paperlessadd'),
entities,
synchronize: true,
synchronize: config.get<string>('NODE_ENV') !== 'production',
charset: 'utf8mb4',
}),
}),
@@ -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;
}
@@ -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();
@@ -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(() => {});
}
}
}
@@ -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,
@@ -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, string>): 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) {
@@ -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
@@ -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<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> {
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 ?? '';
}
@@ -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
@@ -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);
}
@@ -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]);
@@ -79,21 +79,22 @@ function ImageWithAuth({ url, token }: { url: string; token: string }) {
const [imgSrc, setImgSrc] = useState<string | null>(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]);