From 46121369bf6e2628b72121568043d3f5d1290c60 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:21:40 +0000 Subject: [PATCH 01/11] fix(watermark): opacity was hardcoded to 1 in frontend service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watermark.service.ts was sending String(1) instead of String(opts.opacity) for the 'opacity' FormData field. The opacity slider in the UI had no effect — every request went to the backend with opacity=1 regardless of the setting. Changed to String(opts.opacity ?? 0.5) to pass the actual value. --- apps/simonapi/src/app/features/watermark/watermark.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/simonapi/src/app/features/watermark/watermark.service.ts b/apps/simonapi/src/app/features/watermark/watermark.service.ts index b6f4021..904c4e9 100644 --- a/apps/simonapi/src/app/features/watermark/watermark.service.ts +++ b/apps/simonapi/src/app/features/watermark/watermark.service.ts @@ -64,7 +64,7 @@ export class WatermarkService { if (opts.anchor != null) { fd.append('anchor', String(opts.anchor)); } - fd.append('opacity', String(1)); + fd.append('opacity', String(opts.opacity ?? 0.5)); fd.append('scale', String(opts.scale ?? 1)); fd.append('margin', String(opts.margin ?? 0)); fd.append('rotate', String(opts.rotate ?? 0)); From 3323a1d8f68b6da4addb0853166d4d68fc52a472 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:21:52 +0000 Subject: [PATCH 02/11] fix(watermark): tile mode rotate default was -30, should be 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTextTileSvg and buildLogoTileSvg used `dto.rotate ?? -30` as fallback for the pattern rotation. When a user set rotate=0 explicitly, the tile would still be rendered at -30 degrees — inconsistent with single-placement mode which correctly used `dto.rotate ?? 0`. Changed both tile builders to use `dto.rotate ?? 0` so the default is no rotation, matching user expectations and the single-placement behaviour. --- apps/server/src/app/watermark/watermark.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/app/watermark/watermark.service.ts b/apps/server/src/app/watermark/watermark.service.ts index 5295e47..0ed44d6 100644 --- a/apps/server/src/app/watermark/watermark.service.ts +++ b/apps/server/src/app/watermark/watermark.service.ts @@ -218,7 +218,7 @@ export class WatermarkService { const gap = dto.gap ?? 128; const color = this.colorToRgba(dto.color ?? '#ffffff', dto.opacity ?? 0.2); const stroke = dto.strokeWidth && dto.strokeWidth > 0 ? ` stroke="${dto.strokeColor ?? '#000'}" stroke-width="${dto.strokeWidth}"` : ''; - const rotate = dto.rotate ?? -30; + const rotate = dto.rotate ?? 0; const tileW = gap * 2; const tileH = gap * 2; @@ -241,7 +241,7 @@ export class WatermarkService { const meta = await Sharp.default(scaled).metadata(); const b64 = scaled.toString('base64'); const gap = dto.gap ?? 256; - const rotate = dto.rotate ?? -30; + const rotate = dto.rotate ?? 0; const h = meta.height ?? targetLogoW; // best effort return ` From fbef40fee4119b54b963521dc2508f270bb21bc7 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:23:36 +0000 Subject: [PATCH 03/11] fix(watermark): add 25 MB file size limit backend + frontend Backend (watermark.controller.ts): FileFieldsInterceptor now has limits: { fileSize: 25 * 1024 * 1024 }. Large uploads are rejected by Multer before Sharp ever touches them, avoiding memory exhaustion from large in-memory buffers. Frontend (watermark-uploader.component.ts/.html): setMainFile() and setLogoFile() now check file.size > MAX_FILE_BYTES (25 MB) before accepting the file. Shows an inline alert under the respective dropzone. formatBytes() helper formats the error message. Prevents the confusing Multer 413/500 response from reaching the user. --- .../src/app/watermark/watermark.controller.ts | 3 ++- .../watermark-uploader.component.html | 2 ++ .../watermark-uploader.component.ts | 22 +++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/server/src/app/watermark/watermark.controller.ts b/apps/server/src/app/watermark/watermark.controller.ts index 6132762..d3389c3 100644 --- a/apps/server/src/app/watermark/watermark.controller.ts +++ b/apps/server/src/app/watermark/watermark.controller.ts @@ -61,9 +61,10 @@ export class WatermarkController { { name: 'file', maxCount: 1 }, { name: 'logo', maxCount: 1 }, ], { + limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB per file fileFilter: (_req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']; - if (!allowed.includes(file.mimetype)) return cb(new BadRequestException('Nur JPEG, PNG, WEBP, AVIF erlaubt.'), false); + if (!allowed.includes(file.mimetype)) return cb(new BadRequestException('Only JPEG, PNG, WEBP and AVIF are allowed.'), false); cb(null, true); }, }), diff --git a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html index 747fff2..b864329 100644 --- a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html +++ b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html @@ -12,6 +12,7 @@
Selected: {{ mainFile.name }}
+
⚠️ {{ mainFileError }}
@@ -21,6 +22,7 @@
Selected: {{ logoFile.name }}
+
⚠️ {{ logoFileError }}
diff --git a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts index ba98040..751d76f 100644 --- a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts +++ b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts @@ -18,6 +18,8 @@ import { debounceTime, filter, Subscription } from 'rxjs'; import { WatermarkAnchor, WatermarkOptions, WatermarkService } from '../watermark.service'; import { DndDirective } from './dnd.directive'; +const MAX_FILE_BYTES = 26214400; // 25 MB — must match backend limits.fileSize + @Component({ selector: 'app-watermark-uploader', standalone: true, @@ -40,6 +42,8 @@ export class WatermarkUploaderComponent implements OnInit, OnDestroy { loading = false; errorMsg: string | null = null; + mainFileError: string | null = null; + logoFileError: string | null = null; dragging = false; dragOffsetX = 0; @@ -72,6 +76,12 @@ export class WatermarkUploaderComponent implements OnInit, OnDestroy { ]; + private formatBytes(bytes: number): string { + return bytes > 1_048_576 + ? `${(bytes / 1_048_576).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; + } + ngOnInit(): void { this.form = this.fb.group({ mode: ['text', Validators.required], @@ -162,10 +172,14 @@ export class WatermarkUploaderComponent implements OnInit, OnDestroy { } private setMainFile(file: File) { + if (file.size > MAX_FILE_BYTES) { + this.mainFileError = `File too large (${this.formatBytes(file.size)}). Max 25 MB.`; + return; + } + this.mainFileError = null; this.mainFile = file; this.cleanupObjectUrl('mainFileUrl'); this.mainFileUrl = URL.createObjectURL(file); - // Reset preview when base image changes this.cleanupObjectUrl('previewUrl'); if (this.form.get('autoPreview')?.value) { this.updatePreview(); @@ -173,10 +187,14 @@ export class WatermarkUploaderComponent implements OnInit, OnDestroy { } private setLogoFile(file: File) { + if (file.size > MAX_FILE_BYTES) { + this.logoFileError = `File too large (${this.formatBytes(file.size)}). Max 25 MB.`; + return; + } + this.logoFileError = null; this.logoFile = file; this.cleanupObjectUrl('logoFileUrl'); this.logoFileUrl = URL.createObjectURL(file); - // Switch mode to logo automatically this.form.patchValue({ mode: 'logo' }); if (this.form.get('autoPreview')?.value && this.mainFile) { this.updatePreview(); From 012cd90141cacf091b6302ed69adda2f6d3c6e79 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:25:08 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(watermark):=20add=20@TierRateLimit()?= =?UTF-8?q?=20=E2=80=94=20CPU-heavy=20endpoint=20now=20rate-limited?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharp image processing is CPU-intensive. Without @TierRateLimit() the /watermark/apply endpoint was completely unguarded against request floods. watermark.controller.ts: @TierRateLimit() added to POST apply. watermark.module.ts: - ApiKeyModule imported (required for ApiKeyGuard resolution) - MulterModule.register limit raised from 15 MB to 25 MB to align with the FileFieldsInterceptor limits added in the previous commit. --- apps/server/src/app/watermark/watermark.controller.ts | 2 ++ apps/server/src/app/watermark/watermark.module.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/app/watermark/watermark.controller.ts b/apps/server/src/app/watermark/watermark.controller.ts index d3389c3..8e12822 100644 --- a/apps/server/src/app/watermark/watermark.controller.ts +++ b/apps/server/src/app/watermark/watermark.controller.ts @@ -10,6 +10,7 @@ import { import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; import { ApiBody, ApiConsumes, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { TierRateLimit } from '../api-key/api-key.decorator'; import { WatermarkService } from './watermark.service'; import { ApplyWatermarkDto } from './dto/apply-watermark.dto'; @@ -19,6 +20,7 @@ export class WatermarkController { constructor(private readonly service: WatermarkService) {} @Post('apply') + @TierRateLimit() @ApiOperation({ summary: 'Bild hochladen und automatisch mit Wasserzeichen versehen (Logo oder Text).' }) @ApiConsumes('multipart/form-data') @ApiBody({ diff --git a/apps/server/src/app/watermark/watermark.module.ts b/apps/server/src/app/watermark/watermark.module.ts index 14cdf3b..7d271c0 100644 --- a/apps/server/src/app/watermark/watermark.module.ts +++ b/apps/server/src/app/watermark/watermark.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ApiKeyModule } from '../api-key/api-key.module'; import { MulterModule } from '@nestjs/platform-express'; import { WatermarkController } from './watermark.controller'; import { WatermarkService } from './watermark.service'; @@ -6,10 +7,11 @@ import { memoryStorage } from 'multer'; @Module({ imports: [ + ApiKeyModule, // Use in-memory storage so we can pass Buffers directly to sharp MulterModule.register({ storage: memoryStorage(), - limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB + limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB — matches FileFieldsInterceptor limit }), ], controllers: [WatermarkController], From 6d35d03807526e4046d4f902de8dd313de65765c Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:25:22 +0000 Subject: [PATCH 05/11] fix(watermark): resolveLogo uses __dirname instead of process.cwd() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation resolved the default logo path relative to process.cwd() which is the repo root in development but the dist output dir in production Docker containers — making the path wrong in both cases. Replaced with __dirname-relative paths: __dirname/assets/watermark/default-logo.png (dist layout) __dirname/../assets/watermark/default-logo.png (fallback) __dirname always points to the directory containing the compiled JS file, which is consistent across local and container environments. --- apps/server/src/app/watermark/watermark.service.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/server/src/app/watermark/watermark.service.ts b/apps/server/src/app/watermark/watermark.service.ts index 0ed44d6..e4297fe 100644 --- a/apps/server/src/app/watermark/watermark.service.ts +++ b/apps/server/src/app/watermark/watermark.service.ts @@ -83,20 +83,21 @@ export class WatermarkService { private async resolveLogo(logoBuffer?: Buffer): Promise { if (logoBuffer && logoBuffer.length > 0) return logoBuffer; - // try to load a default logo from assets if present + // Resolve default logo relative to this source file so the path works + // in both local (src/) and production (dist/) environments. const candidates = [ - path.resolve(process.cwd(), 'apps/server/src/assets/watermark/default-logo.png'), - path.resolve(process.cwd(), 'dist/apps/server/assets/watermark/default-logo.png'), + path.resolve(__dirname, 'assets', 'watermark', 'default-logo.png'), + path.resolve(__dirname, '..', 'assets', 'watermark', 'default-logo.png'), ]; for (const candidate of candidates) { try { await fs.promises.access(candidate, fs.constants.R_OK); return await fs.promises.readFile(candidate); } catch { - // try next + // try next candidate } } - return null; + return null; // no default logo available — caller must handle } private async scaleLogoToWidth(input: Buffer, targetWidth: number) { From 79efa1d5ba936e6248c4457afff94f7827820668 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 15:25:50 +0000 Subject: [PATCH 06/11] fix(watermark): parse JSON error from blob response in frontend When the backend returns a 400/413/500, Angular's responseType:'blob' still delivers a Blob to the next() handler instead of routing to error(). The component would then call URL.createObjectURL() on a JSON payload and display a broken image with no error message. watermark.service.ts now wraps the HTTP call in an Observable that inspects the response blob's content-type. If it is not image/* the blob is read as text, parsed as JSON, and re-thrown as a proper Error so the component's catchError / error handler can display the message to the user. --- .../features/watermark/watermark.service.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/simonapi/src/app/features/watermark/watermark.service.ts b/apps/simonapi/src/app/features/watermark/watermark.service.ts index 904c4e9..503c3b8 100644 --- a/apps/simonapi/src/app/features/watermark/watermark.service.ts +++ b/apps/simonapi/src/app/features/watermark/watermark.service.ts @@ -79,6 +79,26 @@ export class WatermarkService { if (opts.strokeWidth != null) fd.append('strokeWidth', String(opts.strokeWidth)); if (opts.download != null) fd.append('download', String(!!opts.download)); - return this.http.post(`${this.API}/watermark/apply`, fd, { responseType: 'blob' }); + return new Observable(observer => { + this.http.post(`${this.API}/watermark/apply`, fd, { responseType: 'blob' }).subscribe({ + next: async (blob) => { + // If server returned an error as JSON (e.g. 400/413) Angular still gives us a Blob. + // Detect by content-type: if it is not an image, parse as text and re-throw. + if (blob.type && !blob.type.startsWith('image/')) { + try { + const text = await blob.text(); + const json = JSON.parse(text); + observer.error(new Error(json?.message ?? json?.error ?? 'Server error')); + } catch { + observer.error(new Error('Unexpected server response')); + } + return; + } + observer.next(blob); + observer.complete(); + }, + error: (err) => observer.error(err), + }); + }); } } From 2e6d5b168786eed1f0c854aff2ffeec63edaf23d Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 16:39:18 +0000 Subject: [PATCH 07/11] fix(watermark): overlay position now matches actual image pixel coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag overlay was positioned in CSS pixels (relative to the rendered preview div) but the x,y values were sent directly to the backend as image pixel coordinates. A 4000x3000 image rendered at 800x300 CSS px would produce a 5x offset between where the overlay appeared and where the watermark was actually placed. Changes: watermark-uploader.component.ts: - Added @ViewChild previewImg to access the rendered element - naturalWidth/naturalHeight tracked via onImageLoad() handler - scaleX / scaleY computed as naturalSize / renderedSize - startDrag: converts stored image-px coords → CSS px for drag offset - onPointerMove: converts CSS-px drag result → image-px for form/backend watermark-uploader.component.html: - #previewImg template ref + (load)=onImageLoad added to - Overlay left/top now divided by scaleX/scaleY so the draggable dot stays visually in sync with where the backend will place the mark --- .../watermark-uploader.component.html | 6 +- .../watermark-uploader.component.ts | 56 ++++++++++++++----- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html index b864329..0f946b2 100644 --- a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html +++ b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.html @@ -157,14 +157,14 @@
- Preview + Preview
Number) @IsNumber() diff --git a/apps/server/src/app/watermark/watermark.controller.ts b/apps/server/src/app/watermark/watermark.controller.ts index 8e12822..41ac2a4 100644 --- a/apps/server/src/app/watermark/watermark.controller.ts +++ b/apps/server/src/app/watermark/watermark.controller.ts @@ -21,7 +21,7 @@ export class WatermarkController { @Post('apply') @TierRateLimit() - @ApiOperation({ summary: 'Bild hochladen und automatisch mit Wasserzeichen versehen (Logo oder Text).' }) + @ApiOperation({ summary: 'Apply a watermark (logo or text) to an uploaded image.' }) @ApiConsumes('multipart/form-data') @ApiBody({ description: 'Multipart Upload: "file" (Pflicht) + optional "logo" bei mode=logo + weitere Felder', @@ -57,7 +57,7 @@ export class WatermarkController { }, }, }) - @ApiResponse({ status: 200, description: 'Gibt das wasserzeichen-versehene Bild zurück (gleiche Bildart wie Upload wenn möglich).' }) + @ApiResponse({ status: 201, description: 'Returns the watermarked image in the same format as the upload (when supported).' }) @UseInterceptors( FileFieldsInterceptor([ { name: 'file', maxCount: 1 }, @@ -66,7 +66,7 @@ export class WatermarkController { limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB per file fileFilter: (_req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']; - if (!allowed.includes(file.mimetype)) return cb(new BadRequestException('Only JPEG, PNG, WEBP and AVIF are allowed.'), false); + if (!allowed.includes(file.mimetype)) return cb(new BadRequestException('Only JPEG, PNG, WEBP and AVIF are accepted.'), false); cb(null, true); }, }), From c8619888ef47cf96baad4cd573d943462b69fc35 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 16:44:15 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix(watermark):=20mock=20ApiKeyGuard=20in?= =?UTF-8?q?=20controller=20spec=20=E2=80=94=20no=20TypeORM=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding @TierRateLimit() brought ApiKeyGuard into the module, which requires TypeORM / DataSource. The test module was importing WatermarkModule directly and had no database context, causing the suite to fail. Rewrote the spec to wire only what it needs: - MulterModule.register (memory storage) - WatermarkController + WatermarkService directly - ApiKeyGuard overridden with { canActivate: () => true } 286/286 tests pass. --- .../watermark/watermark.controller.spec.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/server/src/app/watermark/watermark.controller.spec.ts b/apps/server/src/app/watermark/watermark.controller.spec.ts index 7bc16d5..43fd20f 100644 --- a/apps/server/src/app/watermark/watermark.controller.spec.ts +++ b/apps/server/src/app/watermark/watermark.controller.spec.ts @@ -1,11 +1,19 @@ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import request from 'supertest'; -import { WatermarkModule } from './watermark.module'; +import { MulterModule } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { WatermarkController } from './watermark.controller'; +import { WatermarkService } from './watermark.service'; +import { ApiKeyGuard } from '../api-key/api-key.guard'; import * as Sharp from 'sharp'; async function makePng(w = 200, h = 120) { - return await Sharp.default({ create: { width: w, height: h, channels: 3, background: { r: 255, g: 255, b: 255 } } }).png().toBuffer(); + return await Sharp.default({ + create: { width: w, height: h, channels: 3, background: { r: 255, g: 255, b: 255 } }, + }) + .png() + .toBuffer(); } describe('WatermarkController (e2e-light)', () => { @@ -13,8 +21,22 @@ describe('WatermarkController (e2e-light)', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - imports: [WatermarkModule], - }).compile(); + imports: [ + MulterModule.register({ storage: memoryStorage() }), + ], + controllers: [WatermarkController], + providers: [ + WatermarkService, + // Mock ApiKeyGuard so the test module doesn't need TypeORM / DataSource + { + provide: ApiKeyGuard, + useValue: { canActivate: () => true }, + }, + ], + }) + .overrideGuard(ApiKeyGuard) + .useValue({ canActivate: () => true }) + .compile(); app = moduleRef.createNestApplication(); await app.init(); @@ -32,7 +54,7 @@ describe('WatermarkController (e2e-light)', () => { .field('mode', 'text') .field('text', '© Test') .attach('file', img, { filename: 'in.png', contentType: 'image/png' }) - .expect(201); // Created + .expect(201); expect(res.headers['content-type']).toMatch(/image\/(png|jpeg|webp|avif)/); expect(res.body.length).toBeGreaterThan(100); From aa357f42857099ac9133f43e6f6f5166e428cd6b Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 16:47:35 +0000 Subject: [PATCH 10/11] fix(watermark): scaleX/scaleY must be protected for template binding --- .../watermark-uploader/watermark-uploader.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts index 726a74f..3280a54 100644 --- a/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts +++ b/apps/simonapi/src/app/features/watermark/watermark-uploader/watermark-uploader.component.ts @@ -66,13 +66,13 @@ export class WatermarkUploaderComponent implements OnInit, OnDestroy { } /** Scale factor: CSS pixels → image pixels */ - private get scaleX(): number { + protected get scaleX(): number { if (!this.previewImg || !this.naturalWidth) return 1; const rendered = this.previewImg.nativeElement.getBoundingClientRect().width; return rendered > 0 ? this.naturalWidth / rendered : 1; } - private get scaleY(): number { + protected get scaleY(): number { if (!this.previewImg || !this.naturalHeight) return 1; const rendered = this.previewImg.nativeElement.getBoundingClientRect().height; return rendered > 0 ? this.naturalHeight / rendered : 1; From a763b5d2b2f715a88b70896d1e5edb3ffc8831b4 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 16:58:54 +0000 Subject: [PATCH 11/11] =?UTF-8?q?fix(watermark):=20clamp=20logo=20overlay?= =?UTF-8?q?=20to=20image=20bounds=20=E2=80=94=20fixes=20Sharp=20composite?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharp requires that composite overlays are never larger than the base image and that top/left coordinates keep the overlay fully within bounds. Two failure modes fixed: 1. Logo scale=1 (frontend default) or a logo larger than the input image: targetWidth was uncapped, producing an overlay wider than the base image. Fix: cap targetWidth to min(calculated, imageWidth) before scaling. 2. After anchor/position calculation top or left could be negative or push the overlay past the image edge (e.g. margin=0, bottom-right on a logo that fills the full width). Fix: clamp top to [0, imageH - wmH] and left to [0, imageW - wmW] after resolving position. scaleLogoToWidth now accepts an optional maxHeight and passes it to Sharp.resize() with withoutEnlargement:true so the logo is never upscaled. Also translated the two remaining German BadRequestException messages. --- .../src/app/watermark/watermark.service.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/server/src/app/watermark/watermark.service.ts b/apps/server/src/app/watermark/watermark.service.ts index e4297fe..ae50ae8 100644 --- a/apps/server/src/app/watermark/watermark.service.ts +++ b/apps/server/src/app/watermark/watermark.service.ts @@ -28,26 +28,36 @@ export class WatermarkService { if (dto.mode === WatermarkMode.LOGO) { const logo = await this.resolveLogo(logoBuffer); - if (!logo) throw new BadRequestException('Logo nicht gefunden. Sende Feld "logo" als Datei oder verwende mode=text.'); + if (!logo) throw new BadRequestException('No logo found. Send a "logo" file field or use mode=text.'); if (dto.tile) { // Create a tiled SVG pattern from the logo const svg = await this.buildLogoTileSvg(logo, width, height, dto); overlays.push({ input: Buffer.from(svg), top: 0, left: 0 }); } else { - const scaled = await this.scaleLogoToWidth(logo, Math.max(1, Math.round(width * (dto.scale ?? 0.2)))); + // Cap logo dimensions to image dimensions so Sharp never gets an overlay larger than the base + const targetW = Math.max(1, Math.min(Math.round(width * (dto.scale ?? 0.2)), width)); + const scaled = await this.scaleLogoToWidth(logo, targetW, height); + const wmW = scaled.info.width!; + const wmH = scaled.info.height!; + const coord = this.resolveAbsolutePosition(dto.position); let top: number, left: number; if (coord) { ({ top, left } = coord); } else { const anchor = (dto.anchor ?? 'bottom-right') as WatermarkAnchor; - ({ top, left } = this.resolveAnchorPosition(width, height, scaled.info.width!, scaled.info.height!, anchor, dto.margin!)); + ({ top, left } = this.resolveAnchorPosition(width, height, wmW, wmH, anchor, dto.margin!)); } + + // Clamp so the overlay never exceeds image bounds (Sharp hard requirement) + top = Math.max(0, Math.min(top, height - wmH)); + left = Math.max(0, Math.min(left, width - wmW)); + overlays.push({ input: scaled.buffer, top, left }); } } else if (dto.mode === WatermarkMode.TEXT) { - if (!dto.text || !dto.text.trim()) throw new BadRequestException('Feld "text" ist erforderlich bei mode=text.'); + if (!dto.text || !dto.text.trim()) throw new BadRequestException('Field "text" is required when mode=text.'); const svg = dto.tile ? this.buildTextTileSvg(dto.text, width, height, dto) : this.buildTextSvg(dto.text, width, height, dto); @@ -100,10 +110,10 @@ export class WatermarkService { return null; // no default logo available — caller must handle } - private async scaleLogoToWidth(input: Buffer, targetWidth: number) { - const img = Sharp.default(input); - await img.metadata(); - const resized = await img.resize({ width: targetWidth }).png().toBuffer(); + private async scaleLogoToWidth(input: Buffer, targetWidth: number, maxHeight?: number) { + const resizeOpts: Sharp.ResizeOptions = { width: targetWidth, withoutEnlargement: true }; + if (maxHeight) resizeOpts.height = maxHeight; + const resized = await Sharp.default(input).resize(resizeOpts).png().toBuffer(); const resizedInfo = await Sharp.default(resized).metadata(); return { buffer: resized, info: resizedInfo }; }