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:
Paul Nothaft
2026-01-03 08:59:01 +01:00
parent 97455ab047
commit 0da45e699a
41 changed files with 6112 additions and 2125 deletions
Binary file not shown.
@@ -0,0 +1,693 @@
/**
* Migration: Add Liquid Glass CSS Templates
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
*/
const LIQUID_GLASS_LIGHT = `/*
* PicPeak Custom CSS Template: Liquid Glass Light
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Translucent frosted glass surfaces
* - Dynamic light refraction effects
* - Subtle specular highlights
* - Soft depth shadows
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-bg-elevated: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-blur: 20px;
--glass-saturation: 180%;
--gallery-bg: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
--gallery-text: #1a1a2e;
--gallery-text-muted: rgba(26, 26, 46, 0.7);
--gallery-accent: #667eea;
--gallery-accent-hover: #764ba2;
--gallery-radius: 24px;
--gallery-spacing: 20px;
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
}
/* Animated gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
}
/* ===== Glass Card Base ===== */
.glass-surface {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
box-shadow:
var(--glass-shadow),
inset 0 1px 1px rgba(255, 255, 255, 0.8),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
}
/* Liquid shine effect */
.glass-surface::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
pointer-events: none;
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
border-bottom: 1px solid var(--glass-border);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.6);
}
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.3) 0%,
transparent 100%
);
pointer-events: none;
z-index: 1;
border-radius: var(--gallery-radius) var(--gallery-radius) 0 0;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow:
0 20px 40px rgba(102, 126, 234, 0.3),
0 8px 16px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.8);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease;
}
.photo-card:hover img {
transform: scale(1.05);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.3) 100%
);
}
/* ===== Buttons - Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.gallery-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
transparent 100%
);
}
.gallery-btn:hover {
background: var(--glass-bg-elevated);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-hover) 100%);
color: white;
border: none;
}
/* ===== Lightbox - Glass Style ===== */
.lightbox-overlay {
background: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text);
transition: all 0.3s ease;
}
.category-pill:hover,
.category-pill.active {
background: var(--gallery-accent);
color: white;
border-color: var(--gallery-accent);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.glass-surface,
.photo-card,
.gallery-btn {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: rgba(255, 255, 255, 0.95);
}
}`;
const LIQUID_GLASS_DARK = `/*
* PicPeak Custom CSS Template: Liquid Glass Dark
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Deep translucent dark surfaces
* - Neon accent highlights
* - Dramatic glass reflections
* - Subtle animated gradients
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(15, 15, 35, 0.7);
--glass-bg-elevated: rgba(25, 25, 55, 0.85);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-highlight: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-blur: 24px;
--glass-saturation: 150%;
--gallery-bg: #0a0a1a;
--gallery-text: #f0f0f5;
--gallery-text-muted: rgba(240, 240, 245, 0.6);
--gallery-accent: #00d4ff;
--gallery-accent-secondary: #ff00e5;
--gallery-accent-hover: #00ffea;
--gallery-radius: 20px;
--gallery-spacing: 20px;
/* Neon glow variables */
--neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2);
--neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2);
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
/* Animated mesh gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background:
radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%),
radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%),
radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%);
animation: gradientShift 20s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gradientShift {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(2%, 2%) rotate(1deg); }
50% { transform: translate(-1%, 3%) rotate(-1deg); }
75% { transform: translate(3%, -2%) rotate(2deg); }
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation));
border-bottom: 1px solid var(--glass-border-highlight);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Dark Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Top highlight reflection */
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
z-index: 2;
}
/* Inner glow effect */
.photo-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--gallery-radius);
padding: 1px;
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0) 0%,
rgba(0, 212, 255, 0) 40%,
rgba(0, 212, 255, 0.1) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: var(--glass-border-highlight);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(0, 212, 255, 0.2),
var(--neon-glow);
}
.photo-card:hover::after {
opacity: 1;
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
}
.photo-card:hover img {
transform: scale(1.05);
filter: brightness(1);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.4) 100%
);
color: var(--gallery-text);
}
.photo-card-info p {
color: var(--gallery-text-muted);
font-size: 0.875rem;
}
/* ===== Buttons - Neon Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.gallery-btn:hover {
border-color: var(--gallery-accent);
box-shadow: var(--neon-glow);
color: var(--gallery-accent);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border: none;
box-shadow: var(--neon-glow);
}
.gallery-btn-primary:hover {
box-shadow:
0 0 30px rgba(0, 212, 255, 0.6),
0 0 60px rgba(0, 212, 255, 0.3),
0 0 90px rgba(255, 0, 229, 0.2);
transform: translateY(-2px);
}
/* ===== Lightbox - Dark Glass ===== */
.lightbox-overlay {
background: rgba(5, 5, 15, 0.9);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg-elevated);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border-highlight);
border-radius: var(--gallery-radius);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.5),
var(--neon-glow);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text-muted);
transition: all 0.3s ease;
}
.category-pill:hover {
border-color: var(--gallery-accent);
color: var(--gallery-accent);
box-shadow: var(--neon-glow);
}
.category-pill.active {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border-color: transparent;
box-shadow: var(--neon-glow);
}
/* ===== Scrollbar Styling ===== */
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gallery-page ::-webkit-scrollbar-track {
background: var(--glass-bg);
border-radius: 4px;
}
.gallery-page ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
border-radius: 4px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
/* Reduce animation complexity on mobile */
.gallery-page::before {
animation: none;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page::before {
animation: none;
}
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.photo-card,
.gallery-btn,
.gallery-header {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.gallery-page {
--glass-bg: rgba(20, 20, 40, 0.98);
--glass-bg-elevated: rgba(30, 30, 60, 0.98);
}
}`;
exports.up = async function(knex) {
// Update template slot 2 with Liquid Glass Light
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Liquid Glass Light',
css_content: LIQUID_GLASS_LIGHT,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
// Update template slot 3 with Liquid Glass Dark
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Liquid Glass Dark',
css_content: LIQUID_GLASS_DARK,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
};
exports.down = async function(knex) {
// Revert to empty templates
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
};
// Export templates for use elsewhere
module.exports.LIQUID_GLASS_LIGHT = LIQUID_GLASS_LIGHT;
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
+6 -14
View File
@@ -25,6 +25,7 @@ const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser');
@@ -471,20 +472,11 @@ try {
logger.warn('Failed to enable frontend static serving', { error: e.message });
}
// Error handling middleware
app.use((err, req, res, next) => {
console.error('EXPRESS ERROR HANDLER:', err);
console.error('Error stack:', err.stack);
console.error('Request URL:', req.url);
console.error('Request method:', req.method);
logger.error('Express error handler:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method
});
res.status(500).json({ error: 'Something went wrong!', details: err.message });
});
// 404 handler for undefined API routes
app.use('/api', notFoundHandler);
// Global error handler (must be last)
app.use(errorHandler);
// Initialize services
async function startServer() {
+183
View File
@@ -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
View File
@@ -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;
+7 -3
View File
@@ -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)
+49 -57
View File
@@ -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) => {
+121
View File
@@ -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
};
+180
View File
@@ -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
};