Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/server/src/app/watermark/dto/apply-watermark.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down
32 changes: 27 additions & 5 deletions apps/server/src/app/watermark/watermark.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
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)', () => {
let app: INestApplication;

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();
Expand All @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/app/watermark/watermark.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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);
},
}),
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/app/watermark/watermark.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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';
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],
Expand Down
41 changes: 26 additions & 15 deletions apps/server/src/app/watermark/watermark.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -83,26 +93,27 @@ export class WatermarkService {

private async resolveLogo(logoBuffer?: Buffer): Promise<Buffer | null> {
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 };
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 `<?xml version="1.0" encoding="UTF-8"?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<label class="stretched-link" for="mainFileInput" aria-label="Select main image"></label>
</div>
<div class="text-muted small" *ngIf="mainFile">Selected: {{ mainFile.name }}</div>
<div class="alert alert-danger py-1 small mb-0" *ngIf="mainFileError">⚠️ {{ mainFileError }}</div>

<!-- Logo Dropzone -->
<div class="dropzone text-center p-3" appDnd (fileDropped)="onLogoDropped($event)">
Expand All @@ -21,6 +22,7 @@
<label class="stretched-link" for="logoFileInput" aria-label="Select logo"></label>
</div>
<div class="text-muted small" *ngIf="logoFile">Selected: {{ logoFile.name }}</div>
<div class="alert alert-danger py-1 small mb-0" *ngIf="logoFileError">⚠️ {{ logoFileError }}</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -155,14 +157,14 @@
<div class="card-body">
<div class="preview-wrapper">
<div #previewCanvas class="preview-canvas" (pointermove)="onPointerMove($event)" (pointerup)="endDrag($event)">
<img *ngIf="(previewUrl || mainFileUrl) as imgSrc" [src]="imgSrc" class="img-preview img-fluid" alt="Preview" />
<img #previewImg *ngIf="(previewUrl || mainFileUrl) as imgSrc" [src]="imgSrc" class="img-preview img-fluid" alt="Preview" (load)="onImageLoad($event)" />

<!-- Draggable overlay: shows text or logo to position with mouse -->
<ng-container *ngIf="mainFile && form.value.positionMode==='absolute'">
<div
class="overlay-draggable"
[style.left.px]="form.value.positionX"
[style.top.px]="form.value.positionY"
[style.left.px]="(form.value.positionX ?? 0) / scaleX"
[style.top.px]="(form.value.positionY ?? 0) / scaleY"
(pointerdown)="startDrag($event)"
[style.opacity]="form.value.opacity"
[style.transform]="'translate(-50%, -50%) rotate(' + form.value.rotate + 'deg) scale(' + form.value.scale + ')'
Expand Down
Loading
Loading