This commit reflects an intentional reorganization of the project. - Deletes obsolete root-level files. - Restructures the admin and gallery components. - Tracks previously untracked application modules.
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
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(`
|
|
<svg width="800" height="200" xmlns="http://www.w3.org/2000/svg">
|
|
<style>
|
|
text { font-family: Arial, sans-serif; }
|
|
</style>
|
|
<text x="780" y="150" text-anchor="end" fill="rgba(255,255,255,0.30)" stroke="rgba(0,0,0,0.25)" stroke-width="2" font-size="64">Beach Party Balloons</text>
|
|
</svg>
|
|
`);
|
|
|
|
const cornerOverlay = Buffer.from(`
|
|
<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg">
|
|
<style>
|
|
text { font-family: Arial, sans-serif; }
|
|
</style>
|
|
<text x="12" y="70" fill="rgba(0,0,0,0.22)" font-size="36">beachpartyballoons.com</text>
|
|
<rect x="2" y="2" width="1" height="1" fill="rgba(${hiddenColor[0]}, ${hiddenColor[1]}, ${hiddenColor[2]}, 0.01)" />
|
|
</svg>
|
|
`);
|
|
|
|
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;
|