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
This commit is contained in:
Generated
+6
-23
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
+231
-69
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+429
-55
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<boolean>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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<string>} - 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<Object>} - 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>} - 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<Object|null>}
|
||||
*/
|
||||
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<Object|null>}
|
||||
*/
|
||||
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<Object>} - 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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user