chris 5053cbcf44 refactor: Reorganize project structure and clean up repository
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.
2025-11-24 15:15:35 -05:00

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;