diff --git a/apps/server/src/app/watermark/dto/apply-watermark.dto.ts b/apps/server/src/app/watermark/dto/apply-watermark.dto.ts index 2cc481f..096cce3 100644 --- a/apps/server/src/app/watermark/dto/apply-watermark.dto.ts +++ b/apps/server/src/app/watermark/dto/apply-watermark.dto.ts @@ -22,7 +22,7 @@ export class ApplyWatermarkDto { mode: Mode; // New absolute position (x,y) in px; when provided, overrides margin-based offset - @ApiPropertyOptional({ description: 'Absolute Position "x,y" in px; überschreibt margin-basiertes Offset.' }) + @ApiPropertyOptional({ description: 'Absolute position "x,y" in px; overrides anchor/margin-based offset.' }) @IsOptional() @IsString() position?: string; @@ -41,7 +41,7 @@ export class ApplyWatermarkDto { @Max(1) opacity?: number = 0.5; - @ApiPropertyOptional({ description: 'Logo-Breite relativ zur Bildbreite (0..1)', default: 0.2 }) + @ApiPropertyOptional({ description: 'Logo width relative to image width (0..1)', default: 0.2 }) @IsOptional() @Type(() => Number) @IsNumber() 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); diff --git a/apps/server/src/app/watermark/watermark.controller.ts b/apps/server/src/app/watermark/watermark.controller.ts index 6132762..41ac2a4 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,7 +20,8 @@ export class WatermarkController { constructor(private readonly service: WatermarkService) {} @Post('apply') - @ApiOperation({ summary: 'Bild hochladen und automatisch mit Wasserzeichen versehen (Logo oder Text).' }) + @TierRateLimit() + @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', @@ -55,15 +57,16 @@ 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 }, { 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 accepted.'), false); cb(null, true); }, }), 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], diff --git a/apps/server/src/app/watermark/watermark.service.ts b/apps/server/src/app/watermark/watermark.service.ts index 5295e47..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); @@ -83,26 +93,27 @@ 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) { - 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 }; } @@ -218,7 +229,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 +252,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 ` 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..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 @@ -12,6 +12,7 @@
Selected: {{ mainFile.name }}
+
⚠️ {{ mainFileError }}
@@ -21,6 +22,7 @@
Selected: {{ logoFile.name }}
+
⚠️ {{ logoFileError }}
@@ -155,14 +157,14 @@
- Preview + Preview