const router = require('express').Router(); const multer = require('multer'); const Photo = require('../models/photo.js'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const fsPromises = require('fs').promises; const { Blob } = require('buffer'); const FormData = global.FormData; const sharp = require('sharp'); const WATERMARK_URL = process.env.WATERMARK_URL || 'http://watermarker:8000/watermark'; const DISABLE_WM = String(process.env.DISABLE_INVISIBLE_WATERMARK || '').toLowerCase() === 'true'; const VARIANTS = { main: { size: 2000, quality: 82, suffix: '' }, medium: { size: 1200, quality: 80, suffix: '-md' }, thumb: { size: 640, quality: 76, suffix: '-sm' }, }; async function applyInvisibleWatermark(buffer, payload, filename) { if (DISABLE_WM) { return buffer; } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const formData = new FormData(); formData.append('payload', payload); formData.append('image', new Blob([buffer], { type: 'image/webp' }), filename || 'image.webp'); const response = await fetch(WATERMARK_URL, { method: 'POST', body: formData, signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { throw new Error(`Watermark service responded with ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch (error) { console.error('Invisible watermarking failed, falling back to visible-only:', error.message); return buffer; } } // Multer setup for file uploads in memory const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); // GET all photos router.route('/').get((req, res) => { Photo.find().sort({ createdAt: -1 }) // Sort by newest first .then(photos => res.json(photos)) .catch(err => res.status(400).json('Error: ' + err)); }); // POST new photo(s) with WebP conversion + duplicate hash checks router.route('/upload').post(upload.array('photos'), async (req, res) => { const files = (req.files && req.files.length) ? req.files : (req.file ? [req.file] : []); if (!files.length) { return res.status(400).json({ success: false, error: 'No file uploaded. Please select at least one file.' }); } const { caption, tags } = req.body; const captionText = typeof caption === 'string' ? caption.trim() : ''; if (!captionText) { return res.status(400).json({ success: false, error: 'Caption is required.' }); } const tagList = typeof tags === 'string' ? tags.split(',').map(tag => tag.trim()).filter(Boolean) : []; const processFile = async (file) => { const hash = crypto.createHash('sha256').update(file.buffer).digest('hex'); let existing = null; try { existing = await Photo.findOne({ hash }); } catch (err) { console.error('Error checking duplicate hash:', err); throw new Error('Server error checking duplicates.'); } if (existing) { return { duplicate: true, hash, existingId: existing._id, filename: existing.filename }; } const originalName = path.parse(file.originalname).name; const baseName = `${Date.now()}-${originalName}`; const makeFilename = (suffix) => `${baseName}${suffix}.webp`; const filename = makeFilename(VARIANTS.main.suffix); const filepath = path.join('uploads', filename); const hiddenColor = [ parseInt(hash.substring(0, 2), 16), parseInt(hash.substring(2, 4), 16), parseInt(hash.substring(4, 6), 16), ]; const mainOverlay = Buffer.from(` Beach Party Balloons `); const cornerOverlay = Buffer.from(` beachpartyballoons.com `); let buffer; try { buffer = await sharp(file.buffer) .rotate() .resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true }) .toColorspace('srgb') .toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 }) .composite([ { input: mainOverlay, gravity: 'southeast' }, { input: cornerOverlay, gravity: 'northwest' }, ]) .toBuffer(); } catch (err) { console.error('Error processing image with sharp:', err); throw new Error('Server error during image processing.'); } try { const payload = `BPB:${hash}`; const stampedBuffer = await applyInvisibleWatermark(buffer, payload, filename); await fsPromises.writeFile(filepath, stampedBuffer); // Create responsive variants from the stamped image to keep overlays consistent const variants = {}; const createVariant = async (key, opts) => { const variantPath = path.join('uploads', makeFilename(opts.suffix)); const resized = await sharp(stampedBuffer) .resize({ width: opts.size, height: opts.size, fit: 'inside', withoutEnlargement: true }) .toFormat('webp', { quality: opts.quality, effort: 5 }) .toBuffer(); await fsPromises.writeFile(variantPath, resized); variants[key] = variantPath; }; await createVariant('medium', VARIANTS.medium); await createVariant('thumb', VARIANTS.thumb); const newPhoto = new Photo({ filename: makeFilename(VARIANTS.main.suffix), path: filepath, variants, caption: captionText, tags: tagList, hash }); try { await newPhoto.save(); return { duplicate: false, photo: newPhoto }; } catch (saveErr) { // Handle race where another upload wrote the same hash between findOne and save if (saveErr.code === 11000) { const dup = await Photo.findOne({ hash }); return { duplicate: true, hash, existingId: dup?._id, filename: dup?.filename }; } console.error('Error saving photo to database:', saveErr); throw new Error('Server error saving photo to database.'); } } catch (err) { console.error('Error finalizing photo:', err); throw err; } }; try { const results = await Promise.all(files.map(processFile)); const uploadedPhotos = results.filter(r => !r.duplicate).map(r => r.photo); const skipped = results.filter(r => r.duplicate).map(r => ({ hash: r.hash, existingId: r.existingId, filename: r.filename })); const uploadedCount = uploadedPhotos.length; const skippedCount = skipped.length; let message = 'Upload complete.'; if (uploadedCount && !skippedCount) { message = uploadedCount > 1 ? 'Photos uploaded and converted successfully!' : 'Photo uploaded and converted successfully!'; } else if (!uploadedCount && skippedCount) { message = 'Skipped upload: files already exist in the gallery.'; } else if (uploadedCount && skippedCount) { message = `Uploaded ${uploadedCount} file${uploadedCount === 1 ? '' : 's'}; skipped ${skippedCount} duplicate${skippedCount === 1 ? '' : 's'}.`; } res.json({ success: true, message, uploaded: uploadedPhotos, skipped }); } catch (error) { res.status(500).json({ success: false, error: error.message || 'Server error during upload.' }); } }); // GET a single photo by ID router.route('/:id').get((req, res) => { Photo.findById(req.params.id) .then(photo => res.json(photo)) .catch(err => res.status(400).json('Error: ' + err)); }); // DELETE a photo by ID router.route('/:id').delete((req, res) => { Photo.findByIdAndDelete(req.params.id) .then(photo => { if (photo) { const pathsToDelete = [photo.path]; if (photo.variants) { if (photo.variants.medium) pathsToDelete.push(photo.variants.medium); if (photo.variants.thumb) pathsToDelete.push(photo.variants.thumb); } Promise.all(pathsToDelete.map(p => fsPromises.unlink(p).catch(() => null))) .then(() => res.json('Photo deleted.')) .catch(err => { console.error('Error deleting photo files:', err); res.json('Photo deleted (files cleanup may be incomplete).'); }); } else { res.status(404).json('Error: Photo not found.'); } }) .catch(err => res.status(400).json('Error: ' + err)); }); // UPDATE a photo by ID router.route('/update/:id').post((req, res) => { Photo.findById(req.params.id) .then(photo => { photo.caption = req.body.caption; photo.tags = req.body.tags.split(',').map(tag => tag.trim()); photo.save() .then(() => res.json('Photo updated!')) .catch(err => res.status(400).json('Error: ' + err)); }) .catch(err => res.status(400).json('Error: ' + err)); }); module.exports = router;