Fix language setting not being saved to database on admin settings page

- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 09:49:45 +02:00
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
+8
View File
@@ -0,0 +1,8 @@
const path = require('path');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
module.exports = {
getStoragePath
};
+29
View File
@@ -0,0 +1,29 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = event;
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
module.exports = {
verifyGalleryAccess
};
+49 -11
View File
@@ -1,13 +1,45 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
async function photoAuth(req, res, next) {
try {
const eventSlug = req.path.split('/')[1];
// First check for JWT token (from gallery access)
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if it's a gallery token for this event
if (decoded.type === 'gallery' && decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
if (event) {
req.event = event;
return next();
}
}
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
return next();
}
}
} catch (err) {
// Token invalid, fall through to password check
}
}
// Check for password header (legacy support)
const password = req.headers['x-gallery-password'];
if (!password) {
return res.status(401).json({ error: 'Password required' });
if (!password && !authHeader) {
return res.status(401).json({ error: 'Authentication required' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
@@ -15,20 +47,26 @@ async function photoAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid password' });
if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid password' });
}
} else {
// No valid authentication
return res.status(401).json({ error: 'Invalid authentication' });
}
req.event = event;
next();
} catch (error) {
console.error('Photo auth error:', error);
res.status(500).json({ error: 'Authentication error' });
}
}
+4
View File
@@ -8,6 +8,8 @@ const emailRoutes = require('./adminEmail');
const settingsRoutes = require('./adminSettings');
const eventsRoutes = require('./adminEvents');
const photosRoutes = require('./adminPhotos');
const categoriesRoutes = require('./adminCategories');
const cmsRoutes = require('./adminCMS');
// Mount sub-routers
router.use('/dashboard', dashboardRoutes);
@@ -16,5 +18,7 @@ router.use('/email', emailRoutes);
router.use('/settings', settingsRoutes);
router.use('/events', eventsRoutes);
router.use('/events', photosRoutes);
router.use('/categories', categoriesRoutes);
router.use('/cms', cmsRoutes);
module.exports = router;
+83
View File
@@ -0,0 +1,83 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all CMS pages
router.get('/pages', adminAuth, async (req, res) => {
try {
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages);
} catch (error) {
console.error('Error fetching CMS pages:', error);
res.status(500).json({ error: 'Failed to fetch pages' });
}
});
// Get a single CMS page
router.get('/pages/:slug', adminAuth, async (req, res) => {
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
res.json(page);
} catch (error) {
console.error('Error fetching CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
// Update a CMS page
router.put('/pages/:slug', adminAuth, [
body('title_en').optional().isString(),
body('title_de').optional().isString(),
body('content_en').optional().isString(),
body('content_de').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { title_en, title_de, content_en, content_de } = req.body;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
// Update the page
await db('cms_pages')
.where('slug', slug)
.update({
title_en,
title_de,
content_en,
content_de,
updated_at: new Date()
});
const updated = await db('cms_pages').where('slug', slug).first();
// Log activity
await logActivity('cms_page_updated',
{ page: slug },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating CMS page:', error);
res.status(500).json({ error: 'Failed to update page' });
}
});
module.exports = router;
+182
View File
@@ -0,0 +1,182 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all global categories
router.get('/global', adminAuth, async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', true)
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
console.error('Error fetching event categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// Create a new category
router.post('/', adminAuth, [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
body('event_id').optional().isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, slug, is_global = true, event_id = null } = req.body;
// Generate slug if not provided
const categorySlug = slug || name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
// Check if slug already exists for this scope
const existing = await db('photo_categories')
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', true);
} else {
this.where('event_id', event_id);
}
})
.first();
if (existing) {
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Create category
const [categoryId] = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id
});
const category = await db('photo_categories').where('id', categoryId).first();
// Log activity
await logActivity('category_created',
{ categoryName: name, isGlobal: is_global },
event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(category);
} catch (error) {
console.error('Error creating category:', error);
res.status(500).json({ error: 'Failed to create category' });
}
});
// Update a category
router.put('/:id', adminAuth, [
body('name').notEmpty().withMessage('Category name is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
await db('photo_categories')
.where('id', id)
.update({
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
res.status(500).json({ error: 'Failed to update category' });
}
});
// Delete a category
router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// Check if category has photos
const photoCount = await db('photos').where('category_id', id).count('id as count').first();
if (photoCount.count > 0) {
return res.status(400).json({
error: 'Cannot delete category with photos. Please reassign photos first.'
});
}
await db('photo_categories').where('id', id).delete();
// Log activity
await logActivity('category_deleted',
{ categoryName: category.name },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Category deleted successfully' });
} catch (error) {
console.error('Error deleting category:', error);
res.status(500).json({ error: 'Failed to delete category' });
}
});
module.exports = router;
+1 -1
View File
@@ -34,7 +34,7 @@ router.post('/', adminAuth, [
admin_email,
password,
welcome_message = '',
color_theme = 'default',
color_theme = null,
expiration_days = 30
} = req.body;
+109 -22
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { generateThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const router = express.Router();
// Get storage path from environment or default
@@ -14,7 +15,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const { eventId } = req.params;
const { type = 'individual' } = req.body;
try {
// Get event details
@@ -23,9 +23,11 @@ const storage = multer.diskStorage({
return cb(new Error('Event not found'));
}
// Create destination path
const photoType = type === 'collage' ? 'collages' : 'individual';
const destPath = path.join(getStoragePath(), 'events/active', event.slug, photoType);
// Store event in request for use in filename generation
req.eventData = event;
// Create destination path - now just event folder, no type subfolder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
// Ensure directory exists
await fs.mkdir(destPath, { recursive: true });
@@ -35,12 +37,14 @@ const storage = multer.diskStorage({
cb(error);
}
},
filename: (req, file, cb) => {
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
const name = path.basename(file.originalname, ext);
cb(null, `${name}-${uniqueSuffix}${ext}`);
filename: async (req, file, cb) => {
try {
// Use temporary filename for now, will rename after getting category info
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
cb(null, tempName);
} catch (error) {
cb(error);
}
}
});
@@ -67,7 +71,12 @@ const upload = multer({
router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => {
try {
const { eventId } = req.params;
const { type = 'individual' } = req.body;
const { category_id } = req.body;
console.log('Upload request received:');
console.log('Body:', req.body);
console.log('Files:', req.files ? req.files.length : 'none');
console.log('Headers:', req.headers);
// Verify event exists and admin has access
const event = await db('events').where({ id: eventId }).first();
@@ -76,40 +85,103 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
}
if (!req.files || req.files.length === 0) {
console.log('No files in request. req.files:', req.files);
return res.status(400).json({ error: 'No files uploaded' });
}
// Get category details if provided
let category = null;
if (category_id) {
category = await db('photo_categories').where({ id: category_id }).first();
if (!category) {
return res.status(400).json({ error: 'Invalid category' });
}
}
const uploadedPhotos = [];
// Process each uploaded file
for (const file of req.files) {
let trx;
try {
// Generate thumbnail
// Start transaction for atomic counter update
trx = await db.transaction();
// Get and increment the counter for this category
let counter = 1;
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: category_id })
.forUpdate()
.first();
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: category_id })
.update({ photo_counter: counter });
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Update file object
file.filename = newFilename;
file.path = newPath;
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath ? path.relative(path.join(storagePath, 'events/active'), thumbnailPath) : null;
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
const [photoId] = await db('photos').insert({
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: type === 'collage' ? 'collage' : 'individual',
category_id: category_id || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
type
category_id: category_id || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
if (trx) await trx.rollback();
// Continue with other files
}
}
@@ -187,23 +259,38 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/photos', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { type } = req.query;
const { category_id, type } = req.query;
let query = db('photos').where({ event_id: eventId });
let query = db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where({ 'photos.event_id': eventId })
.select(
'photos.*',
'photo_categories.name as category_name',
'photo_categories.slug as category_slug'
);
if (type) {
query = query.where({ type });
if (category_id) {
query = query.where({ 'photos.category_id': category_id });
}
const photos = await query.orderBy('uploaded_at', 'desc');
// Keep type filter for backwards compatibility
if (type) {
query = query.where({ 'photos.type': type });
}
const photos = await query.orderBy('photos.uploaded_at', 'desc');
res.json({
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
category_slug: photo.category_slug,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at
}))
+160 -12
View File
@@ -3,7 +3,7 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
@@ -36,6 +36,32 @@ const upload = multer({
}
});
// Configure multer for favicon uploads
const faviconStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `favicon-${Date.now()}${ext}`);
}
});
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Favicon must be PNG or ICO format'));
}
}
});
// Get all settings
router.get('/', adminAuth, async (req, res) => {
try {
@@ -44,9 +70,17 @@ router.get('/', adminAuth, async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
} else {
settingsObject[setting.setting_key] = null;
}
});
res.json(settingsObject);
@@ -67,9 +101,17 @@ router.get('/:type', adminAuth, async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
} else {
settingsObject[setting.setting_key] = null;
}
});
res.json(settingsObject);
@@ -87,7 +129,10 @@ router.put('/branding', adminAuth, async (req, res) => {
company_tagline,
support_email,
footer_text,
watermark_enabled
watermark_enabled,
watermark_position,
watermark_opacity,
watermark_size
} = req.body;
const brandingSettings = {
@@ -95,7 +140,10 @@ router.put('/branding', adminAuth, async (req, res) => {
company_tagline,
support_email,
footer_text,
watermark_enabled
watermark_enabled,
watermark_position,
watermark_opacity,
watermark_size
};
// Update or insert each setting
@@ -172,19 +220,19 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_value: JSON.stringify(publicPath),
setting_value: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(publicPath),
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Logo uploaded successfully',
logo_url: publicPath
logoUrl: publicPath
});
} catch (error) {
console.error('Logo upload error:', error);
@@ -192,6 +240,68 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
}
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Delete old watermark logo if exists
const oldWatermarkLogoSetting = await db('app_settings')
.where('setting_key', 'branding_watermark_logo_path')
.first();
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
}
}
// Save new watermark logo path
const logoPath = req.file.path;
const publicPath = `/uploads/logos/${req.file.filename}`;
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_path',
setting_value: JSON.stringify(logoPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(logoPath),
updated_at: new Date()
});
// Save public URL
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_url',
setting_value: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
});
} catch (error) {
console.error('Watermark logo upload error:', error);
res.status(500).json({ error: 'Failed to upload watermark logo' });
}
});
// Update theme settings
router.put('/theme', adminAuth, async (req, res) => {
try {
@@ -346,4 +456,42 @@ router.get('/storage/info', adminAuth, async (req, res) => {
}
});
// Upload favicon endpoint
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No favicon file provided' });
}
// The file is already in the correct location from multer
const faviconUrl = `/uploads/favicons/${req.file.filename}`;
// Save to database
await db('app_settings')
.insert({
setting_key: 'branding_favicon_url',
setting_value: faviconUrl,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: faviconUrl,
updated_at: new Date()
});
// Log activity
await logActivity('favicon_uploaded',
{ faviconUrl },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ faviconUrl });
} catch (error) {
console.error('Error uploading favicon:', error);
res.status(500).json({ error: 'Failed to upload favicon' });
}
});
module.exports = router;
+5 -1
View File
@@ -87,7 +87,11 @@ router.post('/gallery/verify', [
});
// Generate session token
const token = jwt.sign({ eventId: event.id }, process.env.JWT_SECRET, { expiresIn: '24h' });
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery'
}, process.env.JWT_SECRET, { expiresIn: '24h' });
res.json({
token,
+68 -10
View File
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -26,7 +27,8 @@ async function verifyGalleryAccess(req, res, next) {
req.event = event;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
@@ -52,7 +54,8 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
res.json({ valid: true });
} catch (error) {
res.status(500).json({ error: 'Failed to verify token' });
console.error('Error verifying token:', error);
res.status(500).json({ error: 'Failed to verify token', details: error.message });
}
});
@@ -89,7 +92,8 @@ router.get('/:slug/info', async (req, res) => {
requires_password: true
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch gallery info' });
console.error('Error fetching gallery info:', error);
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message });
}
});
@@ -97,8 +101,23 @@ router.get('/:slug/info', async (req, res) => {
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
const photos = await db('photos')
.where('event_id', req.event.id)
.orderBy('uploaded_at', 'desc');
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.select(
'photos.*',
'photo_categories.name as category_name',
'photo_categories.slug as category_slug'
)
.orderBy('photos.uploaded_at', 'desc');
// Get all categories for this event
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
.orWhere('event_id', req.event.id);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
// Log view
await db('access_logs').insert({
@@ -118,18 +137,28 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
color_theme: req.event.color_theme,
expires_at: req.event.expires_at
},
categories: categories.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global
})),
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${req.event.slug}/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
category_slug: photo.category_slug,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at
}))
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch photos' });
console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos', details: error.message });
}
});
@@ -159,7 +188,25 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
});
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
res.download(filePath, photo.filename);
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Length': watermarkedBuffer.length
});
res.send(watermarkedBuffer);
} else {
// Send original file
res.download(filePath, photo.filename);
}
} catch (error) {
res.status(500).json({ error: 'Failed to download photo' });
}
@@ -184,10 +231,21 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
archive.pipe(res);
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Add photos to archive
for (const photo of photos) {
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
archive.file(filePath, { name: photo.path });
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: photo.path });
} else {
// Add original file
archive.file(filePath, { name: photo.path });
}
}
await archive.finalize();
+189
View File
@@ -0,0 +1,189 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage');
const crypto = require('crypto');
const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
const secret = process.env.JWT_SECRET || 'your-secret-key';
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
/**
* Verify image token
*/
function verifyImageToken(token) {
try {
const secret = process.env.JWT_SECRET || 'your-secret-key';
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
// Verify signature
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
if (signature !== expectedSignature) {
return null;
}
// Check expiration
if (Date.now() > parseInt(expires)) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
} catch (error) {
return null;
}
}
/**
* Serve watermarked image
*/
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Get photo details
const photo = await db('photos')
.where({
id: photoId,
event_id: req.event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
// Apply watermark if enabled
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Set appropriate headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
});
// Send the watermarked image
res.send(imageBuffer);
} catch (error) {
console.error('Error serving watermarked image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
/**
* Generate signed URL for image access
*/
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Verify photo belongs to this event
const photo = await db('photos')
.where({
id: photoId,
event_id: req.event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Generate signed token
const token = generateImageToken(photoId);
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
url: signedUrl,
expiresIn: 3600 // 1 hour
});
} catch (error) {
console.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
});
/**
* Serve image with signed URL (no gallery auth required, token is the auth)
*/
router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
try {
const { slug, photoId, token } = req.params;
// Verify token
const tokenData = verifyImageToken(token);
if (!tokenData || tokenData.photoId !== parseInt(photoId)) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Get event
const event = await db('events')
.where({ slug })
.where('is_active', true)
.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Get photo
const photo = await db('photos')
.where({
id: photoId,
event_id: event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', event.slug, photo.path);
// Apply watermark if enabled
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Set appropriate headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
});
// Send the watermarked image
res.send(imageBuffer);
} catch (error) {
console.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
module.exports = router;
+33
View File
@@ -0,0 +1,33 @@
const express = require('express');
const { db } = require('../database/db');
const router = express.Router();
// Get public CMS page
router.get('/pages/:slug', async (req, res) => {
try {
const { slug } = req.params;
const { lang = 'en' } = req.query;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
// Return the appropriate language version
const title = lang === 'de' ? page.title_de : page.title_en;
const content = lang === 'de' ? page.content_de : page.content_en;
res.json({
title,
content,
slug: page.slug,
updated_at: page.updated_at
});
} catch (error) {
console.error('Error fetching public CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
module.exports = router;
+10 -3
View File
@@ -5,9 +5,9 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding and theme settings
// Fetch branding, theme, and general settings
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme'])
.whereIn('setting_type', ['branding', 'theme', 'general'])
.select('setting_key', 'setting_value');
// Convert to object format
@@ -30,7 +30,14 @@ router.get('/', async (req, res) => {
branding_support_email: settingsObject.branding_support_email || '',
branding_footer_text: settingsObject.branding_footer_text || '',
branding_watermark_enabled: settingsObject.branding_watermark_enabled || false,
theme_config: settingsObject.theme_config || null
branding_watermark_logo_url: settingsObject.branding_watermark_logo_url || '',
branding_watermark_position: settingsObject.branding_watermark_position || 'bottom-right',
branding_watermark_opacity: settingsObject.branding_watermark_opacity || 50,
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en'
};
res.json(publicSettings);
+4 -1
View File
@@ -60,12 +60,15 @@ async function processNewPhoto(filePath) {
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
await db('photos').insert({
event_id: event.id,
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: thumbnailPath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
});
+6 -4
View File
@@ -3,15 +3,17 @@ const path = require('path');
const fs = require('fs').promises;
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
async function generateThumbnail(imagePath) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename);
const thumbnailDir = getThumbnailPath();
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
// Ensure thumbnail directory exists
await fs.mkdir(THUMBNAIL_PATH, { recursive: true });
await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
@@ -22,7 +24,7 @@ async function generateThumbnail(imagePath) {
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
}
module.exports = { generateThumbnail };
+226
View File
@@ -0,0 +1,226 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
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
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.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();
}
}
module.exports = new WatermarkService();
+57
View File
@@ -0,0 +1,57 @@
/**
* Sanitize a string to be used as a filename component
* @param {string} str - The string to sanitize
* @param {number} maxLength - Maximum length of the sanitized string
* @returns {string} - Sanitized string
*/
function sanitizeFilename(str, maxLength = 50) {
if (!str) return 'unnamed';
// Convert to string and trim
let sanitized = String(str).trim();
// Replace spaces with underscores
sanitized = sanitized.replace(/\s+/g, '_');
// Remove special characters except hyphens, underscores, and dots
sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, '');
// Remove multiple consecutive underscores or hyphens
sanitized = sanitized.replace(/[_\-]{2,}/g, '_');
// Remove leading/trailing underscores or hyphens
sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, '');
// Limit length
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
// If empty after sanitization, use default
if (!sanitized) {
sanitized = 'unnamed';
}
return sanitized;
}
/**
* Generate a photo filename based on event name, category, and counter
* @param {string} eventName - The event name
* @param {string} categoryName - The category name
* @param {number} counter - The photo counter
* @param {string} extension - The file extension (including dot)
* @returns {string} - Generated filename
*/
function generatePhotoFilename(eventName, categoryName, counter, extension) {
const sanitizedEvent = sanitizeFilename(eventName, 30);
const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20);
const paddedCounter = String(counter).padStart(4, '0');
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
}
module.exports = {
sanitizeFilename,
generatePhotoFilename
};