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
Build and Push Multi-Platform Images / build-and-push (push) Successful in 48s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user