From 3424bd22eed6d08c42b79bb757e7ae8ecc114428 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 2 Jan 2026 10:12:24 +0100 Subject: [PATCH] refactor: Phase 1 code consolidation and service layer setup Phase 1.1: Shared parsers utility - Create backend/src/utils/parsers.js with parseBooleanInput, parseStringInput, etc. - Create frontend/src/utils/parsers.ts with TypeScript equivalents - Update routes to import from shared parsers Phase 1.2: Auth routes consolidation - Merge auth.js, auth-enhanced.js, auth-enhanced-v2.js into single auth.js - Add password change and password strength endpoints - Consolidate middleware (auth.js with token revocation support) - Update all imports across 14+ route files Phase 1.3: CreateEvent page consolidation - Remove duplicate CreateEventPage.tsx (basic version) - Rename CreateEventPageEnhanced.tsx to CreateEventPage.tsx - Update exports and imports Phase 1.4: CMS page consolidation - Remove duplicate CMSPage.tsx (basic version) - Rename CMSPageEnhanced.tsx to CMSPage.tsx - Update exports and imports Phase 1.5: Multer config factory - Create backend/src/config/multerConfig.js - Centralized upload configuration with presets for photos, logos, favicons - Reusable helpers: createDiskStorage, createFileFilter, uploadTimeoutMiddleware Phase 2.1: Event service layer - Create backend/src/services/eventService.js - Move event business logic out of routes - Functions: createEvent, getAllEvents, updateEvent, deleteEvent, extendExpiration --- backend/package-lock.json | 29 +- backend/server.js | 2 +- backend/src/config/multerConfig.js | 255 ++++++ backend/src/middleware/auth-enhanced-v2.js | 169 ---- backend/src/middleware/auth-enhanced.js | 241 ------ backend/src/middleware/auth.js | 300 +++++-- .../src/routes/__tests__/adminAuth.test.js | 2 +- .../__tests__/adminNotifications.test.js | 2 +- backend/src/routes/adminArchives.js | 2 +- backend/src/routes/adminAuth.js | 2 +- backend/src/routes/adminCMS.js | 2 +- backend/src/routes/adminCategories.js | 2 +- backend/src/routes/adminDashboard.js | 2 +- backend/src/routes/adminEmail.js | 2 +- backend/src/routes/adminEventRename.js | 2 +- backend/src/routes/adminEvents.js | 43 +- backend/src/routes/adminFeedback.js | 2 +- backend/src/routes/adminNotifications.js | 2 +- backend/src/routes/adminSystem.js | 2 +- backend/src/routes/auth-enhanced-v2.js | 393 --------- backend/src/routes/auth-enhanced.js | 395 --------- backend/src/routes/auth.js | 484 +++++++++-- backend/src/routes/events.js | 43 +- backend/src/services/eventService.js | 401 +++++++++ backend/src/utils/parsers.js | 204 +++++ frontend/package-lock.json | 223 ++++- frontend/src/App.tsx | 8 +- frontend/src/pages/admin/CMSPage.tsx | 218 +++-- frontend/src/pages/admin/CMSPageEnhanced.tsx | 618 -------------- frontend/src/pages/admin/CreateEventPage.tsx | 802 +++++++++--------- .../pages/admin/CreateEventPageEnhanced.tsx | 709 ---------------- frontend/src/pages/admin/SettingsPage.tsx | 30 +- frontend/src/pages/admin/index.ts | 2 +- frontend/src/utils/parsers.ts | 166 ++++ 34 files changed, 2450 insertions(+), 3309 deletions(-) create mode 100644 backend/src/config/multerConfig.js delete mode 100644 backend/src/middleware/auth-enhanced-v2.js delete mode 100644 backend/src/middleware/auth-enhanced.js delete mode 100644 backend/src/routes/auth-enhanced-v2.js delete mode 100644 backend/src/routes/auth-enhanced.js create mode 100644 backend/src/services/eventService.js create mode 100644 backend/src/utils/parsers.js delete mode 100644 frontend/src/pages/admin/CMSPageEnhanced.tsx delete mode 100644 frontend/src/pages/admin/CreateEventPageEnhanced.tsx create mode 100644 frontend/src/utils/parsers.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index dda58902..cd1b54f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -262,6 +262,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.940.0.tgz", "integrity": "sha512-Wi4qnBT6shRRMXuuTgjMFTU5mu2KFWisgcigEMPptjPGUtJvBVi4PTGgS64qsLoUk/obqDAyOBOfEtRZ2ddC2w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -1025,6 +1026,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3831,6 +3833,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4431,6 +4434,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5396,29 +5400,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -5546,6 +5527,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6703,6 +6685,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, diff --git a/backend/server.js b/backend/server.js index 6693963f..fb9f9e6c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -34,7 +34,7 @@ const { } = require('./src/utils/tokenUtils'); // Import routes -const authRoutes = require('./src/routes/auth-enhanced'); +const authRoutes = require('./src/routes/auth'); const eventRoutes = require('./src/routes/events'); const galleryRoutes = require('./src/routes/gallery'); const adminRoutes = require('./src/routes/admin'); diff --git a/backend/src/config/multerConfig.js b/backend/src/config/multerConfig.js new file mode 100644 index 00000000..98365cce --- /dev/null +++ b/backend/src/config/multerConfig.js @@ -0,0 +1,255 @@ +/** + * Centralized Multer Configuration Factory + * Provides pre-configured multer instances for different upload scenarios + * + * @module config/multerConfig + */ + +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { validateFileType } = require('../utils/fileSecurityUtils'); + +/** + * Get the storage path from environment or default + * @returns {string} + */ +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Default allowed MIME types for different upload types + */ +const ALLOWED_TYPES = { + photos: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], + videos: ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'], + media: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'], + logos: ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'], + favicons: ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'], + documents: ['application/pdf', 'text/plain'] +}; + +/** + * Default file size limits (in bytes) + */ +const SIZE_LIMITS = { + small: 1 * 1024 * 1024, // 1MB + medium: 5 * 1024 * 1024, // 5MB + large: 50 * 1024 * 1024, // 50MB + xlarge: 500 * 1024 * 1024, // 500MB + huge: 10 * 1024 * 1024 * 1024 // 10GB (for large videos) +}; + +/** + * Create a disk storage configuration + * + * @param {Object} options - Storage options + * @param {string} options.subdir - Subdirectory within storage path + * @param {Function} [options.filename] - Custom filename generator + * @param {boolean} [options.useTemp] - Use temp directory instead + * @returns {multer.StorageEngine} + */ +const createDiskStorage = (options = {}) => { + const { subdir, filename, useTemp = false } = options; + + return multer.diskStorage({ + destination: async (req, file, cb) => { + try { + let uploadDir; + if (useTemp) { + uploadDir = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`); + } else { + uploadDir = path.join(getStoragePath(), subdir || 'uploads'); + } + // Create directory synchronously to prevent race conditions + fs.mkdirSync(uploadDir, { recursive: true }); + cb(null, uploadDir); + } catch (error) { + cb(error); + } + }, + filename: filename || ((req, file, cb) => { + const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + const ext = path.extname(file.originalname); + const baseName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '_'); + cb(null, `${baseName}-${uniqueSuffix}${ext}`); + }) + }); +}; + +/** + * Create a file filter function + * + * @param {string[]} allowedTypes - Array of allowed MIME types + * @param {Object} [options] - Filter options + * @param {boolean} [options.validateMagicNumbers] - Whether to validate file magic numbers + * @param {string[]} [options.skipMagicValidation] - MIME types to skip magic number validation for + * @returns {Function} Multer file filter function + */ +const createFileFilter = (allowedTypes, options = {}) => { + const { validateMagicNumbers = true, skipMagicValidation = [] } = options; + + return (req, file, cb) => { + // Basic MIME type check + if (!allowedTypes.includes(file.mimetype)) { + return cb(new Error(`File type ${file.mimetype} not allowed. Allowed types: ${allowedTypes.join(', ')}`)); + } + + // Validate file type with magic numbers (if enabled and not skipped) + if (validateMagicNumbers && !skipMagicValidation.includes(file.mimetype)) { + if (validateFileType && !validateFileType(file.originalname, file.mimetype, allowedTypes)) { + return cb(new Error('File content does not match file type')); + } + } + + cb(null, true); + }; +}; + +/** + * Create a multer instance for photo uploads + * + * @param {Object} [options] - Override options + * @returns {multer.Multer} + */ +const createPhotoUploader = (options = {}) => { + const defaults = { + storage: createDiskStorage({ useTemp: true }), + limits: { + fileSize: options.maxSize || SIZE_LIMITS.huge, + files: options.maxFiles || 2000, + fieldSize: 10 * 1024 * 1024, + parts: 10000, + headerPairs: 2000 + }, + fileFilter: createFileFilter(ALLOWED_TYPES.media, { + validateMagicNumbers: true + }) + }; + + return multer({ ...defaults, ...options }); +}; + +/** + * Create a multer instance for logo uploads + * + * @param {Object} [options] - Override options + * @returns {multer.Multer} + */ +const createLogoUploader = (options = {}) => { + const defaults = { + storage: createDiskStorage({ + subdir: 'uploads/logos', + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `logo-${Date.now()}${ext}`); + } + }), + limits: { + fileSize: options.maxSize || SIZE_LIMITS.medium + }, + fileFilter: createFileFilter(ALLOWED_TYPES.logos, { + skipMagicValidation: ['image/svg+xml'] + }) + }; + + return multer({ ...defaults, ...options }); +}; + +/** + * Create a multer instance for favicon uploads + * + * @param {Object} [options] - Override options + * @returns {multer.Multer} + */ +const createFaviconUploader = (options = {}) => { + const defaults = { + storage: createDiskStorage({ + subdir: 'uploads/favicons', + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `favicon-${Date.now()}${ext}`); + } + }), + limits: { + fileSize: options.maxSize || SIZE_LIMITS.small + }, + fileFilter: createFileFilter(ALLOWED_TYPES.favicons, { + skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon'] + }) + }; + + return multer({ ...defaults, ...options }); +}; + +/** + * Create a multer instance for gallery user uploads + * + * @param {string} destDir - Destination directory + * @param {Object} [options] - Override options + * @returns {multer.Multer} + */ +const createGalleryUploader = (destDir, options = {}) => { + const defaults = { + dest: destDir, + limits: { + fileSize: options.maxSize || SIZE_LIMITS.large, + files: options.maxFiles || 10 + }, + fileFilter: createFileFilter(ALLOWED_TYPES.photos) + }; + + return multer({ ...defaults, ...options }); +}; + +/** + * Create a custom multer instance + * + * @param {Object} config - Full multer configuration + * @returns {multer.Multer} + */ +const createCustomUploader = (config) => { + return multer(config); +}; + +/** + * Upload timeout middleware + * + * @param {number} [timeout=300000] - Timeout in milliseconds (default 5 minutes) + * @returns {Function} Express middleware + */ +const uploadTimeoutMiddleware = (timeout = 300000) => { + return (req, res, next) => { + req.setTimeout(timeout, () => { + console.error('Upload request timed out'); + if (!res.headersSent) { + res.status(408).json({ error: 'Upload request timed out' }); + } + }); + + res.setTimeout(timeout, () => { + console.error('Upload response timed out'); + }); + + next(); + }; +}; + +module.exports = { + // Pre-configured uploaders + createPhotoUploader, + createLogoUploader, + createFaviconUploader, + createGalleryUploader, + createCustomUploader, + + // Building blocks for custom configurations + createDiskStorage, + createFileFilter, + + // Middleware + uploadTimeoutMiddleware, + + // Constants + ALLOWED_TYPES, + SIZE_LIMITS +}; diff --git a/backend/src/middleware/auth-enhanced-v2.js b/backend/src/middleware/auth-enhanced-v2.js deleted file mode 100644 index d78ea479..00000000 --- a/backend/src/middleware/auth-enhanced-v2.js +++ /dev/null @@ -1,169 +0,0 @@ -const jwt = require('jsonwebtoken'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { isTokenRevoked } = require('../utils/tokenRevocation'); -const logger = require('../utils/logger'); -const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); - -/** - * Enhanced admin authentication middleware with revocation checking - */ -async function adminAuth(req, res, next) { - try { - const token = getAdminTokenFromRequest(req); - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - issuer: 'picpeak-auth', - complete: true - }); - decoded = decoded.payload; - } catch (err) { - if (err.name === 'TokenExpiredError') { - return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); - } - return res.status(401).json({ error: 'Invalid token' }); - } - - // Check if token is revoked - if (await isTokenRevoked(decoded)) { - logger.warn('Revoked token used', { - userId: decoded.id, - tokenType: decoded.type - }); - return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); - } - - // Verify token type - if (decoded.type !== 'admin') { - logger.warn('Non-admin token used for admin endpoint', { - userId: decoded.id, - tokenType: decoded.type - }); - return res.status(403).json({ error: 'Insufficient permissions' }); - } - - // IP validation (optional - can be strict or just log) - const currentIp = req.ip || req.connection.remoteAddress; - if (decoded.ip && decoded.ip !== currentIp) { - logger.warn('Token used from different IP', { - userId: decoded.id, - tokenIp: decoded.ip, - currentIp: currentIp - }); - } - - // Check if admin still exists and is active - const admin = await db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .first(); - - if (!admin) { - return res.status(401).json({ error: 'Invalid token' }); - } - - // Check if password was changed after token was issued - if (admin.password_changed_at) { - const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; - if (decoded.iat < passwordChangedTime) { - logger.warn('Token used after password change', { userId: decoded.id }); - return res.status(401).json({ - error: 'Token invalid due to password change', - code: 'PASSWORD_CHANGED' - }); - } - } - - // Add user info to request - req.admin = { - id: admin.id, - username: admin.username, - email: admin.email - }; - req.token = token; // Store token for potential revocation - - next(); - } catch (error) { - logger.error('Auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); - } -} - -/** - * Enhanced gallery authentication middleware with revocation checking - */ -async function galleryAuth(req, res, next) { - try { - const slug = req.params?.slug || req.requestedSlug; - const token = getGalleryTokenFromRequest(req, slug); - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - issuer: 'picpeak-auth', - complete: true - }); - decoded = decoded.payload; - } catch (err) { - if (err.name === 'TokenExpiredError') { - return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' }); - } - return res.status(401).json({ error: 'Invalid session' }); - } - - // Check if token is revoked - if (await isTokenRevoked(decoded)) { - return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' }); - } - - // Verify token type - if (decoded.type !== 'gallery') { - return res.status(403).json({ error: 'Invalid access token' }); - } - - // Check if event still exists and is active - 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' }); - } - - // Check if gallery has expired - if (new Date(event.expires_at) < new Date()) { - return res.status(410).json({ - error: 'Gallery has expired', - code: 'GALLERY_EXPIRED' - }); - } - - // Add event info to request - req.event = event; - req.galleryToken = decoded; - req.token = token; - - next(); - } catch (error) { - logger.error('Gallery auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); - } -} - -// Export other middleware functions from original file... -module.exports = { - adminAuth, - galleryAuth, - // ... other exports -}; diff --git a/backend/src/middleware/auth-enhanced.js b/backend/src/middleware/auth-enhanced.js deleted file mode 100644 index db942832..00000000 --- a/backend/src/middleware/auth-enhanced.js +++ /dev/null @@ -1,241 +0,0 @@ -const jwt = require('jsonwebtoken'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const logger = require('../utils/logger'); -const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); - -/** - * Enhanced admin authentication middleware - * Adds additional security checks beyond basic JWT validation - */ -async function adminAuth(req, res, next) { - try { - const token = getAdminTokenFromRequest(req); - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - issuer: 'picpeak-auth', - complete: true - }); - decoded = decoded.payload; // Extract payload when using complete: true - } catch (err) { - if (err.name === 'TokenExpiredError') { - return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); - } - return res.status(401).json({ error: 'Invalid token' }); - } - - // Verify token type - if (decoded.type !== 'admin') { - logger.warn('Non-admin token used for admin endpoint', { - userId: decoded.id, - tokenType: decoded.type - }); - return res.status(403).json({ error: 'Insufficient permissions' }); - } - - // IP validation (optional - can be strict or just log) - const currentIp = req.ip || req.connection.remoteAddress; - if (decoded.ip && decoded.ip !== currentIp) { - logger.warn('Token used from different IP', { - userId: decoded.id, - tokenIp: decoded.ip, - currentIp: currentIp - }); - // Optional: Reject if IP doesn't match - // return res.status(401).json({ error: 'Invalid token' }); - } - - // Check if admin still exists and is active - const admin = await db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .first(); - - if (!admin) { - return res.status(401).json({ error: 'Invalid token' }); - } - - // Check if password was changed after token was issued - if (admin.password_changed_at) { - const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; - if (decoded.iat < passwordChangedTime) { - logger.warn('Token used after password change', { userId: decoded.id }); - return res.status(401).json({ - error: 'Token invalid due to password change', - code: 'PASSWORD_CHANGED' - }); - } - } - - // Add user info to request - req.admin = { - id: admin.id, - username: admin.username, - email: admin.email - }; - - next(); - } catch (error) { - logger.error('Auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); - } -} - -/** - * Enhanced gallery authentication middleware - */ -async function galleryAuth(req, res, next) { - try { - const slug = req.params?.slug || req.requestedSlug; - const token = getGalleryTokenFromRequest(req, slug); - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - issuer: 'picpeak-auth', - complete: true - }); - decoded = decoded.payload; - } catch (err) { - if (err.name === 'TokenExpiredError') { - return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' }); - } - return res.status(401).json({ error: 'Invalid session' }); - } - - // Verify token type - if (decoded.type !== 'gallery') { - return res.status(403).json({ error: 'Invalid access token' }); - } - - // Check if event still exists and is active - 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' }); - } - - // Check if gallery has expired - if (new Date(event.expires_at) < new Date()) { - return res.status(410).json({ - error: 'Gallery has expired', - code: 'GALLERY_EXPIRED' - }); - } - - // Add event info to request - req.event = event; - req.galleryToken = decoded; - - next(); - } catch (error) { - logger.error('Gallery auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); - } -} - -/** - * Photo access authentication - * Validates both admin and gallery tokens for photo access - */ -async function photoAuth(req, res, next) { - try { - const slug = req.params?.slug || req.requestedSlug; - const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); - if (!token) { - return res.status(401).json({ error: 'Authentication required' }); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET); - } catch (err) { - return res.status(401).json({ error: 'Invalid token' }); - } - - // Allow both admin and gallery tokens - if (decoded.type === 'admin') { - const admin = await db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .first(); - - if (!admin) { - return res.status(401).json({ error: 'Invalid token' }); - } - - req.auth = { type: 'admin', user: admin }; - } else if (decoded.type === 'gallery') { - 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' }); - } - - // For gallery tokens, ensure they can only access their event's photos - req.auth = { type: 'gallery', event: event }; - } else { - return res.status(403).json({ error: 'Invalid token type' }); - } - - next(); - } catch (error) { - logger.error('Photo auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); - } -} - -/** - * Verify gallery access for specific operations - */ -async function verifyGalleryAccess(req, res, next) { - try { - if (!req.auth) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const { eventId } = req.params; - - // Admins can access any gallery - if (req.auth.type === 'admin') { - return next(); - } - - // Gallery tokens can only access their own event - if (req.auth.type === 'gallery') { - if (req.auth.event.id !== parseInt(eventId)) { - return res.status(403).json({ error: 'Access denied' }); - } - return next(); - } - - res.status(403).json({ error: 'Access denied' }); - } catch (error) { - res.status(500).json({ error: 'Access verification failed' }); - } -} - -module.exports = { - adminAuth, - galleryAuth, - photoAuth, - verifyGalleryAccess -}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 4f855ce8..fb9c5332 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,98 +1,260 @@ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { isTokenRevoked } = require('../utils/tokenRevocation'); const logger = require('../utils/logger'); -const { getAdminTokenFromRequest } = require('../utils/tokenUtils'); +const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); +/** + * Enhanced admin authentication middleware with revocation checking + */ async function adminAuth(req, res, next) { try { const token = getAdminTokenFromRequest(req); if (!token) { - const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - logger.warn('Admin auth attempt without token', { - ip: clientIp, - path: req.path, - method: req.method, - userAgent: req.headers['user-agent'] - }); return res.status(401).json({ error: 'No token provided' }); } let decoded; try { - // Try to verify with issuer first, fallback to no issuer for backward compatibility - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - issuer: 'picpeak-auth' - }); - } catch (issuerError) { - // If verification fails with issuer, try without issuer (backward compatibility) - if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET); - } else { - throw issuerError; - } - } - } catch (jwtError) { - const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - - logger.warn('JWT validation failed', { - ip: clientIp, - path: req.path, - method: req.method, - userAgent: req.headers['user-agent'], - error: jwtError.name, - message: jwtError.message, - timestamp: new Date().toISOString() + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true }); - - if (jwtError.name === 'TokenExpiredError') { - return res.status(401).json({ error: 'Token expired' }); + decoded = decoded.payload; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); } return res.status(401).json({ error: 'Invalid token' }); } - const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first(); + // Check if token is revoked + if (await isTokenRevoked(decoded)) { + logger.warn('Revoked token used', { + userId: decoded.id, + tokenType: decoded.type + }); + return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); + } + + // Verify token type + if (decoded.type !== 'admin') { + logger.warn('Non-admin token used for admin endpoint', { + userId: decoded.id, + tokenType: decoded.type + }); + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + // IP validation (optional - can be strict or just log) + const currentIp = req.ip || req.connection.remoteAddress; + if (decoded.ip && decoded.ip !== currentIp) { + logger.warn('Token used from different IP', { + userId: decoded.id, + tokenIp: decoded.ip, + currentIp: currentIp + }); + } + + // Check if admin still exists and is active + const admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .first(); if (!admin) { - const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - - logger.warn('Admin auth failed - user not found or inactive', { - ip: clientIp, - userId: decoded.id, - path: req.path, - method: req.method, - timestamp: new Date().toISOString() - }); return res.status(401).json({ error: 'Invalid token' }); } - req.admin = admin; + // Check if password was changed after token was issued + if (admin.password_changed_at) { + const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; + if (decoded.iat < passwordChangedTime) { + logger.warn('Token used after password change', { userId: decoded.id }); + return res.status(401).json({ + error: 'Token invalid due to password change', + code: 'PASSWORD_CHANGED' + }); + } + } + + // Add user info to request + req.admin = { + id: admin.id, + username: admin.username, + email: admin.email + }; + req.token = token; // Store token for potential revocation + next(); } catch (error) { - const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || - req.headers['x-real-ip'] || - req.connection.remoteAddress || - req.ip; - - logger.error('Admin auth middleware error', { - ip: clientIp, - path: req.path, - error: error.message, - stack: error.stack, - timestamp: new Date().toISOString() - }); - res.status(401).json({ error: 'Invalid token' }); + logger.error('Auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); } } -module.exports = { adminAuth }; +/** + * Enhanced gallery authentication middleware with revocation checking + */ +async function galleryAuth(req, res, next) { + try { + const slug = req.params?.slug || req.requestedSlug; + const token = getGalleryTokenFromRequest(req, slug); + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true + }); + decoded = decoded.payload; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid session' }); + } + + // Check if token is revoked + if (await isTokenRevoked(decoded)) { + return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' }); + } + + // Verify token type + if (decoded.type !== 'gallery') { + return res.status(403).json({ error: 'Invalid access token' }); + } + + // Check if event still exists and is active + 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' }); + } + + // Check if gallery has expired + if (new Date(event.expires_at) < new Date()) { + return res.status(410).json({ + error: 'Gallery has expired', + code: 'GALLERY_EXPIRED' + }); + } + + // Add event info to request + req.event = event; + req.galleryToken = decoded; + req.token = token; + + next(); + } catch (error) { + logger.error('Gallery auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Photo access authentication + * Validates both admin and gallery tokens for photo access + */ +async function photoAuth(req, res, next) { + try { + const slug = req.params?.slug || req.requestedSlug; + const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Check if token is revoked + if (await isTokenRevoked(decoded)) { + return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); + } + + // Allow both admin and gallery tokens + if (decoded.type === 'admin') { + const admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .first(); + + if (!admin) { + return res.status(401).json({ error: 'Invalid token' }); + } + + req.auth = { type: 'admin', user: admin }; + } else if (decoded.type === 'gallery') { + 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' }); + } + + // For gallery tokens, ensure they can only access their event's photos + req.auth = { type: 'gallery', event: event }; + } else { + return res.status(403).json({ error: 'Invalid token type' }); + } + + next(); + } catch (error) { + logger.error('Photo auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Verify gallery access for specific operations + */ +async function verifyGalleryAccess(req, res, next) { + try { + if (!req.auth) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { eventId } = req.params; + + // Admins can access any gallery + if (req.auth.type === 'admin') { + return next(); + } + + // Gallery tokens can only access their own event + if (req.auth.type === 'gallery') { + if (req.auth.event.id !== parseInt(eventId)) { + return res.status(403).json({ error: 'Access denied' }); + } + return next(); + } + + res.status(403).json({ error: 'Access denied' }); + } catch (error) { + res.status(500).json({ error: 'Access verification failed' }); + } +} + +module.exports = { + adminAuth, + galleryAuth, + photoAuth, + verifyGalleryAccess +}; diff --git a/backend/src/routes/__tests__/adminAuth.test.js b/backend/src/routes/__tests__/adminAuth.test.js index 1e0934fd..934fd464 100644 --- a/backend/src/routes/__tests__/adminAuth.test.js +++ b/backend/src/routes/__tests__/adminAuth.test.js @@ -27,7 +27,7 @@ jest.mock('../../database/db', () => { }; }); -jest.mock('../../middleware/auth-enhanced-v2', () => ({ +jest.mock('../../middleware/auth', () => ({ adminAuth: (_req, _res, next) => { _req.admin = { id: 1, username: 'admin' }; next(); diff --git a/backend/src/routes/__tests__/adminNotifications.test.js b/backend/src/routes/__tests__/adminNotifications.test.js index ae956f41..d1416545 100644 --- a/backend/src/routes/__tests__/adminNotifications.test.js +++ b/backend/src/routes/__tests__/adminNotifications.test.js @@ -24,7 +24,7 @@ jest.mock('../../database/db', () => { return { db: dbMock }; }); -jest.mock('../../middleware/auth-enhanced-v2', () => ({ +jest.mock('../../middleware/auth', () => ({ adminAuth: (_req, _res, next) => next(), })); diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index 9bd4c2c3..445ec6ff 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -3,7 +3,7 @@ const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const archiver = require('archiver'); const AdmZip = require('adm-zip'); const router = express.Router(); diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index 481243b2..0b31888c 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -2,7 +2,7 @@ const express = require('express'); const bcrypt = require('bcrypt'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const { endSession } = require('../middleware/sessionTimeout'); const { validatePasswordStrength } = require('../utils/passwordGenerator'); const router = express.Router(); diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 758f15e2..b5ecb1bb 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -1,7 +1,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const router = express.Router(); // Get all CMS pages diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index f3d9000b..c160dc31 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -2,7 +2,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const router = express.Router(); // Get all global categories diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 40bbdfd5..73fea443 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -1,6 +1,6 @@ const express = require('express'); const { db } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { formatBoolean } = require('../utils/dbCompat'); const router = express.Router(); diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index d7c95e83..deb627f8 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -2,7 +2,7 @@ const express = require('express'); const nodemailer = require('nodemailer'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const router = express.Router(); // Get email configuration diff --git a/backend/src/routes/adminEventRename.js b/backend/src/routes/adminEventRename.js index efd93926..ab859be6 100644 --- a/backend/src/routes/adminEventRename.js +++ b/backend/src/routes/adminEventRename.js @@ -5,7 +5,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const eventRenameService = require('../services/eventRenameService'); const router = express.Router(); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index fe9111df..2b0a61b3 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -2,7 +2,7 @@ const express = require('express'); const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const router = express.Router(); const bcrypt = require('bcrypt'); const crypto = require('crypto'); @@ -15,6 +15,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const logger = require('../utils/logger'); const { buildShareLinkVariants } = require('../services/shareLinkService'); +const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers'); // Helper to get event field requirements from settings const getEventFieldRequirements = async () => { @@ -58,43 +59,9 @@ const getEventFieldRequirements = async () => { } }; -const parseBooleanInput = (value, defaultValue = true) => { - if (value === undefined || value === null) { - return defaultValue; - } - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'number') { - return value !== 0; - } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - if (['false', '0', 'no', 'off'].includes(normalized)) { - return false; - } - if (['true', '1', 'yes', 'on'].includes(normalized)) { - return true; - } - } - return defaultValue; -}; - -const getCustomerNameFromPayload = (payload = {}) => { - if (typeof payload.customer_name === 'string') { - const trimmed = payload.customer_name.trim(); - return trimmed || null; - } - return null; -}; - -const getCustomerEmailFromPayload = (payload = {}) => { - if (typeof payload.customer_email === 'string') { - const trimmed = payload.customer_email.trim(); - return trimmed || null; - } - return null; -}; +// Use parseStringInput from shared parsers for customer data extraction +const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); +const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index fb4ceebf..02d9c01f 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const feedbackService = require('../services/feedbackService'); const feedbackModeration = require('../services/feedbackModeration'); const { db, logActivity } = require('../database/db'); diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index ffe4d680..46f310d9 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -1,6 +1,6 @@ const express = require('express'); const { db, logActivity } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const router = express.Router(); // Get notifications (unread activity logs) diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index 3362397b..ac6e2d66 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -1,6 +1,6 @@ const express = require('express'); const { db, withRetry } = require('../database/db'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); diff --git a/backend/src/routes/auth-enhanced-v2.js b/backend/src/routes/auth-enhanced-v2.js deleted file mode 100644 index fccc390c..00000000 --- a/backend/src/routes/auth-enhanced-v2.js +++ /dev/null @@ -1,393 +0,0 @@ -const express = require('express'); -const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); -const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { verifyRecaptcha } = require('../services/recaptcha'); -const { - trackFailedAttempt, - trackSuccessfulLogin, - checkAccountLockout, - checkSuspiciousActivity, - getGenericAuthError -} = require('../utils/authSecurity'); -const { - validatePasswordInContext, - getBcryptRounds, - logPasswordValidationFailure -} = require('../utils/passwordValidation'); -const { endSession } = require('../middleware/sessionTimeout'); -const logger = require('../utils/logger'); -const { getClientIp } = require('../utils/requestIp'); -const router = express.Router(); - -// Admin login with enhanced security -router.post('/admin/login', [ - body('username').notEmpty().trim(), - body('password').notEmpty() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { username, password, recaptchaToken } = req.body; - const ipAddress = getClientIp(req); - const userAgent = req.headers['user-agent'] || ''; - - // Check account lockout first - const lockoutStatus = await checkAccountLockout(username); - if (lockoutStatus.isLocked) { - logger.warn('Login attempt on locked account', { username, ipAddress }); - return res.status(423).json({ - error: 'Account temporarily locked due to too many failed attempts', - retryAfter: lockoutStatus.remainingTime - }); - } - - // Verify reCAPTCHA - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - - // Check for suspicious activity - const isSuspicious = await checkSuspiciousActivity(username, ipAddress); - if (isSuspicious) { - // Still allow login but log it - logger.warn('Suspicious login pattern detected', { username, ipAddress }); - } - - const admin = await db('admin_users') - .where({ username }) - .orWhere({ email: username }) - .first(); - - // Use generic error to prevent user enumeration - if (!admin || !await bcrypt.compare(password, admin.password_hash)) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(401).json({ error: getGenericAuthError() }); - } - - if (!admin.is_active) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(401).json({ error: getGenericAuthError() }); - } - - // Successful login - await trackSuccessfulLogin(username, ipAddress, userAgent); - - // Update last login and login metadata - await db('admin_users').where('id', admin.id).update({ - last_login: new Date(), - last_login_ip: ipAddress - }); - - // Generate token with additional claims - const token = jwt.sign({ - id: admin.id, - username: admin.username, - type: 'admin', - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - res.json({ - token, - user: { - id: admin.id, - username: admin.username, - email: admin.email, - mustChangePassword: admin.must_change_password || false - } - }); - } catch (error) { - logger.error('Login error:', error); - res.status(500).json({ error: 'Login failed' }); - } -}); - -// Admin password change with validation -router.post('/admin/change-password', [ - body('currentPassword').notEmpty(), - body('newPassword').notEmpty(), - body('confirmPassword').notEmpty() - .custom((value, { req }) => value === req.body.newPassword) - .withMessage('Passwords do not match') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { currentPassword, newPassword } = req.body; - const adminId = req.admin.id; // From auth middleware - - // Get admin user - const admin = await db('admin_users').where({ id: adminId }).first(); - if (!admin) { - return res.status(404).json({ error: 'User not found' }); - } - - // Verify current password - const validPassword = await bcrypt.compare(currentPassword, admin.password_hash); - if (!validPassword) { - return res.status(401).json({ error: 'Current password is incorrect' }); - } - - // Validate new password - const passwordValidation = validatePasswordInContext(newPassword, 'admin', { - username: admin.username, - email: admin.email - }); - - if (!passwordValidation.valid) { - logPasswordValidationFailure('admin_password_change', passwordValidation.errors, { - userId: adminId, - username: admin.username - }); - - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback - }); - } - - // Hash new password with configurable rounds - const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds()); - - // Update password and track change time - await db('admin_users').where('id', adminId).update({ - password_hash: hashedPassword, - password_changed_at: new Date(), - must_change_password: false - }); - - // Log password change - logger.info('Admin password changed', { - userId: adminId, - username: admin.username, - ip: ipAddress - }); - - res.json({ - message: 'Password changed successfully', - score: passwordValidation.score - }); - } catch (error) { - logger.error('Password change error:', error); - res.status(500).json({ error: 'Failed to change password' }); - } -}); - -// Logout endpoint -router.post('/logout', async (req, res) => { - try { - const token = req.headers.authorization?.split(' ')[1]; - - if (token) { - // End the session - endSession(token); - - // Log the logout - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - logger.info('User logged out', { - userId: decoded.id, - username: decoded.username, - type: decoded.type - }); - } catch (err) { - // Token might be invalid, but still process logout - } - } - - res.json({ message: 'Logged out successfully' }); - } catch (error) { - logger.error('Logout error:', error); - res.status(500).json({ error: 'Logout failed' }); - } -}); - -// Gallery password verification with enhanced security -router.post('/gallery/verify', [ - body('slug').notEmpty().trim(), - body('password').optional().isString() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { slug, password, recaptchaToken } = req.body; - const ipAddress = getClientIp(req); - const userAgent = req.headers['user-agent'] || ''; - - const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); - const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0')); - - if (requiresPassword) { - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress); - if (lockoutStatus.isLocked) { - logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); - return res.status(423).json({ - error: 'Too many failed attempts. Please try again later.', - retryAfter: lockoutStatus.remainingTime - }); - } - - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - } - - if (!event) { - // Don't reveal if gallery exists - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - if (requiresPassword) { - if (!password) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_fail' - }); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - } else { - logger.info('Public gallery access granted without password', { slug, ipAddress }); - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - } - - // Generate session token with additional security info - const token = jwt.sign({ - eventId: event.id, - eventSlug: event.slug, - type: 'gallery', - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - res.json({ - token, - event: { - id: event.id, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - welcome_message: event.welcome_message, - color_theme: event.color_theme, - expires_at: event.expires_at, - allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id, - require_password: requiresPassword - } - }); - } catch (error) { - logger.error('Gallery verification error:', error); - res.status(500).json({ error: 'Verification failed' }); - } -}); - -// Get current session info -router.get('/session', async (req, res) => { - try { - const token = req.headers.authorization?.split(' ')[1]; - - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - // Calculate remaining time - const now = Date.now() / 1000; - const remainingTime = Math.max(0, decoded.exp - now); - - res.json({ - valid: true, - type: decoded.type, - expiresIn: Math.floor(remainingTime), - user: decoded.username || decoded.eventSlug - }); - } catch (err) { - res.json({ - valid: false, - error: 'Invalid or expired token' - }); - } - } catch (error) { - res.status(500).json({ error: 'Session check failed' }); - } -}); - -// Password strength check endpoint (for real-time validation) -router.post('/password-strength', [ - body('password').notEmpty(), - body('context').isIn(['admin', 'gallery']).optional() -], async (req, res) => { - try { - const { password, context = 'gallery' } = req.body; - - // Get user data if available (for context-aware validation) - const userData = {}; - if (context === 'admin' && req.admin) { - userData.username = req.admin.username; - userData.email = req.admin.email; - } - - const validation = validatePasswordInContext(password, context, userData); - - res.json({ - valid: validation.valid, - score: validation.score, - errors: validation.errors, - feedback: validation.feedback - }); - } catch (error) { - res.status(500).json({ error: 'Failed to check password strength' }); - } -}); - -module.exports = router; diff --git a/backend/src/routes/auth-enhanced.js b/backend/src/routes/auth-enhanced.js deleted file mode 100644 index f675137a..00000000 --- a/backend/src/routes/auth-enhanced.js +++ /dev/null @@ -1,395 +0,0 @@ -const express = require('express'); -const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); -const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { verifyRecaptcha } = require('../services/recaptcha'); -const { - trackFailedAttempt, - trackSuccessfulLogin, - checkAccountLockout, - checkSuspiciousActivity, - getGenericAuthError -} = require('../utils/authSecurity'); -const { endSession } = require('../middleware/sessionTimeout'); -const logger = require('../utils/logger'); -const { - setAdminAuthCookie, - clearAdminAuthCookie, - setGalleryAuthCookies, - clearGalleryAuthCookies, - getAdminTokenFromRequest, - getGalleryTokenFromRequest, -} = require('../utils/tokenUtils'); -const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService'); -const { getClientIp } = require('../utils/requestIp'); -const router = express.Router(); - -// Admin login with enhanced security -router.post('/admin/login', [ - body('username').notEmpty().trim(), - body('password').notEmpty() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { username, password, recaptchaToken } = req.body; - const ipAddress = getClientIp(req); - const userAgent = req.headers['user-agent'] || ''; - - // Check account lockout first - const lockoutStatus = await checkAccountLockout(username); - if (lockoutStatus.isLocked) { - logger.warn('Login attempt on locked account', { username, ipAddress }); - return res.status(423).json({ - error: 'Account temporarily locked due to too many failed attempts', - retryAfter: lockoutStatus.remainingTime - }); - } - - // Verify reCAPTCHA - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - - // Check for suspicious activity - const isSuspicious = await checkSuspiciousActivity(username, ipAddress); - if (isSuspicious) { - // Still allow login but log it - logger.warn('Suspicious login pattern detected', { username, ipAddress }); - } - - const admin = await db('admin_users') - .where({ username }) - .orWhere({ email: username }) - .first(); - - // Use generic error to prevent user enumeration - if (!admin || !await bcrypt.compare(password, admin.password_hash)) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(401).json({ error: getGenericAuthError() }); - } - - if (!admin.is_active) { - await trackFailedAttempt(username, ipAddress, userAgent); - return res.status(401).json({ error: getGenericAuthError() }); - } - - // Successful login - await trackSuccessfulLogin(username, ipAddress, userAgent); - - // Update last login and login metadata - await db('admin_users').where('id', admin.id).update({ - last_login: new Date(), - last_login_ip: ipAddress - }); - - // Generate token with additional claims - const token = jwt.sign({ - id: admin.id, - username: admin.username, - type: 'admin', - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - setAdminAuthCookie(res, token); - - res.json({ - token, - user: { - id: admin.id, - username: admin.username, - email: admin.email, - mustChangePassword: admin.must_change_password || false - } - }); - } catch (error) { - logger.error('Login error:', error); - res.status(500).json({ error: 'Login failed' }); - } -}); - -// Logout endpoint -router.post('/logout', async (req, res) => { - try { - const adminToken = getAdminTokenFromRequest(req); - const galleryToken = getGalleryTokenFromRequest(req); - const token = adminToken || galleryToken; - - if (token) { - // End the session - endSession(token); - - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - logger.info('User logged out', { - userId: decoded.id, - username: decoded.username, - type: decoded.type - }); - - if (decoded.type === 'admin') { - clearAdminAuthCookie(res); - } else if (decoded.type === 'gallery') { - clearGalleryAuthCookies(res, decoded.eventSlug); - } - } catch (err) { - // Token might be invalid, but still process logout and clear cookies - clearAdminAuthCookie(res); - clearGalleryAuthCookies(res); - } - } else { - // No token found, but ensure cookies are cleared - clearAdminAuthCookie(res); - clearGalleryAuthCookies(res); - } - - res.json({ message: 'Logged out successfully' }); - } catch (error) { - logger.error('Logout error:', error); - res.status(500).json({ error: 'Logout failed' }); - } -}); - -// Gallery password verification with enhanced security -router.post('/gallery/verify', [ - body('slug').notEmpty().trim(), - body('password').optional().isString() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { slug, password, recaptchaToken } = req.body; - const ipAddress = getClientIp(req); - const userAgent = req.headers['user-agent'] || ''; - const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .first(); - - if (!event) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - - if (requiresPassword) { - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress); - if (lockoutStatus.isLocked) { - logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); - return res.status(423).json({ - error: 'Too many failed attempts. Please try again later.', - retryAfter: lockoutStatus.remainingTime - }); - } - - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - - if (!password) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_fail' - }); - return res.status(401).json({ error: 'Invalid gallery or password' }); - } - - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - } else { - logger.info('Public gallery access granted without password', { slug, ipAddress }); - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - } - - const token = jwt.sign({ - eventId: event.id, - eventSlug: event.slug, - type: 'gallery', - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - setGalleryAuthCookies(res, token, event.slug); - - res.json({ - token, - event: { - id: event.id, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - welcome_message: event.welcome_message, - color_theme: event.color_theme, - expires_at: event.expires_at, - allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id, - require_password: requiresPassword - } - }); - } catch (error) { - logger.error('Gallery verification error:', error); - res.status(500).json({ error: 'Verification failed' }); - } -}); - -// Share link authentication (token-based) -router.post('/gallery/share-login', [ - body('slug').notEmpty().trim(), - body('token').notEmpty() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { slug, token } = req.body; - const ipAddress = getClientIp(req); - const userAgent = req.headers['user-agent'] || ''; - - let event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .first(); - - if (!event) { - const resolved = await resolveShareIdentifier(slug); - if (resolved?.event) { - event = resolved.event; - } - } - - if (!event) { - return res.status(404).json({ error: 'Gallery not found' }); - } - - const expectedToken = getEventShareToken(event); - - if (!expectedToken || token !== expectedToken) { - return res.status(401).json({ error: 'Invalid or expired share link' }); - } - - const jwtToken = jwt.sign({ - eventId: event.id, - eventSlug: event.slug, - type: 'gallery', - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent); - setGalleryAuthCookies(res, jwtToken, event.slug); - - const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - - res.json({ - token: jwtToken, - event: { - id: event.id, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - welcome_message: event.welcome_message, - color_theme: event.color_theme, - expires_at: event.expires_at, - allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id, - require_password: requiresPassword - } - }); - } catch (error) { - logger.error('Share link authentication error:', error); - res.status(500).json({ error: 'Share link login failed' }); - } -}); - -// Gallery logout to clear cookies -router.post('/gallery/logout', async (req, res) => { - try { - const { slug } = req.body || {}; - clearGalleryAuthCookies(res, slug); - res.json({ message: 'Logged out successfully' }); - } catch (error) { - logger.error('Gallery logout error:', error); - res.status(500).json({ error: 'Logout failed' }); - } -}); - -// Get current session info -router.get('/session', async (req, res) => { - try { - const { slug } = req.query; - const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); - - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - // Calculate remaining time - const now = Date.now() / 1000; - const remainingTime = Math.max(0, decoded.exp - now); - - res.json({ - valid: true, - type: decoded.type, - expiresIn: Math.floor(remainingTime), - user: decoded.username || decoded.eventSlug, - eventSlug: decoded.eventSlug, - adminUsername: decoded.username - }); - } catch (err) { - res.json({ - valid: false, - error: 'Invalid or expired token' - }); - } - } catch (error) { - res.status(500).json({ error: 'Session check failed' }); - } -}); - -module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 283bf248..51754d0d 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -5,11 +5,35 @@ const { body, validationResult } = require('express-validator'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { verifyRecaptcha } = require('../services/recaptcha'); +const { + trackFailedAttempt, + trackSuccessfulLogin, + checkAccountLockout, + checkSuspiciousActivity, + getGenericAuthError +} = require('../utils/authSecurity'); +const { endSession } = require('../middleware/sessionTimeout'); +const logger = require('../utils/logger'); +const { + setAdminAuthCookie, + clearAdminAuthCookie, + setGalleryAuthCookies, + clearGalleryAuthCookies, + getAdminTokenFromRequest, + getGalleryTokenFromRequest, +} = require('../utils/tokenUtils'); +const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService'); +const { getClientIp } = require('../utils/requestIp'); +const { + validatePasswordInContext, + getBcryptRounds, + logPasswordValidationFailure +} = require('../utils/passwordValidation'); const router = express.Router(); -// Admin login +// Admin login with enhanced security router.post('/admin/login', [ - body('username').notEmpty(), + body('username').notEmpty().trim(), body('password').notEmpty() ], async (req, res) => { try { @@ -19,30 +43,71 @@ router.post('/admin/login', [ } const { username, password, recaptchaToken } = req.body; + const ipAddress = getClientIp(req); + const userAgent = req.headers['user-agent'] || ''; + + // Check account lockout first + const lockoutStatus = await checkAccountLockout(username); + if (lockoutStatus.isLocked) { + logger.warn('Login attempt on locked account', { username, ipAddress }); + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed attempts', + retryAfter: lockoutStatus.remainingTime + }); + } // Verify reCAPTCHA const recaptchaValid = await verifyRecaptcha(recaptchaToken); if (!recaptchaValid) { + await trackFailedAttempt(username, ipAddress, userAgent); return res.status(400).json({ error: 'reCAPTCHA verification failed' }); } + // Check for suspicious activity + const isSuspicious = await checkSuspiciousActivity(username, ipAddress); + if (isSuspicious) { + // Still allow login but log it + logger.warn('Suspicious login pattern detected', { username, ipAddress }); + } + const admin = await db('admin_users') .where({ username }) .orWhere({ email: username }) .first(); + // Use generic error to prevent user enumeration if (!admin || !await bcrypt.compare(password, admin.password_hash)) { - return res.status(401).json({ error: 'Invalid credentials' }); + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); } if (!admin.is_active) { - return res.status(401).json({ error: 'Account disabled' }); + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); } - // Update last login - await db('admin_users').where('id', admin.id).update({ last_login: new Date() }); + // Successful login + await trackSuccessfulLogin(username, ipAddress, userAgent); - const token = jwt.sign({ id: admin.id, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '24h' }); + // Update last login and login metadata + await db('admin_users').where('id', admin.id).update({ + last_login: new Date(), + last_login_ip: ipAddress + }); + + // Generate token with additional claims + const token = jwt.sign({ + id: admin.id, + username: admin.username, + type: 'admin', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + setAdminAuthCookie(res, token); res.json({ token, @@ -54,14 +119,57 @@ router.post('/admin/login', [ } }); } catch (error) { + logger.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); -// Gallery password verification +// Logout endpoint +router.post('/logout', async (req, res) => { + try { + const adminToken = getAdminTokenFromRequest(req); + const galleryToken = getGalleryTokenFromRequest(req); + const token = adminToken || galleryToken; + + if (token) { + // End the session + endSession(token); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + logger.info('User logged out', { + userId: decoded.id, + username: decoded.username, + type: decoded.type + }); + + if (decoded.type === 'admin') { + clearAdminAuthCookie(res); + } else if (decoded.type === 'gallery') { + clearGalleryAuthCookies(res, decoded.eventSlug); + } + } catch (err) { + // Token might be invalid, but still process logout and clear cookies + clearAdminAuthCookie(res); + clearGalleryAuthCookies(res); + } + } else { + // No token found, but ensure cookies are cleared + clearAdminAuthCookie(res); + clearGalleryAuthCookies(res); + } + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +// Gallery password verification with enhanced security router.post('/gallery/verify', [ - body('slug').notEmpty(), - body('password').notEmpty() + body('slug').notEmpty().trim(), + body('password').optional().isString() ], async (req, res) => { try { const errors = validationResult(req); @@ -70,67 +178,333 @@ router.post('/gallery/verify', [ } const { slug, password, recaptchaToken } = req.body; - - // Verify reCAPTCHA - temporarily disabled for testing - // const recaptchaValid = await verifyRecaptcha(recaptchaToken); - // if (!recaptchaValid) { - // return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - // } - - const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first(); + const ipAddress = getClientIp(req); + const userAgent = req.headers['user-agent'] || ''; + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .first(); + if (!event) { - return res.status(404).json({ error: 'Gallery not found or expired' }); + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); } - - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { + + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + if (requiresPassword) { + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress); + if (lockoutStatus.isLocked) { + logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); + return res.status(423).json({ + error: 'Too many failed attempts. Please try again later.', + retryAfter: lockoutStatus.remainingTime + }); + } + + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + if (!password) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); await db('access_logs').insert({ event_id: event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'login_fail' + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' + }); + } else { + logger.info('Public gallery access granted without password', { slug, ipAddress }); + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' }); - return res.status(401).json({ error: 'Invalid password' }); } - - // Log successful access - await db('access_logs').insert({ - event_id: event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'login_success' - }); - - // Generate session token + const token = jwt.sign({ eventId: event.id, eventSlug: event.slug, - type: 'gallery' - }, process.env.JWT_SECRET, { expiresIn: '24h' }); - - const responseEvent = { - id: event.id, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - welcome_message: event.welcome_message, - color_theme: event.color_theme, - expires_at: event.expires_at, - allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id, - hero_photo_id: event.hero_photo_id, - allow_downloads: event.allow_downloads - }; - - console.log('Auth response event:', JSON.stringify(responseEvent, null, 2)); + type: 'gallery', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + setGalleryAuthCookies(res, token, event.slug); res.json({ token, - event: responseEvent + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id, + require_password: requiresPassword + } }); } catch (error) { + logger.error('Gallery verification error:', error); res.status(500).json({ error: 'Verification failed' }); } }); +// Share link authentication (token-based) +router.post('/gallery/share-login', [ + body('slug').notEmpty().trim(), + body('token').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug, token } = req.body; + const ipAddress = getClientIp(req); + const userAgent = req.headers['user-agent'] || ''; + + let event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .first(); + + if (!event) { + const resolved = await resolveShareIdentifier(slug); + if (resolved?.event) { + event = resolved.event; + } + } + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + const expectedToken = getEventShareToken(event); + + if (!expectedToken || token !== expectedToken) { + return res.status(401).json({ error: 'Invalid or expired share link' }); + } + + const jwtToken = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent); + setGalleryAuthCookies(res, jwtToken, event.slug); + + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + res.json({ + token: jwtToken, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id, + require_password: requiresPassword + } + }); + } catch (error) { + logger.error('Share link authentication error:', error); + res.status(500).json({ error: 'Share link login failed' }); + } +}); + +// Gallery logout to clear cookies +router.post('/gallery/logout', async (req, res) => { + try { + const { slug } = req.body || {}; + clearGalleryAuthCookies(res, slug); + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Gallery logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +// Get current session info +router.get('/session', async (req, res) => { + try { + const { slug } = req.query; + const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); + + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Calculate remaining time + const now = Date.now() / 1000; + const remainingTime = Math.max(0, decoded.exp - now); + + res.json({ + valid: true, + type: decoded.type, + expiresIn: Math.floor(remainingTime), + user: decoded.username || decoded.eventSlug, + eventSlug: decoded.eventSlug, + adminUsername: decoded.username + }); + } catch (err) { + res.json({ + valid: false, + error: 'Invalid or expired token' + }); + } + } catch (error) { + res.status(500).json({ error: 'Session check failed' }); + } +}); + +// Admin password change with validation +router.post('/admin/change-password', [ + body('currentPassword').notEmpty(), + body('newPassword').notEmpty(), + body('confirmPassword').notEmpty() + .custom((value, { req }) => value === req.body.newPassword) + .withMessage('Passwords do not match') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { currentPassword, newPassword } = req.body; + const ipAddress = getClientIp(req); + + // Get admin from request (should be set by auth middleware) + if (!req.admin) { + return res.status(401).json({ error: 'Authentication required' }); + } + const adminId = req.admin.id; + + // Get admin user + const admin = await db('admin_users').where({ id: adminId }).first(); + if (!admin) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify current password + const validPassword = await bcrypt.compare(currentPassword, admin.password_hash); + if (!validPassword) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Validate new password + const passwordValidation = validatePasswordInContext(newPassword, 'admin', { + username: admin.username, + email: admin.email + }); + + if (!passwordValidation.valid) { + logPasswordValidationFailure('admin_password_change', passwordValidation.errors, { + userId: adminId, + username: admin.username + }); + + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + + // Hash new password with configurable rounds + const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds()); + + // Update password and track change time + await db('admin_users').where('id', adminId).update({ + password_hash: hashedPassword, + password_changed_at: new Date(), + must_change_password: false + }); + + // Log password change + logger.info('Admin password changed', { + userId: adminId, + username: admin.username, + ip: ipAddress + }); + + res.json({ + message: 'Password changed successfully', + score: passwordValidation.score + }); + } catch (error) { + logger.error('Password change error:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +// Password strength check endpoint (for real-time validation) +router.post('/password-strength', [ + body('password').notEmpty(), + body('context').isIn(['admin', 'gallery']).optional() +], async (req, res) => { + try { + const { password, context = 'gallery' } = req.body; + + // Get user data if available (for context-aware validation) + const userData = {}; + if (context === 'admin' && req.admin) { + userData.username = req.admin.username; + userData.email = req.admin.email; + } + + const validation = validatePasswordInContext(password, context, userData); + + res.json({ + valid: validation.valid, + score: validation.score, + errors: validation.errors, + feedback: validation.feedback + }); + } catch (error) { + res.status(500).json({ error: 'Failed to check password strength' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index a431373a..357ed65f 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -5,49 +5,16 @@ const crypto = require('crypto'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); -const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { adminAuth } = require('../middleware/auth'); const fs = require('fs').promises; const path = require('path'); const router = express.Router(); const { buildShareLinkVariants } = require('../services/shareLinkService'); +const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); -const parseBooleanInput = (value, defaultValue = true) => { - if (value === undefined || value === null) { - return defaultValue; - } - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'number') { - return value !== 0; - } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - if (['false', '0', 'no', 'off'].includes(normalized)) { - return false; - } - if (['true', '1', 'yes', 'on'].includes(normalized)) { - return true; - } - } - return defaultValue; -}; - -const getCustomerNameFromPayload = (payload = {}) => { - if (typeof payload.customer_name === 'string') { - const trimmed = payload.customer_name.trim(); - return trimmed || null; - } - return null; -}; - -const getCustomerEmailFromPayload = (payload = {}) => { - if (typeof payload.customer_email === 'string') { - const trimmed = payload.customer_email.trim(); - return trimmed || null; - } - return null; -}; +// Use parseStringInput from shared parsers for customer data extraction +const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); +const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js new file mode 100644 index 00000000..6171393c --- /dev/null +++ b/backend/src/services/eventService.js @@ -0,0 +1,401 @@ +/** + * Event Service Layer + * Handles all event-related business logic + * + * @module services/eventService + */ + +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); +const { buildShareLinkVariants } = require('./shareLinkService'); +const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); + +// Cache for schema detection +let customerColumnCache = null; + +/** + * Check if the database has the new customer_email column + * @returns {Promise} + */ +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + return false; + } +}; + +/** + * Map event for API response (normalize customer fields) + * @param {Object} event - Database event object + * @returns {Object} - Normalized event object + */ +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null + }; +}; + +/** + * Generate a unique slug for an event + * @param {string} eventType + * @param {string} eventName + * @param {string} eventDate + * @returns {Promise} + */ +const generateUniqueSlug = async (eventType, eventName, eventDate) => { + const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + return slug; +}; + +/** + * Create event storage folders + * @param {string} slug - Event slug + * @returns {Promise} - Path to event folder + */ +const createEventFolders = async (slug) => { + const storagePath = getStoragePath(); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + return eventPath; +}; + +/** + * Create a new event + * @param {Object} eventData - Event data + * @returns {Promise} - Created event + */ +const createEvent = async (eventData) => { + const { + event_type, + event_name, + event_date, + customer_name, + customer_email, + admin_email, + password, + require_password = true, + welcome_message, + color_theme, + expiration_days = 30, + // Feedback settings + feedback_enabled, + allow_ratings, + allow_likes, + allow_comments, + allow_favorites, + require_name_email, + moderate_comments, + show_feedback_to_guests, + // Upload settings + allow_user_uploads, + upload_category_id + } = eventData; + + const requirePassword = parseBooleanInput(require_password, true); + const customerColumnsAvailable = await hasCustomerContactColumns(); + + // Validate password if required + if (requirePassword) { + const passwordValidation = await validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + const error = new Error('Password does not meet security requirements'); + error.code = 'PASSWORD_INVALID'; + error.details = passwordValidation.errors; + error.score = passwordValidation.score; + error.feedback = passwordValidation.feedback; + throw error; + } + } + + // Generate unique slug + const slug = await generateUniqueSlug(event_type, event_name, event_date); + + // Generate share link + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // Hash password + const password_hash = requirePassword + ? await bcrypt.hash(password, getBcryptRounds()) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + + // Calculate expiration date + const expires_at = new Date(event_date); + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + + // Create folder structure + await createEventFolders(slug); + + // Build insert data + const insertData = { + slug, + event_type, + event_name, + event_date, + ...(customerColumnsAvailable ? { customer_name, customer_email } : {}), + host_name: customer_name, + host_email: customer_email, + admin_email, + password_hash, + welcome_message, + color_theme, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at, + require_password: formatBoolean(requirePassword), + // Feedback settings + feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined, + allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined, + allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined, + allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined, + allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined, + require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined, + moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined, + show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined, + // Upload settings + allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined, + upload_category_id: upload_category_id || null + }; + + // Remove undefined values + Object.keys(insertData).forEach(key => { + if (insertData[key] === undefined) { + delete insertData[key]; + } + }); + + // Insert into database + const insertResult = await db('events').insert(insertData).returning('id'); + const eventId = insertResult[0]?.id || insertResult[0]; + + return { + id: eventId, + slug, + share_link: shareUrl, + expires_at, + require_password: requirePassword, + customer_name, + customer_email + }; +}; + +/** + * Get all events with optional filtering + * @param {Object} options - Filter options + * @param {string} options.status - 'all', 'active', or 'archived' + * @returns {Promise} - Array of events + */ +const getAllEvents = async (options = {}) => { + const { status = 'all' } = options; + + let query = db('events').select('*'); + + if (status === 'active') { + query = query.where('is_active', formatBoolean(true)); + } else if (status === 'archived') { + query = query.where('is_archived', formatBoolean(true)); + } + + const events = await query.orderBy('created_at', 'desc'); + + // Add photo counts + for (const event of events) { + const photoCount = await db('photos').where('event_id', event.id).count('id as count').first(); + event.photo_count = photoCount.count; + } + + return events.map(mapEventForApi); +}; + +/** + * Get a single event by ID + * @param {number} id - Event ID + * @returns {Promise} + */ +const getEventById = async (id) => { + const event = await db('events').where('id', id).first(); + return event ? mapEventForApi(event) : null; +}; + +/** + * Get a single event by slug + * @param {string} slug - Event slug + * @returns {Promise} + */ +const getEventBySlug = async (slug) => { + const event = await db('events').where('slug', slug).first(); + return event ? mapEventForApi(event) : null; +}; + +/** + * Update an event + * @param {number} id - Event ID + * @param {Object} updates - Fields to update + * @returns {Promise} - Updated event + */ +const updateEvent = async (id, updates) => { + const customerColumnsAvailable = await hasCustomerContactColumns(); + + // Don't allow updating certain fields + delete updates.id; + delete updates.slug; + delete updates.created_at; + delete updates.password_confirmation; + + // Handle legacy field names + if (updates.host_name || updates.host_email) { + throw new Error('host_name and host_email are no longer supported. Use customer_name and customer_email instead.'); + } + + // Handle customer name update + if (updates.customer_name !== undefined) { + const nextName = parseStringInput(updates.customer_name); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; + } else { + delete updates.customer_name; + } + } + + // Handle customer email update + if (updates.customer_email !== undefined) { + const nextEmail = parseStringInput(updates.customer_email); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; + } else { + delete updates.customer_email; + } + } + + // Handle require_password update + if (updates.require_password !== undefined) { + const requirePasswordUpdate = parseBooleanInput(updates.require_password, true); + updates.require_password = formatBoolean(requirePasswordUpdate); + + // Get current event to check password requirements + const event = await db('events').where('id', id).first(); + const currentRequirePassword = parseBooleanInput(event.require_password, true); + + // If enabling password and it was previously disabled, require a new password + if (requirePasswordUpdate === true && !currentRequirePassword && !updates.password) { + throw new Error('Password must be provided when enabling password requirement.'); + } + + // If disabling password, generate a random hash + if (requirePasswordUpdate === false && currentRequirePassword) { + updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + } + } + + // Handle password update + if (updates.password) { + updates.password_hash = await bcrypt.hash(updates.password, getBcryptRounds()); + delete updates.password; + } + + await db('events').where('id', id).update(updates); + + return { success: true }; +}; + +/** + * Soft delete an event (mark as inactive) + * @param {number} id - Event ID + * @returns {Promise} + */ +const deleteEvent = async (id) => { + await db('events').where('id', id).update({ is_active: formatBoolean(false) }); + return { success: true }; +}; + +/** + * Extend event expiration + * @param {number} id - Event ID + * @param {number} days - Days to extend + * @returns {Promise} + */ +const extendExpiration = async (id, days) => { + const event = await db('events').where('id', id).first(); + if (!event) { + throw new Error('Event not found'); + } + + const newExpiration = new Date(event.expires_at); + newExpiration.setDate(newExpiration.getDate() + days); + + await db('events').where('id', id).update({ + expires_at: newExpiration, + is_active: formatBoolean(true) // Reactivate if expired + }); + + return { expires_at: newExpiration }; +}; + +module.exports = { + // Core CRUD + createEvent, + getAllEvents, + getEventById, + getEventBySlug, + updateEvent, + deleteEvent, + extendExpiration, + + // Utilities + mapEventForApi, + hasCustomerContactColumns, + generateUniqueSlug, + createEventFolders +}; diff --git a/backend/src/utils/parsers.js b/backend/src/utils/parsers.js new file mode 100644 index 00000000..7eaeffd5 --- /dev/null +++ b/backend/src/utils/parsers.js @@ -0,0 +1,204 @@ +/** + * Shared Parser Utilities + * Pure functions for parsing and transforming input values + * + * @module utils/parsers + */ + +/** + * Parse any input value to boolean with configurable default + * Handles: boolean, number, string representations + * + * @param {*} value - Input value to parse + * @param {boolean} [defaultValue=true] - Default if value is undefined/null + * @returns {boolean} + * + * @example + * parseBooleanInput(true) // true + * parseBooleanInput('false') // false + * parseBooleanInput('1') // true + * parseBooleanInput(0) // false + * parseBooleanInput(undefined, false) // false + */ +const parseBooleanInput = (value, defaultValue = true) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) return defaultValue; + return value !== 0; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['false', '0', 'no', 'off', ''].includes(normalized)) { + return false; + } + if (['true', '1', 'yes', 'on'].includes(normalized)) { + return true; + } + } + return defaultValue; +}; + +/** + * Parse numeric input with validation and bounds + * + * @param {*} value - Input value to parse + * @param {number} defaultValue - Default if invalid + * @param {Object} [options] - Bounds options + * @param {number} [options.min] - Minimum allowed value + * @param {number} [options.max] - Maximum allowed value + * @returns {number} + * + * @example + * parseNumberInput('42', 0) // 42 + * parseNumberInput('abc', 10) // 10 + * parseNumberInput(5, 0, { min: 10 }) // 10 + * parseNumberInput(100, 0, { max: 50 }) // 50 + */ +const parseNumberInput = (value, defaultValue, options = {}) => { + const { min, max } = options; + + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return defaultValue; + } + + let result = parsed; + if (min !== undefined && result < min) result = min; + if (max !== undefined && result > max) result = max; + + return result; +}; + +/** + * Parse string input with trimming and null handling + * + * @param {*} value - Input value + * @param {string|null} [defaultValue=null] - Default if empty + * @returns {string|null} + * + * @example + * parseStringInput(' hello ') // 'hello' + * parseStringInput('') // null + * parseStringInput(null, 'default') // 'default' + */ +const parseStringInput = (value, defaultValue = null) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || defaultValue; + } + return String(value); +}; + +/** + * Parse JSON string safely + * Returns the parsed value or default if parsing fails + * + * @param {*} value - JSON string or already parsed value + * @param {*} [defaultValue=null] - Default if parsing fails + * @returns {*} + * + * @example + * parseJsonInput('{"a":1}') // { a: 1 } + * parseJsonInput({ a: 1 }) // { a: 1 } (passthrough) + * parseJsonInput('invalid', {}) // {} + */ +const parseJsonInput = (value, defaultValue = null) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value !== 'string') { + return value; // Already parsed + } + try { + return JSON.parse(value); + } catch { + return defaultValue; + } +}; + +/** + * Parse email input with validation + * + * @param {*} value - Input value + * @returns {string|null} - Valid email or null + */ +const parseEmailInput = (value) => { + const str = parseStringInput(value); + if (!str) return null; + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(str) ? str.toLowerCase() : null; +}; + +/** + * Parse date input to ISO string + * + * @param {*} value - Date string, Date object, or timestamp + * @param {string|null} [defaultValue=null] - Default if invalid + * @returns {string|null} - ISO date string (YYYY-MM-DD) or null + */ +const parseDateInput = (value, defaultValue = null) => { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + const date = new Date(value); + if (isNaN(date.getTime())) { + return defaultValue; + } + + // Return YYYY-MM-DD format + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +/** + * Parse array input (handles JSON strings and arrays) + * + * @param {*} value - Array or JSON string + * @param {Array} [defaultValue=[]] - Default if invalid + * @returns {Array} + */ +const parseArrayInput = (value, defaultValue = []) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : defaultValue; + } catch { + // Try comma-separated + return value.split(',').map(s => s.trim()).filter(Boolean); + } + } + return defaultValue; +}; + +module.exports = { + parseBooleanInput, + parseNumberInput, + parseStringInput, + parseJsonInput, + parseEmailInput, + parseDateInput, + parseArrayInput +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbadc279..5fa71a46 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -140,6 +140,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -488,6 +489,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -511,6 +513,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1626,6 +1629,44 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -1693,6 +1734,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -1789,6 +1831,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz", "integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2067,6 +2110,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2148,6 +2192,13 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2282,6 +2333,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2293,6 +2345,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2370,6 +2423,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2736,6 +2790,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2780,6 +2835,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2817,6 +2882,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2979,6 +3057,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3411,6 +3490,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3596,6 +3685,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4170,6 +4260,7 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -4252,6 +4343,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -4424,6 +4516,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4684,6 +4777,7 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "license": "MIT", + "peer": true, "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", @@ -4713,6 +4807,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4779,6 +4883,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5088,13 +5205,14 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5140,6 +5258,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5293,6 +5412,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5416,6 +5570,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -5445,6 +5600,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -5493,6 +5649,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5550,6 +5707,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5588,6 +5746,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5743,6 +5902,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -6175,19 +6347,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -6316,8 +6475,9 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6419,6 +6579,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6511,19 +6672,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -6597,19 +6745,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c964e881..ee84188f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { AdminLoginPage, AdminDashboard, EventsListPage, - CreateEventPageEnhanced as CreateEventPage, + CreateEventPage, EventDetailsPage, EventFeedbackPage, EmailConfigPage, @@ -22,9 +22,9 @@ import { AnalyticsPage, BrandingPage, SettingsPage, - BackupManagement + BackupManagement, + CMSPage } from './pages/admin'; -import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -127,7 +127,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index 06af08f2..d8270fe7 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -1,8 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { FileText, Globe, Sparkles, ShieldCheck } from 'lucide-react'; +import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { debounce } from 'lodash'; import DOMPurify from 'dompurify'; import { Button, Card, Input, Loading } from '../../components/common'; @@ -17,6 +18,9 @@ export const CMSPage: React.FC = () => { const [selectedPage, setSelectedPage] = useState('impressum'); const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); const [editForm, setEditForm] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [isAutoSaving, setIsAutoSaving] = useState(false); const [publicSiteEnabled, setPublicSiteEnabled] = useState(false); const [publicSiteHtml, setPublicSiteHtml] = useState(''); const [publicSiteCss, setPublicSiteCss] = useState(''); @@ -39,63 +43,25 @@ export const CMSPage: React.FC = () => { queryFn: () => settingsService.getPublicSiteDefaults(), }); - React.useEffect(() => { - if (publicSiteDefaults) { - setPublicSiteBaseCss(publicSiteDefaults.baseCss || ''); - setPublicSiteBranding(publicSiteDefaults.branding); - } - }, [publicSiteDefaults]); - - React.useEffect(() => { - if (!adminSettings) { - return; - } - - setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled)); - setPublicSiteHtml((adminSettings.general_public_site_html as string) || ''); - setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || ''); - }, [adminSettings]); - // Update page mutation const updateMutation = useMutation({ mutationFn: ({ slug, data }: { slug: string; data: Partial }) => cmsService.updatePage(slug, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); - toast.success(t('cms.pageUpdated')); + setHasUnsavedChanges(false); + setLastSaved(new Date()); + setIsAutoSaving(false); + if (!isAutoSaving) { + toast.success(t('cms.pageUpdated')); + } }, onError: () => { + setIsAutoSaving(false); toast.error(t('toast.saveError')); }, }); - // Load page data when selection changes - React.useEffect(() => { - if (pages) { - const page = pages.find(p => p.slug === selectedPage); - if (page) { - setEditForm(page); - } - } - }, [pages, selectedPage]); - - const handleSave = () => { - updateMutation.mutate({ - slug: selectedPage, - data: editForm, - }); - }; - - const handleContentChange = (content: string) => { - const field = editingLang === 'de' ? 'content_de' : 'content_en'; - setEditForm(prev => ({ ...prev, [field]: content })); - }; - - const handleTitleChange = (title: string) => { - const field = editingLang === 'de' ? 'title_de' : 'title_en'; - setEditForm(prev => ({ ...prev, [field]: title })); - }; - const publicSiteSaveMutation = useMutation({ mutationFn: async () => { const trimmedHtml = publicSiteHtml.trim(); @@ -140,19 +106,95 @@ export const CMSPage: React.FC = () => { }, onError: () => { toast.error(t('settings.publicSite.resetError')); - } + }, }); - if (isLoading) { - return ( -
- -
- ); - } + // Auto-save functionality + const autoSave = useCallback( + debounce(() => { + if (hasUnsavedChanges && !updateMutation.isPending) { + setIsAutoSaving(true); + updateMutation.mutate({ + slug: selectedPage, + data: editForm, + }); + } + }, 3000), + [hasUnsavedChanges, editForm, selectedPage] + ); + + useEffect(() => { + if (publicSiteDefaults) { + setPublicSiteBaseCss(publicSiteDefaults.baseCss || ''); + setPublicSiteBranding(publicSiteDefaults.branding); + } + }, [publicSiteDefaults]); + + useEffect(() => { + if (!adminSettings) { + return; + } + + setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled)); + setPublicSiteHtml((adminSettings.general_public_site_html as string) || ''); + setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || ''); + }, [adminSettings]); + + // Trigger auto-save when content changes + useEffect(() => { + if (hasUnsavedChanges) { + autoSave(); + } + return () => { + autoSave.cancel(); + }; + }, [hasUnsavedChanges, autoSave]); + + // Load page data when selection changes + React.useEffect(() => { + if (pages) { + const page = pages.find(p => p.slug === selectedPage); + if (page) { + setEditForm(page); + setHasUnsavedChanges(false); + } + } + }, [pages, selectedPage]); + + const handleSave = () => { + autoSave.cancel(); // Cancel any pending auto-save + updateMutation.mutate({ + slug: selectedPage, + data: editForm, + }); + }; + + const handleContentChange = (content: string) => { + const field = editingLang === 'de' ? 'content_de' : 'content_en'; + setEditForm(prev => ({ ...prev, [field]: content })); + setHasUnsavedChanges(true); + }; + + const handleTitleChange = (title: string) => { + const field = editingLang === 'de' ? 'title_de' : 'title_en'; + setEditForm(prev => ({ ...prev, [field]: title })); + setHasUnsavedChanges(true); + }; + + // Warn before leaving with unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUnsavedChanges]); const currentPage = pages?.find(p => p.slug === selectedPage); - const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', { ALLOWED_TAGS: [ 'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em', @@ -204,15 +246,9 @@ export const CMSPage: React.FC = () => { company_name: branding.companyName || '', company_tagline: branding.companyTagline || '', support_email: branding.supportEmail || '', - brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png', - brand_primary_hex: branding.colors.primary, - brand_accent_hex: branding.colors.accent, - brand_background_hex: branding.colors.background, - brand_text_hex: branding.colors.text, }; - return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi, - (_, key: string) => tokens[key] || ''); + return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || ''); }; const publicSitePreview = useMemo(() => { @@ -278,6 +314,14 @@ export const CMSPage: React.FC = () => { const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults; + if (isLoading) { + return ( +
+ +
+ ); + } + return (
@@ -423,18 +467,28 @@ export const CMSPage: React.FC = () => { {pages?.map((page) => ( ))}
@@ -463,6 +517,32 @@ export const CMSPage: React.FC = () => {
+ + {/* Auto-save status */} + {(hasUnsavedChanges || lastSaved) && ( + +
+ {isAutoSaving && ( +
+
+ Auto-saving... +
+ )} + {!isAutoSaving && hasUnsavedChanges && ( +
+
+ Unsaved changes +
+ )} + {!hasUnsavedChanges && lastSaved && ( +
+ + Saved {new Date(lastSaved).toLocaleTimeString()} +
+ )} +
+ + )}
{/* Editor */} @@ -483,7 +563,7 @@ export const CMSPage: React.FC = () => { : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }`} > - 🇬🇧 English + English
diff --git a/frontend/src/pages/admin/CMSPageEnhanced.tsx b/frontend/src/pages/admin/CMSPageEnhanced.tsx deleted file mode 100644 index e25a680a..00000000 --- a/frontend/src/pages/admin/CMSPageEnhanced.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; -import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { debounce } from 'lodash'; -import DOMPurify from 'dompurify'; - -import { Button, Card, Input, Loading } from '../../components/common'; -import { CMSEditor } from '../../components/admin/CMSEditor'; -import { cmsService } from '../../services/cms.service'; -import type { CMSPage as CMSPageType } from '../../services/cms.service'; -import { settingsService, PublicSiteBranding } from '../../services/settings.service'; - -export const CMSPageEnhanced: React.FC = () => { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const [selectedPage, setSelectedPage] = useState('impressum'); - const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); - const [editForm, setEditForm] = useState>({}); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [lastSaved, setLastSaved] = useState(null); - const [isAutoSaving, setIsAutoSaving] = useState(false); - const [publicSiteEnabled, setPublicSiteEnabled] = useState(false); - const [publicSiteHtml, setPublicSiteHtml] = useState(''); - const [publicSiteCss, setPublicSiteCss] = useState(''); - const [publicSiteBaseCss, setPublicSiteBaseCss] = useState(''); - const [publicSiteBranding, setPublicSiteBranding] = useState(undefined); - - // Fetch CMS pages - const { data: pages, isLoading } = useQuery({ - queryKey: ['cms-pages'], - queryFn: cmsService.getPages, - }); - - const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({ - queryKey: ['admin-settings'], - queryFn: () => settingsService.getAllSettings(), - }); - - const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({ - queryKey: ['public-site-defaults'], - queryFn: () => settingsService.getPublicSiteDefaults(), - }); - - // Update page mutation - const updateMutation = useMutation({ - mutationFn: ({ slug, data }: { slug: string; data: Partial }) => - cmsService.updatePage(slug, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); - setHasUnsavedChanges(false); - setLastSaved(new Date()); - setIsAutoSaving(false); - if (!isAutoSaving) { - toast.success(t('cms.pageUpdated')); - } - }, - onError: () => { - setIsAutoSaving(false); - toast.error(t('toast.saveError')); - }, - }); - - const publicSiteSaveMutation = useMutation({ - mutationFn: async () => { - const trimmedHtml = publicSiteHtml.trim(); - if (publicSiteEnabled && !trimmedHtml) { - throw new Error('PUBLIC_SITE_HTML_REQUIRED'); - } - - await settingsService.updatePublicSite({ - enabled: publicSiteEnabled, - html: trimmedHtml || '', - css: publicSiteCss, - }); - }, - onSuccess: async () => { - toast.success(t('settings.publicSite.saveSuccess')); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }), - queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }), - ]); - }, - onError: (error: any) => { - if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') { - toast.error(t('settings.publicSite.htmlRequired')); - return; - } - toast.error(t('settings.publicSite.saveError')); - }, - }); - - const publicSiteResetMutation = useMutation({ - mutationFn: () => settingsService.resetPublicSite(), - onSuccess: async (data) => { - toast.success(t('settings.publicSite.resetSuccess')); - setPublicSiteHtml(data.html || ''); - setPublicSiteCss(data.css || ''); - setPublicSiteBaseCss(data.baseCss || ''); - setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }), - queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }), - ]); - }, - onError: () => { - toast.error(t('settings.publicSite.resetError')); - }, - }); - - // Auto-save functionality - const autoSave = useCallback( - debounce(() => { - if (hasUnsavedChanges && !updateMutation.isPending) { - setIsAutoSaving(true); - updateMutation.mutate({ - slug: selectedPage, - data: editForm, - }); - } - }, 3000), - [hasUnsavedChanges, editForm, selectedPage] - ); - - useEffect(() => { - if (publicSiteDefaults) { - setPublicSiteBaseCss(publicSiteDefaults.baseCss || ''); - setPublicSiteBranding(publicSiteDefaults.branding); - } - }, [publicSiteDefaults]); - - useEffect(() => { - if (!adminSettings) { - return; - } - - setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled)); - setPublicSiteHtml((adminSettings.general_public_site_html as string) || ''); - setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || ''); - }, [adminSettings]); - - // Trigger auto-save when content changes - useEffect(() => { - if (hasUnsavedChanges) { - autoSave(); - } - return () => { - autoSave.cancel(); - }; - }, [hasUnsavedChanges, autoSave]); - - // Load page data when selection changes - React.useEffect(() => { - if (pages) { - const page = pages.find(p => p.slug === selectedPage); - if (page) { - setEditForm(page); - setHasUnsavedChanges(false); - } - } - }, [pages, selectedPage]); - - const handleSave = () => { - autoSave.cancel(); // Cancel any pending auto-save - updateMutation.mutate({ - slug: selectedPage, - data: editForm, - }); - }; - - const handleContentChange = (content: string) => { - const field = editingLang === 'de' ? 'content_de' : 'content_en'; - setEditForm(prev => ({ ...prev, [field]: content })); - setHasUnsavedChanges(true); - }; - - const handleTitleChange = (title: string) => { - const field = editingLang === 'de' ? 'title_de' : 'title_en'; - setEditForm(prev => ({ ...prev, [field]: title })); - setHasUnsavedChanges(true); - }; - - // Warn before leaving with unsaved changes - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges) { - e.preventDefault(); - e.returnValue = ''; - } - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [hasUnsavedChanges]); - - const currentPage = pages?.find(p => p.slug === selectedPage); - const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', { - ALLOWED_TAGS: [ - 'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em', - 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', - 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup', - 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul' - ], - ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'], - ALLOW_UNKNOWN_PROTOCOLS: false, - ADD_ATTR: ['data-*'], - }), [publicSiteHtml]); - - const sanitizeCss = (css: string) => { - if (!css) { - return ''; - } - - let sanitized = css; - const disallowedPatterns = [ - /@import[^;]+;?/gi, - /@charset[^;]+;?/gi, - /expression\s*\([^)]*\)/gi, - /url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi, - /url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi - ]; - - disallowedPatterns.forEach((pattern) => { - sanitized = sanitized.replace(pattern, ''); - }); - - sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); - - const MAX_LENGTH = 100 * 1024; - if (sanitized.length > MAX_LENGTH) { - sanitized = sanitized.slice(0, MAX_LENGTH); - } - - return sanitized.trim(); - }; - - const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]); - - const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => { - if (!html || !branding) { - return html; - } - - const tokens: Record = { - company_name: branding.companyName || '', - company_tagline: branding.companyTagline || '', - support_email: branding.supportEmail || '', - }; - - return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || ''); - }; - - const publicSitePreview = useMemo(() => { - const branding = publicSiteBranding || publicSiteDefaults?.branding; - const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding); - const inlineStyles = [ - branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '', - publicSiteBaseCss, - publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : '' - ].filter(Boolean).join('\n\n'); - - const logo = branding?.logoUrl ? `` : ''; - const tagline = branding?.companyTagline ? `

${branding.companyTagline}

` : ''; - const support = branding?.supportEmail ? `${branding.supportEmail}` : ''; - const footerNote = branding?.footerText ? `

${branding.footerText}

` : ''; - - const displayName = branding?.companyName || 'Celebration Stories'; - - return ` - - - - - - - -
- -
- ${substitutedHtml} -
-
- -
-
- -`; - }, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]); - - const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults; - - if (isLoading) { - return ( -
- -
- ); - } - - return ( -
-
-

{t('cms.title')}

-

{t('cms.subtitle')}

-
- -
- -
-
-
- - {t('settings.publicSite.badge')} -
-

{t('settings.publicSite.title')}

-

{t('settings.publicSite.subtitle')}

-
- -
- - {publicSiteLoading ? ( -
- -
- ) : ( -
-
-
- -