Files
picpeak/backend/src/services/watermarkService.js
T
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
  (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
  getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
  traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
  S3StorageAdapter (used by backupService) mapping it onto the canonical
  interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
  (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
  before the first request.

Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
  storage.put; expose withLocalCopy() helper for S3-mode regeneration
  paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
  storage.putFromFile. Atomic-rename pattern preserved on local; S3
  emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
  routes/v1/events.js POST /events/:id/photos / routes/events.js — every
  upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
  photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
  storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
  via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
  (chokidar can't watch S3); auto-import lands via the S3 prefix walker
  introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
  shipping in the next commit).

Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
  that walks photos.path, thumbnail_path, hero_path, watermark_path and
  events.archive_path/download_zip_path; streams local → S3; sha256
  size-match skip for idempotent re-run; failures CSV.

Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
  + downloads enabled + watermark NOT enabled, /download-all returns a
  302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
  ships in the next commit's UI.

Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
  contract suite running against BOTH LocalFs AND MinIO (18 tests, both
  backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
  parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
  drop the redundant initDb() (001_init handles it) and remove
  schema-drift in configureS3Backup (app_settings has no created_at
  anymore and the unique constraint is on setting_key alone, not
  composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
  mode with managed-uploaded photos) now fall back to managed when
  external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
  auto-skips against local backend; full upload → serve → delete
  round-trip when run against an S3-mode backend.

Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
  and the S3 auto-importer startup. Those features ship in the next two
  commits — co-located here for one bisectable diff per file.

Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
  IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
  documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
  so backend/src/services/storage/ (the new abstraction code) is
  trackable. The runtime ./storage/ data dir stays ignored.

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00

336 lines
10 KiB
JavaScript

const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { getStorage } = require('./storage');
class WatermarkService {
constructor() {
this.cache = new Map();
this.cacheMaxAge = 3600000; // 1 hour in milliseconds
}
/**
* Get watermark settings from database
*/
async getWatermarkSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_watermark_enabled',
'branding_watermark_logo_path',
'branding_watermark_position',
'branding_watermark_opacity',
'branding_watermark_size',
'branding_company_name'
])
.select('setting_key', 'setting_value');
const settingsObj = {};
settings.forEach(setting => {
try {
settingsObj[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
settingsObj[setting.setting_key] = setting.setting_value;
}
});
return {
enabled: settingsObj.branding_watermark_enabled || false,
logoPath: settingsObj.branding_watermark_logo_path || null,
position: settingsObj.branding_watermark_position || 'bottom-right',
opacity: parseInt(settingsObj.branding_watermark_opacity || 50),
size: parseInt(settingsObj.branding_watermark_size || 15),
companyName: settingsObj.branding_company_name || 'Photo Gallery'
};
} catch (error) {
console.error('Error fetching watermark settings:', error);
return null;
}
}
/**
* Calculate position coordinates based on position string
*/
getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) {
const padding = 20;
let left, top;
switch (position) {
case 'top-left':
left = padding;
top = padding;
break;
case 'top-right':
left = imageWidth - watermarkWidth - padding;
top = padding;
break;
case 'bottom-left':
left = padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'bottom-right':
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2);
break;
default:
// Default to bottom-right
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
}
return { left: Math.max(0, left), top: Math.max(0, top) };
}
/**
* Apply watermark to an image
*/
async applyWatermark(imagePath, settings) {
try {
if (!settings || !settings.enabled) {
// Return original image if watermarking is disabled
return await fs.readFile(imagePath);
}
// Check cache first
const cacheKey = `${imagePath}_${JSON.stringify(settings)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
return cached.buffer;
}
// Load the main image
const image = sharp(imagePath);
const metadata = await image.metadata();
let watermarkBuffer;
let watermarkMetadata;
// Try to use logo watermark first
if (settings.logoPath) {
try {
const watermarkImage = sharp(settings.logoPath);
watermarkMetadata = await watermarkImage.metadata();
// Calculate watermark size based on percentage of main image
const scaleFactor = settings.size / 100;
const targetWidth = Math.floor(metadata.width * scaleFactor);
const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width));
// Resize watermark and apply opacity
watermarkBuffer = await watermarkImage
.resize(targetWidth, targetHeight, { fit: 'inside' })
.composite([{
input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]),
raw: {
width: 1,
height: 1,
channels: 4
},
tile: true,
blend: 'dest-in'
}])
.toBuffer();
watermarkMetadata = { width: targetWidth, height: targetHeight };
} catch (error) {
console.error('Error processing watermark logo:', error);
watermarkBuffer = null;
}
}
// If no logo or logo failed, create text watermark
if (!watermarkBuffer) {
const fontSize = Math.max(16, Math.floor(metadata.width * 0.03));
const padding = 10;
// Create SVG text watermark
const svg = `
<svg width="${settings.companyName.length * fontSize * 0.6 + padding * 2}" height="${fontSize + padding * 2}">
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.5" rx="5"/>
<text x="${padding}" y="${fontSize + padding/2}"
font-family="Arial, sans-serif"
font-size="${fontSize}"
fill="white"
opacity="${settings.opacity / 100}">
${settings.companyName}
</text>
</svg>
`;
watermarkBuffer = Buffer.from(svg);
watermarkMetadata = {
width: settings.companyName.length * fontSize * 0.6 + padding * 2,
height: fontSize + padding * 2
};
}
// Calculate position
const position = this.getPositionCoordinates(
metadata.width,
metadata.height,
watermarkMetadata.width,
watermarkMetadata.height,
settings.position
);
// Apply watermark with high quality output to preserve original image quality
let watermarkedImage = image.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}]);
// Preserve original format with high quality settings
const format = metadata.format || 'jpeg';
let watermarkedBuffer;
if (format === 'png') {
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
} else if (format === 'webp') {
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
} else {
// Default to JPEG with maximum quality (100) to prevent recompression
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
}
// Cache the result
this.cache.set(cacheKey, {
buffer: watermarkedBuffer,
timestamp: Date.now()
});
// Clean old cache entries
this.cleanCache();
return watermarkedBuffer;
} catch (error) {
console.error('Error applying watermark:', error);
// Return original image on error
return await fs.readFile(imagePath);
}
}
/**
* Clean old cache entries
*/
cleanCache() {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.cacheMaxAge) {
this.cache.delete(key);
}
}
}
/**
* Clear entire cache
*/
clearCache() {
this.cache.clear();
}
/**
* Get the file extension from a filename
*/
getFileExtension(filename) {
const ext = path.extname(filename).toLowerCase();
// Map common extensions
if (ext === '.jpeg') return '.jpg';
return ext || '.jpg';
}
/**
* Generate watermarked version of a photo and persist it through the
* storage backend. The source must be a local filesystem path because
* sharp doesn't take streams; callers in S3 mode should materialize a
* tmp local copy via imageProcessor.withLocalCopy first.
*
* @param {Object} photo - Photo object with id, filename, and path info
* @param {string} originalPath - Local path to the original image file
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
* @returns {Object} { success, watermarkPath, error }
*/
async generateAndSaveWatermark(photo, originalPath, settings = null) {
try {
if (!settings) {
settings = await this.getWatermarkSettings();
}
if (!settings || !settings.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
try {
await fs.access(originalPath);
} catch {
return { success: false, watermarkPath: null, error: 'Original file not found' };
}
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
const ext = this.getFileExtension(photo.filename);
const outputFilename = `${photo.id}_watermarked${ext}`;
const relativePath = `watermarks/${outputFilename}`;
await getStorage().put(relativePath, watermarkedBuffer, {
contentType: ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg',
});
return {
success: true,
watermarkPath: relativePath,
error: null
};
} catch (error) {
console.error(`Error generating watermark for photo ${photo.id}:`, error);
return {
success: false,
watermarkPath: null,
error: error.message
};
}
}
/**
* Delete a pre-generated watermark file from the storage backend.
* @param {string} watermarkPath - Relative storage key (e.g. "watermarks/123_watermarked.jpg")
* @returns {boolean} - True if a delete was attempted (no-op if missing)
*/
async deleteWatermarkFile(watermarkPath) {
if (!watermarkPath) return false;
try {
await getStorage().delete(watermarkPath);
return true;
} catch (error) {
console.error('Error deleting watermark file:', error);
return false;
}
}
/**
* Create a hash of current watermark settings for change detection
* @returns {string} - Hash string of settings
*/
async getSettingsHash() {
const settings = await this.getWatermarkSettings();
if (!settings) return '';
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
// Simple hash for change detection (not cryptographic)
let hash = 0;
for (let i = 0; i < hashData.length; i++) {
const char = hashData.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
}
}
module.exports = new WatermarkService();