Fix brand theme application and add comprehensive translations
- Fixed theme not being reflected on gallery and admin login pages - Created GlobalThemeProvider to apply themes globally - Updated gallery and admin login pages to use dynamic CSS variables - Added complete translations for all admin sections in English and German: - Notifications management - Event view and creation - Photo upload functionality - Category management - Archive page view - Analytics dashboard - Branding and theme settings - System settings - CMS page management - Email configuration - Fixed admin photo management display issues - Fixed photo upload category assignment - Added password reset functionality for galleries - Improved error handling and user feedback 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -161,7 +161,16 @@ async function initializeDatabase() {
|
||||
table.json('metadata'); // Additional data about the activity
|
||||
table.integer('event_id').references('id').inTable('events');
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('read_at').nullable();
|
||||
});
|
||||
} else {
|
||||
// Check if read_at column exists
|
||||
const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at');
|
||||
if (!hasReadAt) {
|
||||
await db.schema.table('activity_logs', (table) => {
|
||||
table.datetime('read_at').nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
// Cache maintenance mode status to avoid DB queries on every request
|
||||
let maintenanceMode = false;
|
||||
let lastCheck = 0;
|
||||
const CACHE_DURATION = 60000; // 1 minute
|
||||
|
||||
async function checkMaintenanceMode() {
|
||||
const now = Date.now();
|
||||
|
||||
// Use cached value if recent
|
||||
if (now - lastCheck < CACHE_DURATION) {
|
||||
return maintenanceMode;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where('setting_key', 'general_maintenance_mode')
|
||||
.where('setting_type', 'general')
|
||||
.first();
|
||||
|
||||
maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false;
|
||||
lastCheck = now;
|
||||
|
||||
return maintenanceMode;
|
||||
} catch (error) {
|
||||
console.error('Error checking maintenance mode:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware to enforce maintenance mode
|
||||
async function maintenanceMiddleware(req, res, next) {
|
||||
// Skip maintenance check for certain paths
|
||||
const skipPaths = [
|
||||
'/api/admin/login',
|
||||
'/api/admin/auth/login',
|
||||
'/api/public/settings',
|
||||
'/health'
|
||||
];
|
||||
|
||||
// Allow static assets (uploads, favicons, logos)
|
||||
const isStaticAsset = req.path.startsWith('/uploads/') ||
|
||||
req.path.startsWith('/favicons/') ||
|
||||
req.path.startsWith('/logos/');
|
||||
|
||||
// Allow admin routes if admin is authenticated
|
||||
const isAdminRoute = req.path.startsWith('/api/admin');
|
||||
const hasAdminAuth = req.headers.authorization?.startsWith('Bearer ');
|
||||
|
||||
if (skipPaths.includes(req.path) || isStaticAsset || (isAdminRoute && hasAdminAuth)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const inMaintenance = await checkMaintenanceMode();
|
||||
|
||||
if (inMaintenance && !isAdminRoute) {
|
||||
return res.status(503).json({
|
||||
error: 'Service Unavailable',
|
||||
message: 'The system is currently undergoing maintenance. Please try again later.',
|
||||
maintenance: true
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Function to clear cache when settings change
|
||||
function clearMaintenanceCache() {
|
||||
lastCheck = 0;
|
||||
}
|
||||
|
||||
module.exports = { maintenanceMiddleware, clearMaintenanceCache };
|
||||
@@ -4,7 +4,17 @@ const { db } = require('../database/db');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
const eventSlug = req.path.split('/')[1];
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
eventSlug = null;
|
||||
} else {
|
||||
// For regular photos, the slug is the first part of the path
|
||||
eventSlug = req.path.split('/')[1];
|
||||
}
|
||||
|
||||
// First check for JWT token (from gallery access)
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -13,17 +23,32 @@ async function photoAuth(req, res, next) {
|
||||
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 a gallery token
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we accept any valid gallery token
|
||||
if (!eventSlug) {
|
||||
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (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') {
|
||||
if (!eventSlug) {
|
||||
// For thumbnails with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
const event = await db('events').where({ slug: eventSlug }).first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
@@ -42,6 +67,11 @@ async function photoAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), we require JWT token
|
||||
if (!eventSlug) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
// In-memory session tracking (in production, use Redis)
|
||||
const sessions = new Map();
|
||||
|
||||
// Default session timeout (60 minutes)
|
||||
const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
|
||||
|
||||
// Clean up expired sessions every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, lastActivity] of sessions.entries()) {
|
||||
if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
async function getSessionTimeout() {
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where('setting_key', 'security_session_timeout_minutes')
|
||||
.first();
|
||||
|
||||
if (setting && setting.setting_value) {
|
||||
const minutes = parseInt(JSON.parse(setting.setting_value));
|
||||
return minutes * 60 * 1000; // Convert to milliseconds
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting session timeout:', error);
|
||||
}
|
||||
|
||||
return DEFAULT_SESSION_TIMEOUT;
|
||||
}
|
||||
|
||||
async function sessionTimeoutMiddleware(req, res, next) {
|
||||
// Skip for non-authenticated routes
|
||||
if (!req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token is valid
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if this is an admin token
|
||||
if (!decoded.id) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastActivity = sessions.get(token);
|
||||
const timeout = await getSessionTimeout();
|
||||
|
||||
// If session exists, check if it's expired
|
||||
if (lastActivity) {
|
||||
if (now - lastActivity > timeout) {
|
||||
sessions.delete(token);
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update last activity
|
||||
sessions.set(token, now);
|
||||
|
||||
// Clean up old token if user has a new one
|
||||
// This prevents memory leaks from token renewals
|
||||
const userId = decoded.id;
|
||||
for (const [oldToken, _] of sessions.entries()) {
|
||||
if (oldToken !== token) {
|
||||
try {
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
|
||||
if (oldDecoded.id === userId) {
|
||||
sessions.delete(oldToken);
|
||||
}
|
||||
} catch (e) {
|
||||
// Token is invalid, remove it
|
||||
sessions.delete(oldToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// Token is invalid
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to end a session
|
||||
function endSession(token) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
|
||||
// Function to get active sessions count
|
||||
function getActiveSessions() {
|
||||
const now = Date.now();
|
||||
let active = 0;
|
||||
|
||||
for (const [_, lastActivity] of sessions.entries()) {
|
||||
if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sessionTimeoutMiddleware,
|
||||
endSession,
|
||||
getActiveSessions
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const eventsRoutes = require('./adminEvents');
|
||||
const photosRoutes = require('./adminPhotos');
|
||||
const categoriesRoutes = require('./adminCategories');
|
||||
const cmsRoutes = require('./adminCMS');
|
||||
const notificationsRoutes = require('./adminNotifications');
|
||||
|
||||
// Mount sub-routers
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
@@ -20,5 +21,6 @@ router.use('/events', eventsRoutes);
|
||||
router.use('/events', photosRoutes);
|
||||
router.use('/categories', categoriesRoutes);
|
||||
router.use('/cms', cmsRoutes);
|
||||
router.use('/notifications', notificationsRoutes);
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
@@ -34,11 +35,13 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
.offset(offset);
|
||||
|
||||
// Check if archive files exist and get their sizes
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => {
|
||||
let archiveFileSize = 0;
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const stats = await fs.stat(archive.archive_path);
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveFileSize = stats.size;
|
||||
} catch (error) {
|
||||
console.error(`Archive file not found: ${archive.archive_path}`);
|
||||
@@ -52,8 +55,8 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
eventDate: archive.event_date,
|
||||
eventType: archive.event_type,
|
||||
hostEmail: archive.host_email,
|
||||
archivedAt: archive.archived_at,
|
||||
expiresAt: archive.expires_at,
|
||||
archivedAt: archive.archived_at ? new Date(archive.archived_at).toISOString() : null,
|
||||
expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null,
|
||||
photoCount: archive.photo_count || 0,
|
||||
originalSize: archive.total_size || 0,
|
||||
archiveSize: archiveFileSize,
|
||||
@@ -97,7 +100,9 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
let archiveFileInfo = null;
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const stats = await fs.stat(archive.archive_path);
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveFileInfo = {
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime,
|
||||
@@ -142,12 +147,123 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Archive not found' });
|
||||
}
|
||||
|
||||
// Check if archive directory exists
|
||||
const archiveDir = path.dirname(archive.archive_path);
|
||||
const extractedDir = archive.archive_path.replace('.zip', '');
|
||||
|
||||
// TODO: Implement actual extraction logic
|
||||
// For now, just update the database
|
||||
// Check if archive file exists
|
||||
if (!archive.archive_path) {
|
||||
return res.status(400).json({ error: 'No archive file found' });
|
||||
}
|
||||
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
|
||||
try {
|
||||
await fs.access(fullArchivePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Archive file not found on disk' });
|
||||
}
|
||||
|
||||
// Extract the archive
|
||||
try {
|
||||
const zip = new AdmZip(fullArchivePath);
|
||||
const eventsDir = path.join(storagePath, 'events/active');
|
||||
const eventDir = path.join(eventsDir, archive.slug);
|
||||
|
||||
// Create event directory if it doesn't exist
|
||||
await fs.mkdir(eventDir, { recursive: true });
|
||||
|
||||
// Log ZIP contents for debugging
|
||||
console.log(`Extracting archive to: ${eventDir}`);
|
||||
const entries = zip.getEntries();
|
||||
console.log(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Extract files to the event directory
|
||||
zip.extractAllTo(eventDir, true);
|
||||
|
||||
// Get list of extracted files to update database
|
||||
const extractedPhotos = [];
|
||||
|
||||
// First, collect all category information from the ZIP structure
|
||||
const categoriesMap = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||
const filename = path.basename(entry.entryName);
|
||||
const dirPath = path.dirname(entry.entryName);
|
||||
const actualFilePath = path.join(eventDir, entry.entryName);
|
||||
|
||||
try {
|
||||
// Check if file was extracted successfully
|
||||
const stats = await fs.stat(actualFilePath);
|
||||
|
||||
// Determine category from directory structure
|
||||
let categoryId = null;
|
||||
if (dirPath && dirPath !== '.') {
|
||||
// Get the first level directory as category
|
||||
const categoryName = dirPath.split(path.sep)[0];
|
||||
|
||||
if (!categoriesMap.has(categoryName)) {
|
||||
// Check if this category exists in the database
|
||||
const existingCategory = await db('photo_categories')
|
||||
.where('event_id', archive.id)
|
||||
.where('name', categoryName)
|
||||
.first();
|
||||
|
||||
if (existingCategory) {
|
||||
categoriesMap.set(categoryName, existingCategory.id);
|
||||
} else {
|
||||
// Create the category if it doesn't exist
|
||||
const [newCategoryId] = await db('photo_categories').insert({
|
||||
event_id: archive.id,
|
||||
name: categoryName,
|
||||
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||
created_at: new Date()
|
||||
});
|
||||
categoriesMap.set(categoryName, newCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
categoryId = categoriesMap.get(categoryName);
|
||||
}
|
||||
|
||||
// Check if photo already exists in database
|
||||
const existingPhoto = await db('photos')
|
||||
.where('event_id', archive.id)
|
||||
.where('filename', filename)
|
||||
.first();
|
||||
|
||||
if (!existingPhoto) {
|
||||
// Store relative path from storage root
|
||||
const relativePath = path.relative(storagePath, actualFilePath);
|
||||
extractedPhotos.push({
|
||||
event_id: archive.id,
|
||||
filename: filename,
|
||||
original_filename: filename,
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will be regenerated by thumbnail service
|
||||
type: path.extname(filename).substring(1).toLowerCase(),
|
||||
size_bytes: stats.size,
|
||||
category_id: categoryId,
|
||||
uploaded_at: new Date()
|
||||
});
|
||||
}
|
||||
} catch (statError) {
|
||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||
console.error(`Entry name was: ${entry.entryName}`);
|
||||
console.error(`Error:`, statError.message);
|
||||
// Skip this file if we can't stat it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new photos if any
|
||||
if (extractedPhotos.length > 0) {
|
||||
await db('photos').insert(extractedPhotos);
|
||||
}
|
||||
|
||||
} catch (extractError) {
|
||||
console.error('Archive extraction error:', extractError);
|
||||
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
|
||||
}
|
||||
|
||||
// Update event status
|
||||
await db('events')
|
||||
@@ -164,8 +280,8 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'archive_restored',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.user.id,
|
||||
actor_name: req.user.username,
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
event_id: archive.id,
|
||||
metadata: JSON.stringify({ event_name: archive.event_name })
|
||||
});
|
||||
@@ -194,8 +310,11 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
|
||||
try {
|
||||
await fs.access(archive.archive_path);
|
||||
await fs.access(fullArchivePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Archive file not found on disk' });
|
||||
}
|
||||
@@ -205,15 +324,15 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`);
|
||||
|
||||
// Stream the file
|
||||
const fileStream = require('fs').createReadStream(archive.archive_path);
|
||||
const fileStream = require('fs').createReadStream(fullArchivePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
// Log download
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'archive_downloaded',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.user.id,
|
||||
actor_name: req.user.username,
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
event_id: archive.id,
|
||||
metadata: JSON.stringify({ event_name: archive.event_name })
|
||||
});
|
||||
@@ -251,8 +370,8 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'archive_deleted',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.user.id,
|
||||
actor_name: req.user.username,
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
metadata: JSON.stringify({
|
||||
event_name: archive.event_name,
|
||||
archived_date: archive.archived_at
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const router = express.Router();
|
||||
|
||||
// Change password
|
||||
@@ -18,7 +19,7 @@ router.post('/change-password', [
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.user.id;
|
||||
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
|
||||
|
||||
// Get user from database
|
||||
const user = await db('admin_users')
|
||||
@@ -46,6 +47,13 @@ router.post('/change-password', [
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: user.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
@@ -53,4 +61,28 @@ router.post('/change-password', [
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
// End the session
|
||||
endSession(token);
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity('admin_logout',
|
||||
{ admin_id: req.admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,6 +7,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
@@ -360,6 +361,67 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return res.status(400).json({ error: 'Cannot reset password for archived event' });
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
const { generatePassword } = require('../utils/passwordGenerator');
|
||||
const newPassword = generatePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_reset',
|
||||
{ eventName: event.event_name, emailSent: sendEmail },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
await db('email_queue').insert({
|
||||
event_id: id,
|
||||
recipient_email: event.host_email,
|
||||
email_type: 'password_reset',
|
||||
email_data: JSON.stringify({
|
||||
event_name: event.event_name,
|
||||
share_link: event.share_link,
|
||||
new_password: newPassword,
|
||||
reset_by: req.admin.username
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
newPassword: newPassword,
|
||||
emailSent: sendEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -374,14 +436,8 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Event is already archived' });
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
is_archived: true,
|
||||
is_active: false,
|
||||
archived_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
@@ -397,4 +453,83 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk archive events
|
||||
router.post('/bulk-archive', adminAuth, [
|
||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No events selected for archiving' });
|
||||
}
|
||||
|
||||
// Get all events to archive
|
||||
const events = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.where('is_archived', false);
|
||||
|
||||
if (events.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
// Process each event
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
{ eventName: event.event_name, bulkOperation: true },
|
||||
event.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
results.successful.push({
|
||||
id: event.id,
|
||||
name: event.event_name
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to archive event ${event.id}:`, error);
|
||||
results.failed.push({
|
||||
id: event.id,
|
||||
name: event.event_name,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log bulk archive activity
|
||||
await logActivity('bulk_archive_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in bulk archive:', error);
|
||||
res.status(500).json({ error: 'Failed to perform bulk archive' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
// Get notifications (unread activity logs)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { limit = 20, includeRead = false } = req.query;
|
||||
|
||||
let query = db('activity_logs')
|
||||
.select(
|
||||
'activity_logs.*',
|
||||
'events.event_name'
|
||||
)
|
||||
.leftJoin('events', 'activity_logs.event_id', 'events.id')
|
||||
.orderBy('activity_logs.created_at', 'desc')
|
||||
.limit(parseInt(limit));
|
||||
|
||||
// By default, only show unread notifications
|
||||
if (includeRead !== 'true') {
|
||||
query = query.whereNull('activity_logs.read_at');
|
||||
}
|
||||
|
||||
const notifications = await query;
|
||||
|
||||
// Format notifications
|
||||
const formattedNotifications = notifications.map(notification => ({
|
||||
id: notification.id,
|
||||
type: notification.activity_type,
|
||||
actorType: notification.actor_type,
|
||||
actorName: notification.actor_name,
|
||||
eventName: notification.event_name,
|
||||
eventId: notification.event_id,
|
||||
metadata: notification.metadata ? JSON.parse(notification.metadata) : {},
|
||||
createdAt: notification.created_at,
|
||||
readAt: notification.read_at,
|
||||
isRead: !!notification.read_at
|
||||
}));
|
||||
|
||||
// Get unread count
|
||||
const unreadCount = await db('activity_logs')
|
||||
.whereNull('read_at')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
notifications: formattedNotifications,
|
||||
unreadCount: unreadCount.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Notifications fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark notification as read
|
||||
router.put('/:id/read', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db('activity_logs')
|
||||
.where('id', id)
|
||||
.update({
|
||||
read_at: new Date()
|
||||
});
|
||||
|
||||
res.json({ message: 'Notification marked as read' });
|
||||
} catch (error) {
|
||||
console.error('Mark notification read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all notifications as read
|
||||
router.put('/read-all', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await db('activity_logs')
|
||||
.whereNull('read_at')
|
||||
.update({
|
||||
read_at: new Date()
|
||||
});
|
||||
|
||||
res.json({ message: 'All notifications marked as read' });
|
||||
} catch (error) {
|
||||
console.error('Mark all notifications read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark all notifications as read' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete old notifications (older than 30 days and read)
|
||||
router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const deletedCount = await db('activity_logs')
|
||||
.whereNotNull('read_at')
|
||||
.where('created_at', '<', db.raw("datetime('now', '-30 days')"))
|
||||
.delete();
|
||||
|
||||
res.json({
|
||||
message: 'Old notifications cleared',
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Clear old notifications error:', error);
|
||||
res.status(500).json({ error: 'Failed to clear old notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -14,12 +14,14 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
console.log('Multer destination called for file:', file.originalname);
|
||||
const { eventId } = req.params;
|
||||
|
||||
try {
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found in multer destination:', eventId);
|
||||
return cb(new Error('Event not found'));
|
||||
}
|
||||
|
||||
@@ -28,21 +30,26 @@ const storage = multer.diskStorage({
|
||||
|
||||
// Create destination path - now just event folder, no type subfolder
|
||||
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
console.log('Destination path:', destPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
cb(null, destPath);
|
||||
} catch (error) {
|
||||
console.error('Error in multer destination:', error);
|
||||
cb(error);
|
||||
}
|
||||
},
|
||||
filename: async (req, file, cb) => {
|
||||
console.log('Multer filename called for file:', file.originalname);
|
||||
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)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
} catch (error) {
|
||||
console.error('Error in multer filename:', error);
|
||||
cb(error);
|
||||
}
|
||||
}
|
||||
@@ -68,31 +75,51 @@ const upload = multer({
|
||||
});
|
||||
|
||||
// Upload photos for an event
|
||||
router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => {
|
||||
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
upload.array('photos', 20)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||
}
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
|
||||
console.log('Upload request received:');
|
||||
console.log('Upload request received for event:', eventId);
|
||||
console.log('Body:', req.body);
|
||||
console.log('Files:', req.files ? req.files.length : 'none');
|
||||
console.log('Headers:', req.headers);
|
||||
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
console.log('Category ID received:', category_id);
|
||||
|
||||
// Verify event exists and admin has access
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found:', eventId);
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.log('No files in request. req.files:', req.files);
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Get category details if provided
|
||||
let category = null;
|
||||
if (category_id) {
|
||||
category = await db('photo_categories').where({ id: category_id }).first();
|
||||
if (parsedCategoryId) {
|
||||
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
||||
if (!category) {
|
||||
return res.status(400).json({ error: 'Invalid category' });
|
||||
}
|
||||
@@ -112,7 +139,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
|
||||
if (category) {
|
||||
// Lock the category row and get current counter
|
||||
const categoryData = await trx('photo_categories')
|
||||
.where({ id: category_id })
|
||||
.where({ id: parsedCategoryId })
|
||||
.forUpdate()
|
||||
.first();
|
||||
|
||||
@@ -120,7 +147,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
|
||||
|
||||
// Update counter
|
||||
await trx('photo_categories')
|
||||
.where({ id: category_id })
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: counter });
|
||||
} else {
|
||||
// For uncategorized photos, count existing uncategorized photos
|
||||
@@ -165,7 +192,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
|
||||
filename: file.filename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: category_id || null,
|
||||
category_id: parsedCategoryId || null,
|
||||
type: 'individual', // Keep for backwards compatibility
|
||||
size_bytes: file.size
|
||||
});
|
||||
@@ -177,7 +204,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
|
||||
id: photoId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
category_id: category_id || null
|
||||
category_id: parsedCategoryId || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.filename}:`, error);
|
||||
@@ -255,11 +282,171 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update a photo (e.g., change category)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Update photo
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ category_id: category_id || null });
|
||||
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk delete photos
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
// Get all photos to delete
|
||||
const photos = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.status(404).json({ error: 'No photos found' });
|
||||
}
|
||||
|
||||
// Delete physical files
|
||||
const storagePath = getStoragePath();
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
|
||||
for (const photo of photos) {
|
||||
// Delete photo file
|
||||
const photoPath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
await fs.unlink(photoPath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo file:', error);
|
||||
}
|
||||
|
||||
// Delete thumbnail
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
||||
try {
|
||||
await fs.unlink(thumbPath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting thumbnail:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.delete();
|
||||
|
||||
// Log activity
|
||||
await logActivity('photos_bulk_deleted',
|
||||
{ count: photos.length, eventName: event.event_name },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: `${photos.length} photos deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('Error bulk deleting photos:', error);
|
||||
res.status(500).json({ error: 'Failed to delete photos' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk update photos
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
// Verify all photos belong to the event
|
||||
const photoCount = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount.count !== photoIds.length) {
|
||||
return res.status(400).json({ error: 'Some photos do not belong to this event' });
|
||||
}
|
||||
|
||||
// Update photos
|
||||
const updateData = {};
|
||||
if (updates.category_id !== undefined) {
|
||||
updateData.category_id = updates.category_id || null;
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.update(updateData);
|
||||
|
||||
res.json({ message: `${photoIds.length} photos updated successfully` });
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating photos:', error);
|
||||
res.status(500).json({ error: 'Failed to update photos' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download a photo
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Send file
|
||||
res.download(filePath, photo.filename);
|
||||
} catch (error) {
|
||||
console.error('Error downloading photo:', error);
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all photos for an event
|
||||
router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type } = req.query;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
@@ -270,8 +457,13 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
'photo_categories.slug as category_slug'
|
||||
);
|
||||
|
||||
if (category_id) {
|
||||
query = query.where({ 'photos.category_id': category_id });
|
||||
// Filter by category (including uncategorized)
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else {
|
||||
query = query.where({ 'photos.category_id': category_id });
|
||||
}
|
||||
}
|
||||
|
||||
// Keep type filter for backwards compatibility
|
||||
@@ -279,14 +471,27 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
query = query.where({ 'photos.type': type });
|
||||
}
|
||||
|
||||
const photos = await query.orderBy('photos.uploaded_at', 'desc');
|
||||
// Search by filename
|
||||
if (search) {
|
||||
query = query.where('photos.filename', 'like', `%${search}%`);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let orderByColumn = 'photos.uploaded_at';
|
||||
if (sort === 'name') {
|
||||
orderByColumn = 'photos.filename';
|
||||
} else if (sort === 'size') {
|
||||
orderByColumn = 'photos.size_bytes';
|
||||
}
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
res.json({
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/photos/${photo.path}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null,
|
||||
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -301,4 +506,82 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve photo with admin authentication
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
// Send file (sendFile requires absolute path)
|
||||
res.sendFile(path.resolve(filePath));
|
||||
} catch (error) {
|
||||
console.error('Error serving photo:', error);
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve thumbnail with admin authentication
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
||||
|
||||
console.log(`Attempting to serve thumbnail: ${filePath}`);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
console.error(`Thumbnail file not found: ${filePath}`, error);
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
// Send file (sendFile requires absolute path)
|
||||
res.sendFile(path.resolve(filePath));
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
console.error('Photo ID:', req.params.photoId);
|
||||
console.error('Event ID:', req.params.eventId);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for logo uploads
|
||||
@@ -357,6 +358,11 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear maintenance mode cache if it was updated
|
||||
if ('general_maintenance_mode' in settings) {
|
||||
clearMaintenanceCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
|
||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login
|
||||
@@ -16,7 +17,13 @@ router.post('/admin/login', [
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.where({ username })
|
||||
@@ -60,9 +67,15 @@ router.post('/gallery/verify', [
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug, password } = req.body;
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
|
||||
const event = await db('events').where({ slug, is_active: true }).first();
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
|
||||
const event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: true, is_archived: false })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
@@ -38,7 +40,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: true })
|
||||
.where({ slug, is_active: true, is_archived: false })
|
||||
.select('id', 'share_link')
|
||||
.first();
|
||||
|
||||
@@ -67,13 +69,18 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'share_link')
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Check if event is archived
|
||||
if (event.is_archived) {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
const expectedToken = event.share_link.split('/').pop();
|
||||
@@ -147,7 +154,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/photos/${photo.path}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
|
||||
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -187,7 +194,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
@@ -236,7 +243,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark
|
||||
|
||||
@@ -5,9 +5,9 @@ const router = express.Router();
|
||||
// Get public settings (branding and theme)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Fetch branding, theme, and general settings
|
||||
// Fetch branding, theme, general, and select security settings
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general'])
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
// Convert to object format
|
||||
@@ -37,7 +37,11 @@ router.get('/', async (req, res) => {
|
||||
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'
|
||||
default_language: settingsObject.general_default_language || 'en',
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const axios = require('axios');
|
||||
const { db } = require('../database/db');
|
||||
|
||||
async function verifyRecaptcha(token) {
|
||||
// Check if reCAPTCHA is enabled
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key'])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(setting => {
|
||||
try {
|
||||
settingsMap[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
settingsMap[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
const isEnabled = settingsMap.security_enable_recaptcha === true ||
|
||||
settingsMap.security_enable_recaptcha === 'true';
|
||||
const secretKey = settingsMap.security_recaptcha_secret_key;
|
||||
|
||||
// If reCAPTCHA is not enabled, always return true
|
||||
if (!isEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If enabled but no token provided, fail
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no secret key configured, log warning but pass
|
||||
if (!secretKey) {
|
||||
console.warn('reCAPTCHA enabled but no secret key configured');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://www.google.com/recaptcha/api/siteverify',
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
secret: secretKey,
|
||||
response: token
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.success === true;
|
||||
} catch (error) {
|
||||
console.error('reCAPTCHA verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyRecaptcha };
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Generate a secure random password
|
||||
* @param {number} length - Password length (default 12)
|
||||
* @returns {string} Generated password
|
||||
*/
|
||||
function generatePassword(length = 12) {
|
||||
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const numbers = '0123456789';
|
||||
const symbols = '!@#$%&*';
|
||||
|
||||
// Ensure at least one character from each set
|
||||
const requiredChars = [
|
||||
lowercase[Math.floor(Math.random() * lowercase.length)],
|
||||
uppercase[Math.floor(Math.random() * uppercase.length)],
|
||||
numbers[Math.floor(Math.random() * numbers.length)],
|
||||
symbols[Math.floor(Math.random() * symbols.length)]
|
||||
];
|
||||
|
||||
// Fill the rest with random characters from all sets
|
||||
const allChars = lowercase + uppercase + numbers + symbols;
|
||||
const remainingLength = length - requiredChars.length;
|
||||
|
||||
let password = '';
|
||||
for (let i = 0; i < remainingLength; i++) {
|
||||
password += allChars[Math.floor(Math.random() * allChars.length)];
|
||||
}
|
||||
|
||||
// Combine and shuffle
|
||||
const passwordArray = [...requiredChars, ...password];
|
||||
for (let i = passwordArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[passwordArray[i], passwordArray[j]] = [passwordArray[j], passwordArray[i]];
|
||||
}
|
||||
|
||||
return passwordArray.join('');
|
||||
}
|
||||
|
||||
module.exports = { generatePassword };
|
||||
Reference in New Issue
Block a user