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:
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -34,7 +34,7 @@ router.post('/', adminAuth, [
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
color_theme = 'default',
|
||||
color_theme = null,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user