feat: Add CSS template system with custom gallery styling support
## Changes ### CSS Template System - Added CSS class hooks to gallery components for custom template targeting - Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates - CSS variables on :root allow themes to override colors, effects, and spacing ### Gallery Component CSS Classes Added - `.gallery-page` - Main gallery container - `.gallery-header` - Top header bar - `.gallery-sidebar` - Filter/download sidebar - `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close` - `.gallery-sidebar-content`, `.gallery-sidebar-section` - `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon` - `.gallery-sidebar-backdrop` - Mobile overlay - `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons - `.gallery-footer` - Footer section - `.photo-card`, `.photo-grid` - Photo display elements ### CSS Templates (Database) - Elegant Dark (id=1): Dark navy theme with light text and red accents - Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background ### Bug Fixes - Fixed CSS variables not inheriting (moved from .gallery-page to :root) - Fixed sidebar position breaking layout (removed position: relative override) - Fixed Elegant Dark sidebar text visibility (white on white issue) ### Other Changes - Settings page refactoring and cleanup - i18n locale updates for new gallery features - Vite proxy port configuration fix - Admin auth route improvements - CSS templates service updates
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Catches all errors and returns standardized responses.
|
||||
* Distinguishes between operational errors (expected) and programming errors (bugs).
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* Determines if an error is operational (expected) or a programming error (bug).
|
||||
* Operational errors are expected failures like validation errors, not found, etc.
|
||||
* Programming errors are bugs that should be logged and investigated.
|
||||
*
|
||||
* @param {Error} err - The error to check
|
||||
* @returns {boolean} True if operational error
|
||||
*/
|
||||
const isOperationalError = (err) => {
|
||||
return err instanceof AppError && err.isOperational;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats error for development environment (includes stack trace).
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @returns {Object} Formatted error response
|
||||
*/
|
||||
const formatDevError = (err) => {
|
||||
return {
|
||||
error: err.message,
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
stack: err.stack,
|
||||
...(err.details && { details: err.details }),
|
||||
...(err.field && { field: err.field })
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats error for production environment (hides sensitive details).
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @param {boolean} isOperational - Whether this is an operational error
|
||||
* @returns {Object} Formatted error response
|
||||
*/
|
||||
const formatProdError = (err, isOperational) => {
|
||||
// For operational errors, show the message
|
||||
if (isOperational) {
|
||||
return {
|
||||
error: err.message,
|
||||
code: err.code || 'ERROR',
|
||||
...(err.details && { details: err.details }),
|
||||
...(err.field && { field: err.field })
|
||||
};
|
||||
}
|
||||
|
||||
// For programming errors, hide details
|
||||
return {
|
||||
error: 'An unexpected error occurred',
|
||||
code: 'INTERNAL_ERROR'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles specific error types and converts them to AppError format.
|
||||
*
|
||||
* @param {Error} err - The error to handle
|
||||
* @returns {Error} Converted error or original error
|
||||
*/
|
||||
const handleKnownErrors = (err) => {
|
||||
// Handle Knex/Database errors
|
||||
if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') {
|
||||
const { AppError } = require('../utils/errors');
|
||||
const error = new AppError('A record with this value already exists', 409, 'DUPLICATE_ENTRY');
|
||||
error.isOperational = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle JSON parsing errors
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Invalid JSON in request body');
|
||||
}
|
||||
|
||||
// Handle multer file upload errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('File size exceeds the maximum allowed limit');
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
const { ValidationError } = require('../utils/errors');
|
||||
return new ValidationError('Unexpected file field');
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
* Must be registered last, after all routes.
|
||||
*
|
||||
* @param {Error} err - The error object
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
// If headers already sent, delegate to Express default handler
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// Convert known error types
|
||||
const error = handleKnownErrors(err);
|
||||
|
||||
// Determine error status code
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
const operational = isOperationalError(error);
|
||||
|
||||
// Log the error
|
||||
const logContext = {
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
statusCode,
|
||||
errorCode: error.code,
|
||||
operational,
|
||||
...(req.admin && { adminId: req.admin.id }),
|
||||
...(req.gallerySlug && { gallerySlug: req.gallerySlug })
|
||||
};
|
||||
|
||||
if (operational) {
|
||||
// Operational errors are expected, log at warn level
|
||||
logger.warn('Operational error', {
|
||||
...logContext,
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
// Programming errors are bugs, log at error level with stack
|
||||
logger.error('Unhandled error', {
|
||||
...logContext,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Format and send response
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const response = isDev ? formatDevError(error) : formatProdError(error, operational);
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* 404 handler for undefined routes.
|
||||
* Should be registered after all routes but before errorHandler.
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Response} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
next(new NotFoundError('Route', req.originalUrl));
|
||||
};
|
||||
|
||||
/**
|
||||
* Async handler that catches unhandled promise rejections.
|
||||
* Use this to wrap async route handlers.
|
||||
*
|
||||
* @param {Function} fn - Async function to wrap
|
||||
* @returns {Function} Wrapped function
|
||||
*/
|
||||
const asyncHandler = (fn) => (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
isOperationalError
|
||||
};
|
||||
+121
-206
@@ -1,31 +1,29 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { body } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const router = express.Router();
|
||||
|
||||
// Change password
|
||||
router.get('/profile', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const admin = await db('admin_users')
|
||||
.where('id', req.admin.id)
|
||||
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
// Get admin profile
|
||||
router.get('/profile', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users')
|
||||
.where('id', req.admin.id)
|
||||
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: 'Admin user not found' });
|
||||
}
|
||||
|
||||
res.json(admin);
|
||||
} catch (error) {
|
||||
console.error('Admin profile fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch admin profile' });
|
||||
if (!admin) {
|
||||
throw new NotFoundError('Admin user');
|
||||
}
|
||||
});
|
||||
|
||||
res.json(admin);
|
||||
}));
|
||||
|
||||
// Update admin profile
|
||||
router.put('/profile', [
|
||||
adminAuth,
|
||||
body('username')
|
||||
@@ -37,212 +35,129 @@ router.put('/profile', [
|
||||
.isEmail()
|
||||
.withMessage('A valid email address is required')
|
||||
.normalizeEmail()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const username = req.body.username.trim();
|
||||
const email = req.body.email.trim().toLowerCase();
|
||||
const adminId = req.admin.id;
|
||||
const username = req.body.username.trim();
|
||||
const email = req.body.email.trim().toLowerCase();
|
||||
const adminId = req.admin.id;
|
||||
|
||||
const existingUsername = await db('admin_users')
|
||||
.where('username', username)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
// Check for username conflict
|
||||
const existingUsername = await db('admin_users')
|
||||
.where('username', username)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already in use' });
|
||||
}
|
||||
|
||||
const existingEmail = await db('admin_users')
|
||||
.where('email', email)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(409).json({ error: 'Email address is already in use' });
|
||||
}
|
||||
|
||||
await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.update({
|
||||
username,
|
||||
email,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_profile_updated',
|
||||
{ username, email },
|
||||
null,
|
||||
{ type: 'admin', id: adminId, name: req.admin.username }
|
||||
);
|
||||
|
||||
const updatedAdmin = await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
message: 'Admin profile updated successfully',
|
||||
user: updatedAdmin
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin profile update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username is already in use', 'username');
|
||||
}
|
||||
});
|
||||
|
||||
// Check for email conflict
|
||||
const existingEmail = await db('admin_users')
|
||||
.where('email', email)
|
||||
.whereNot('id', adminId)
|
||||
.first();
|
||||
|
||||
if (existingEmail) {
|
||||
throw new ConflictError('Email address is already in use', 'email');
|
||||
}
|
||||
|
||||
await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.update({
|
||||
username,
|
||||
email,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_profile_updated',
|
||||
{ username, email },
|
||||
null,
|
||||
{ type: 'admin', id: adminId, name: req.admin.username }
|
||||
);
|
||||
|
||||
const updatedAdmin = await db('admin_users')
|
||||
.where('id', adminId)
|
||||
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
|
||||
.first();
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Admin profile updated successfully',
|
||||
user: updatedAdmin
|
||||
});
|
||||
}));
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', [
|
||||
adminAuth,
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.admin.id;
|
||||
|
||||
// Validate new password strength
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.messages
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await db('admin_users')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
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);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
// Validate new password strength
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new ValidationError('Password does not meet security requirements', passwordValidation.messages);
|
||||
}
|
||||
});
|
||||
|
||||
// Update admin profile
|
||||
router.put('/profile', [
|
||||
adminAuth,
|
||||
body('username').trim().notEmpty().withMessage('Username is required'),
|
||||
body('email').trim().isEmail().withMessage('Valid email is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
// Get user from database
|
||||
const user = await db('admin_users')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
const { username, email } = req.body;
|
||||
const userId = req.admin.id;
|
||||
|
||||
// Check for email conflicts
|
||||
const existingEmail = await db('admin_users')
|
||||
.where('email', email)
|
||||
.whereNot('id', userId)
|
||||
.first();
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(409).json({ error: 'Email is already in use by another admin' });
|
||||
}
|
||||
|
||||
// Check username conflict (if multiple admins are supported)
|
||||
const existingUsername = await db('admin_users')
|
||||
.where('username', username)
|
||||
.whereNot('id', userId)
|
||||
.first();
|
||||
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({ error: 'Username is already in use by another admin' });
|
||||
}
|
||||
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
username,
|
||||
email,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const updatedUser = await db('admin_users')
|
||||
.select('id', 'username', 'email', 'must_change_password')
|
||||
.where('id', userId)
|
||||
.first();
|
||||
|
||||
await logActivity(
|
||||
'admin_profile_updated',
|
||||
{ admin_id: userId, updated_fields: ['username', 'email'] },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: username }
|
||||
);
|
||||
|
||||
res.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('Admin profile update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||
if (!user) {
|
||||
throw new NotFoundError('User');
|
||||
}
|
||||
});
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
throw new ValidationError('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
null,
|
||||
{ type: 'admin', id: userId, name: user.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Password changed successfully' });
|
||||
}));
|
||||
|
||||
// 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' });
|
||||
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
// 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 }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -141,7 +141,8 @@ router.post('/', adminAuth, [
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
body('watermark_text').optional().trim(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -178,7 +179,9 @@ router.post('/', adminAuth, [
|
||||
allow_favorites = true,
|
||||
require_name_email = false,
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
css_template_id = null
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -299,7 +302,8 @@ router.post('/', adminAuth, [
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
|
||||
@@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
@@ -32,69 +34,59 @@ async function checkSlugRedirect(slug) {
|
||||
}
|
||||
|
||||
// Resolve gallery identifier (slug or token) to canonical data
|
||||
router.get('/resolve/:identifier', async (req, res) => {
|
||||
try {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
let result = await resolveShareIdentifier(identifier);
|
||||
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
// If not found, check for redirect
|
||||
if (!result) {
|
||||
const newSlug = await checkSlugRedirect(identifier);
|
||||
if (newSlug) {
|
||||
return res.status(301).json({
|
||||
redirect: true,
|
||||
newSlug,
|
||||
message: 'Gallery has been renamed'
|
||||
});
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resolving gallery identifier:', error);
|
||||
res.status(500).json({ error: 'Failed to resolve gallery link' });
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
});
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
try {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
res.status(500).json({ error: 'Failed to verify token' });
|
||||
router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundError('Gallery');
|
||||
}
|
||||
});
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
throw new NotFoundError('Gallery', 'Invalid gallery link');
|
||||
}
|
||||
|
||||
res.json({ valid: true });
|
||||
}));
|
||||
|
||||
// Get gallery info (with optional token verification)
|
||||
router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Custom error classes for standardized error handling across the application.
|
||||
* These errors are caught by the global error handler and converted to appropriate HTTP responses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for operational errors (expected errors that can occur during normal operation)
|
||||
*/
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.isOperational = true;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
code: this.code,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: this.stack })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error - for invalid input data (400 Bad Request)
|
||||
*/
|
||||
class ValidationError extends AppError {
|
||||
constructor(message = 'Validation failed', details = null) {
|
||||
super(message, 400, 'VALIDATION_ERROR');
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
...(this.details && { details: this.details })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found error - for resources that don't exist (404 Not Found)
|
||||
*/
|
||||
class NotFoundError extends AppError {
|
||||
constructor(resource = 'Resource', identifier = null) {
|
||||
const message = identifier
|
||||
? `${resource} with identifier '${identifier}' not found`
|
||||
: `${resource} not found`;
|
||||
super(message, 404, 'NOT_FOUND');
|
||||
this.resource = resource;
|
||||
this.identifier = identifier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized error - for missing or invalid authentication (401 Unauthorized)
|
||||
*/
|
||||
class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message, 401, 'UNAUTHORIZED');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden error - for insufficient permissions (403 Forbidden)
|
||||
*/
|
||||
class ForbiddenError extends AppError {
|
||||
constructor(message = 'Access denied') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict error - for resource conflicts (409 Conflict)
|
||||
*/
|
||||
class ConflictError extends AppError {
|
||||
constructor(message = 'Resource conflict', field = null) {
|
||||
super(message, 409, 'CONFLICT');
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
...(this.field && { field: this.field })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit error - for too many requests (429 Too Many Requests)
|
||||
*/
|
||||
class RateLimitError extends AppError {
|
||||
constructor(message = 'Too many requests', retryAfter = null) {
|
||||
super(message, 429, 'RATE_LIMIT_EXCEEDED');
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service unavailable error - for maintenance mode or service issues (503 Service Unavailable)
|
||||
*/
|
||||
class ServiceUnavailableError extends AppError {
|
||||
constructor(message = 'Service temporarily unavailable') {
|
||||
super(message, 503, 'SERVICE_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AppError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
ServiceUnavailableError
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Route helper utilities for standardized request handling.
|
||||
* Provides async error wrapping, validation, and response formatting.
|
||||
*/
|
||||
|
||||
const { validationResult } = require('express-validator');
|
||||
const { ValidationError } = require('./errors');
|
||||
|
||||
/**
|
||||
* Wraps an async route handler to catch errors and pass them to the error handler.
|
||||
* Eliminates the need for try/catch blocks in every route.
|
||||
*
|
||||
* @param {Function} fn - Async route handler function
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* router.get('/events', handleAsync(async (req, res) => {
|
||||
* const events = await eventService.getAll();
|
||||
* res.json(events);
|
||||
* }));
|
||||
*/
|
||||
const handleAsync = (fn) => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the request using express-validator and throws ValidationError if invalid.
|
||||
* Should be called at the beginning of route handlers after validation middleware.
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @throws {ValidationError} If validation fails
|
||||
*
|
||||
* @example
|
||||
* router.post('/events', [
|
||||
* body('name').notEmpty(),
|
||||
* body('date').isDate()
|
||||
* ], handleAsync(async (req, res) => {
|
||||
* validateRequest(req);
|
||||
* // ... rest of handler
|
||||
* }));
|
||||
*/
|
||||
const validateRequest = (req) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
const errorDetails = errors.array().map(err => ({
|
||||
field: err.path || err.param,
|
||||
message: err.msg
|
||||
}));
|
||||
throw new ValidationError('Validation failed', errorDetails);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a standardized success response.
|
||||
*
|
||||
* @param {Response} res - Express response object
|
||||
* @param {*} data - Data to send in the response
|
||||
* @param {number} [statusCode=200] - HTTP status code
|
||||
* @param {string} [message] - Optional success message
|
||||
*
|
||||
* @example
|
||||
* successResponse(res, { event }, 201, 'Event created successfully');
|
||||
*/
|
||||
const successResponse = (res, data, statusCode = 200, message = null) => {
|
||||
const response = message ? { message, ...data } : data;
|
||||
res.status(statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a standardized error response.
|
||||
* Note: Prefer throwing custom errors and letting the error handler format the response.
|
||||
*
|
||||
* @param {Response} res - Express response object
|
||||
* @param {string} message - Error message
|
||||
* @param {number} [statusCode=500] - HTTP status code
|
||||
* @param {string} [code] - Optional error code
|
||||
* @param {*} [details] - Optional additional error details
|
||||
*
|
||||
* @example
|
||||
* errorResponse(res, 'Invalid input', 400, 'VALIDATION_ERROR', { field: 'email' });
|
||||
*/
|
||||
const errorResponse = (res, message, statusCode = 500, code = null, details = null) => {
|
||||
const response = {
|
||||
error: message,
|
||||
...(code && { code }),
|
||||
...(details && { details })
|
||||
};
|
||||
res.status(statusCode).json(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a route handler with built-in validation.
|
||||
* Combines handleAsync and validateRequest for cleaner route definitions.
|
||||
*
|
||||
* @param {Function} fn - Async route handler function
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* router.post('/events', [
|
||||
* body('name').notEmpty()
|
||||
* ], withValidation(async (req, res) => {
|
||||
* const event = await eventService.create(req.body);
|
||||
* successResponse(res, { event }, 201);
|
||||
* }));
|
||||
*/
|
||||
const withValidation = (fn) => {
|
||||
return handleAsync(async (req, res, next) => {
|
||||
validateRequest(req);
|
||||
return fn(req, res, next);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts pagination parameters from query string with defaults.
|
||||
*
|
||||
* @param {Request} req - Express request object
|
||||
* @param {Object} [defaults] - Default values
|
||||
* @param {number} [defaults.page=1] - Default page number
|
||||
* @param {number} [defaults.limit=20] - Default items per page
|
||||
* @param {number} [defaults.maxLimit=100] - Maximum allowed limit
|
||||
* @returns {{ page: number, limit: number, offset: number }}
|
||||
*
|
||||
* @example
|
||||
* const { page, limit, offset } = getPagination(req);
|
||||
* const events = await db('events').limit(limit).offset(offset);
|
||||
*/
|
||||
const getPagination = (req, defaults = {}) => {
|
||||
const { page: defaultPage = 1, limit: defaultLimit = 20, maxLimit = 100 } = defaults;
|
||||
|
||||
let page = parseInt(req.query.page, 10) || defaultPage;
|
||||
let limit = parseInt(req.query.limit, 10) || defaultLimit;
|
||||
|
||||
// Ensure valid values
|
||||
page = Math.max(1, page);
|
||||
limit = Math.min(Math.max(1, limit), maxLimit);
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return { page, limit, offset };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a paginated response with metadata.
|
||||
*
|
||||
* @param {*} data - Data array
|
||||
* @param {number} total - Total count of items
|
||||
* @param {number} page - Current page
|
||||
* @param {number} limit - Items per page
|
||||
* @returns {Object} Paginated response object
|
||||
*
|
||||
* @example
|
||||
* const events = await db('events').limit(limit).offset(offset);
|
||||
* const total = await db('events').count('* as count').first();
|
||||
* res.json(paginatedResponse(events, total.count, page, limit));
|
||||
*/
|
||||
const paginatedResponse = (data, total, page, limit) => {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasMore: page < totalPages
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handleAsync,
|
||||
validateRequest,
|
||||
successResponse,
|
||||
errorResponse,
|
||||
withValidation,
|
||||
getPagination,
|
||||
paginatedResponse
|
||||
};
|
||||
Reference in New Issue
Block a user