Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a |
Binary file not shown.
@@ -0,0 +1,63 @@
|
|||||||
|
exports.up = async function(knex) {
|
||||||
|
// Add rate limit settings to app_settings
|
||||||
|
const rateLimitSettings = [
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_enabled',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_window_minutes',
|
||||||
|
setting_value: JSON.stringify(15),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_max_requests',
|
||||||
|
setting_value: JSON.stringify(1000),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_auth_max_requests',
|
||||||
|
setting_value: JSON.stringify(5),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_skip_authenticated',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_public_endpoints_only',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'security'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert settings if they don't exist
|
||||||
|
for (const setting of rateLimitSettings) {
|
||||||
|
const exists = await knex('app_settings')
|
||||||
|
.where('setting_key', setting.setting_key)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await knex('app_settings').insert({
|
||||||
|
...setting,
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Remove rate limit settings
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'rate_limit_enabled',
|
||||||
|
'rate_limit_window_minutes',
|
||||||
|
'rate_limit_max_requests',
|
||||||
|
'rate_limit_auth_max_requests',
|
||||||
|
'rate_limit_skip_authenticated',
|
||||||
|
'rate_limit_public_endpoints_only'
|
||||||
|
])
|
||||||
|
.del();
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.57",
|
"version": "1.0.62",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.57",
|
"version": "1.0.62",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.57",
|
"version": "1.0.62",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+42
-31
@@ -7,8 +7,6 @@ validateEnvironment();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { initializeDatabase, db } = require('./src/database/db');
|
const { initializeDatabase, db } = require('./src/database/db');
|
||||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||||
@@ -16,6 +14,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
|
|||||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
const logger = require('./src/utils/logger');
|
const logger = require('./src/utils/logger');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
@@ -93,37 +92,23 @@ const corsOptions = {
|
|||||||
|
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
// Rate limiting with admin bypass
|
// Initialize rate limiters (they will be created dynamically)
|
||||||
const limiter = rateLimit({
|
let generalRateLimiter;
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
let authRateLimiter;
|
||||||
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
|
|
||||||
skip: (req) => {
|
|
||||||
// Skip rate limiting for authenticated admin users
|
|
||||||
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
|
|
||||||
const token = req.headers.authorization.replace('Bearer ', '');
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
return decoded.type === 'admin';
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also skip rate limiting for public settings endpoint in development
|
|
||||||
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const authLimiter = rateLimit({
|
// Function to initialize rate limiters
|
||||||
windowMs: 15 * 60 * 1000,
|
async function initializeRateLimiters() {
|
||||||
max: 5 // limit auth attempts
|
generalRateLimiter = await createRateLimiter();
|
||||||
});
|
authRateLimiter = await createAuthRateLimiter();
|
||||||
|
|
||||||
// Apply rate limiting - admin routes check will skip for valid admin tokens
|
// Apply rate limiting
|
||||||
app.use('/api/', limiter);
|
app.use('/api/', generalRateLimiter);
|
||||||
app.use('/api/auth', authLimiter);
|
app.use('/api/auth', authRateLimiter);
|
||||||
|
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||||
|
app.use('/api/admin/auth/login', authRateLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Rate limiters will be initialized after database connection
|
||||||
|
|
||||||
// Body parsing middleware with increased limits for large uploads
|
// Body parsing middleware with increased limits for large uploads
|
||||||
app.use(express.json({ limit: '100mb' }));
|
app.use(express.json({ limit: '100mb' }));
|
||||||
@@ -158,6 +143,28 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
|
|||||||
// Static file serving for uploads (public - logos, favicons)
|
// Static file serving for uploads (public - logos, favicons)
|
||||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||||
|
|
||||||
|
// Debug endpoint to check IP detection (only in development)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
app.get('/api/debug/ip', (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
detectedIp: clientIp,
|
||||||
|
reqIp: req.ip,
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||||
|
'x-real-ip': req.headers['x-real-ip'],
|
||||||
|
'x-forwarded-proto': req.headers['x-forwarded-proto'],
|
||||||
|
'x-forwarded-host': req.headers['x-forwarded-host']
|
||||||
|
},
|
||||||
|
trustProxy: app.get('trust proxy')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +210,10 @@ async function startServer() {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Initialize rate limiters after database is ready
|
||||||
|
await initializeRateLimiters();
|
||||||
|
logger.info('Rate limiters initialized with database configuration');
|
||||||
|
|
||||||
// Initialize auth security cleanup job
|
// Initialize auth security cleanup job
|
||||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||||
initializeCleanupJob();
|
initializeCleanupJob();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function validateEnvironment() {
|
|||||||
if (name === 'JWT_SECRET' && value) {
|
if (name === 'JWT_SECRET' && value) {
|
||||||
// Check for the insecure default value
|
// Check for the insecure default value
|
||||||
if (value === 'your-secret-key') {
|
if (value === 'your-secret-key') {
|
||||||
errors.push(`CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.`);
|
errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check minimum length (should be at least 32 characters for security)
|
// Check minimum length (should be at least 32 characters for security)
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ async function initializeDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
|
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||||
await db.raw(`DROP TABLE events`);
|
await db.raw('DROP TABLE events');
|
||||||
await db.raw(`ALTER TABLE events_new RENAME TO events`);
|
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the migration fails, it might already have been applied
|
// If the migration fails, it might already have been applied
|
||||||
console.log('Color theme migration may have already been applied');
|
console.log('Color theme migration may have already been applied');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate uploaded file is complete and not corrupted
|
||||||
|
*/
|
||||||
|
async function validateUploadedFile(filePath) {
|
||||||
|
try {
|
||||||
|
// Check file exists and has size
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
throw new Error('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For image files, verify they can be read by Sharp
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
if (imageExtensions.includes(ext)) {
|
||||||
|
// Try to read metadata - this will fail if image is corrupted
|
||||||
|
const metadata = await sharp(filePath).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error('Invalid image dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reasonable dimensions
|
||||||
|
if (metadata.width < 10 || metadata.height < 10) {
|
||||||
|
throw new Error('Image dimensions too small');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`File validation failed for ${filePath}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to validate uploaded files after multer processing
|
||||||
|
*/
|
||||||
|
async function validateUploadedFiles(req, res, next) {
|
||||||
|
if (!req.files || req.files.length === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = [];
|
||||||
|
const invalidFiles = [];
|
||||||
|
|
||||||
|
// Validate each file
|
||||||
|
for (const file of req.files) {
|
||||||
|
try {
|
||||||
|
await validateUploadedFile(file.path);
|
||||||
|
validFiles.push(file);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`);
|
||||||
|
invalidFiles.push({
|
||||||
|
filename: file.originalname,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the invalid file
|
||||||
|
try {
|
||||||
|
await fs.unlink(file.path);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update req.files to only include valid files
|
||||||
|
req.files = validFiles;
|
||||||
|
|
||||||
|
// Store invalid files info for response
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
req.invalidFiles = invalidFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateUploadedFile,
|
||||||
|
validateUploadedFiles
|
||||||
|
};
|
||||||
@@ -251,7 +251,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||||
console.error(`Entry name was: ${entry.entryName}`);
|
console.error(`Entry name was: ${entry.entryName}`);
|
||||||
console.error(`Error:`, statError.message);
|
console.error('Error:', statError.message);
|
||||||
// Skip this file if we can't stat it
|
// Skip this file if we can't stat it
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const path = require('path');
|
|||||||
const { archiveEvent } = require('../services/archiveService');
|
const { archiveEvent } = require('../services/archiveService');
|
||||||
const { queueEmail } = require('../services/emailProcessor');
|
const { queueEmail } = require('../services/emailProcessor');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { formatDate } = require('../utils/dateFormatter');
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
@@ -89,7 +89,14 @@ router.post('/', adminAuth, [
|
|||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date(event_date);
|
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||||
|
let expires_at;
|
||||||
|
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||||
|
expires_at = new Date(year, month - 1, day);
|
||||||
|
} else {
|
||||||
|
expires_at = new Date(event_date);
|
||||||
|
}
|
||||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||||
|
|
||||||
// Create folder structure
|
// Create folder structure
|
||||||
@@ -128,8 +135,7 @@ router.post('/', adminAuth, [
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue creation email
|
// Queue creation email
|
||||||
// Determine language based on email domain
|
// Language detection is handled by email processor
|
||||||
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
await db('email_queue').insert({
|
await db('email_queue').insert({
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
@@ -138,10 +144,10 @@ router.post('/', adminAuth, [
|
|||||||
email_data: JSON.stringify({
|
email_data: JSON.stringify({
|
||||||
host_name: host_name,
|
host_name: host_name,
|
||||||
event_name,
|
event_name,
|
||||||
event_date: await formatDate(event_date, emailLang),
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: await formatDate(expires_at, emailLang),
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
}),
|
}),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -546,10 +552,10 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||||
host_name: event.host_email.split('@')[0],
|
host_name: event.host_email.split('@')[0],
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_date: new Date(event.event_date).toLocaleDateString(),
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
gallery_link: event.share_link,
|
||||||
gallery_password: newPassword,
|
gallery_password: newPassword,
|
||||||
expiry_date: new Date(event.expires_at).toLocaleDateString()
|
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,18 +602,16 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
|||||||
galleryPassword = '{{password_security_message}}';
|
galleryPassword = '{{password_security_message}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format dates in a neutral format - the email processor will localize them
|
// Dates will be formatted by the email processor based on recipient language
|
||||||
const eventDate = new Date(event.event_date);
|
|
||||||
const expiryDate = new Date(event.expires_at);
|
|
||||||
|
|
||||||
// Queue the email
|
// Queue the email
|
||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||||
host_name: event.host_name || event.host_email.split('@')[0],
|
host_name: event.host_name || event.host_email.split('@')[0],
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_date: eventDate.toISOString().split('T')[0], // YYYY-MM-DD format
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
gallery_link: event.share_link,
|
||||||
gallery_password: galleryPassword,
|
gallery_password: galleryPassword,
|
||||||
expiry_date: expiryDate.toISOString().split('T')[0], // YYYY-MM-DD format
|
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||||
welcome_message: event.welcome_message || '',
|
welcome_message: event.welcome_message || '',
|
||||||
eventId: id,
|
eventId: id,
|
||||||
isResend: true // Flag to indicate this is a resend
|
isResend: true // Flag to indicate this is a resend
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { generateThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
@@ -106,7 +107,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}, validateUploadContent, async (req, res) => {
|
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id } = req.body;
|
||||||
@@ -197,8 +198,14 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
file.filename = newFilename;
|
file.filename = newFilename;
|
||||||
file.path = newPath;
|
file.path = newPath;
|
||||||
|
|
||||||
// Generate thumbnail with new filename
|
// Generate thumbnail with new filename (with better error handling)
|
||||||
const thumbnailPath = await generateThumbnail(file.path);
|
let thumbnailPath = null;
|
||||||
|
try {
|
||||||
|
thumbnailPath = await generateThumbnail(file.path);
|
||||||
|
} catch (thumbError) {
|
||||||
|
console.error(`Thumbnail generation failed for ${file.filename}:`, thumbError.message);
|
||||||
|
// Continue without thumbnail rather than failing the whole upload
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate relative paths
|
// Calculate relative paths
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
@@ -269,19 +276,23 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Include any files that were invalid from the validation middleware
|
||||||
|
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
|
||||||
|
|
||||||
// Prepare response
|
// Prepare response
|
||||||
|
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||||
const response = {
|
const response = {
|
||||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||||
photos: uploadedPhotos,
|
photos: uploadedPhotos,
|
||||||
totalFiles: req.files.length,
|
totalFiles: totalAttempted,
|
||||||
successCount: uploadedPhotos.length,
|
successCount: uploadedPhotos.length,
|
||||||
failureCount: errors.length
|
failureCount: totalInvalidFiles.length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include error details if any files failed
|
// Include error details if any files failed
|
||||||
if (errors.length > 0) {
|
if (totalInvalidFiles.length > 0) {
|
||||||
response.errors = errors;
|
response.errors = totalInvalidFiles;
|
||||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
@@ -613,26 +624,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
|||||||
.where({ id: photoId, event_id: eventId })
|
.where({ id: photoId, event_id: eventId })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!photo || !photo.thumbnail_path) {
|
if (!photo) {
|
||||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||||
|
const thumbnailPath = await ensureThumbnail(photo);
|
||||||
|
|
||||||
|
if (!thumbnailPath) {
|
||||||
|
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||||
|
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
const filePath = path.join(storagePath, thumbnailPath);
|
||||||
|
|
||||||
console.log(`Attempting to serve thumbnail: ${filePath}`);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Thumbnail file not found: ${filePath}`, error);
|
|
||||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate headers
|
// Set appropriate headers
|
||||||
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
|
res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
|
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Configure multer for logo uploads
|
// Configure multer for logo uploads
|
||||||
@@ -582,4 +583,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update rate limit settings
|
||||||
|
router.put('/security/rate-limit', adminAuth, [
|
||||||
|
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||||
|
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||||
|
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||||
|
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
|
||||||
|
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
|
||||||
|
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
rate_limit_enabled,
|
||||||
|
rate_limit_window_minutes,
|
||||||
|
rate_limit_max_requests,
|
||||||
|
rate_limit_auth_max_requests,
|
||||||
|
rate_limit_skip_authenticated,
|
||||||
|
rate_limit_public_endpoints_only
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Update each setting
|
||||||
|
const settings = [
|
||||||
|
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
|
||||||
|
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
|
||||||
|
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
|
||||||
|
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
|
||||||
|
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
|
||||||
|
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, value } of settings) {
|
||||||
|
await db('app_settings')
|
||||||
|
.where('setting_key', key)
|
||||||
|
.update({
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the rate limit settings cache to apply changes immediately
|
||||||
|
clearSettingsCache();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('settings_updated',
|
||||||
|
{
|
||||||
|
category: 'security',
|
||||||
|
subcategory: 'rate_limit',
|
||||||
|
changes: settings.length
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Rate limit settings updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rate limit settings update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -87,10 +87,10 @@ router.post('/', adminAuth, [
|
|||||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||||
host_name: host_email.split('@')[0], // Extract name from email
|
host_name: host_email.split('@')[0], // Extract name from email
|
||||||
event_name,
|
event_name,
|
||||||
event_date: new Date(event_date).toLocaleDateString(),
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: expires_at.toLocaleDateString(),
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Configure sharp for better memory management with large batches
|
// Configure sharp for better memory management with large batches
|
||||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||||
@@ -10,7 +11,7 @@ const THUMBNAIL_WIDTH = 300;
|
|||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||||
|
|
||||||
async function generateThumbnail(imagePath) {
|
async function generateThumbnail(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = path.basename(imagePath);
|
||||||
const thumbnailFilename = `thumb_${filename}`;
|
const thumbnailFilename = `thumb_${filename}`;
|
||||||
const thumbnailDir = getThumbnailPath();
|
const thumbnailDir = getThumbnailPath();
|
||||||
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
|
|||||||
// Ensure thumbnail directory exists
|
// Ensure thumbnail directory exists
|
||||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||||
|
|
||||||
|
// Check if we need to regenerate (for broken thumbnails)
|
||||||
|
if (options.regenerate) {
|
||||||
try {
|
try {
|
||||||
// Generate thumbnail with memory-efficient settings
|
await fs.unlink(thumbnailPath);
|
||||||
|
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
// File might not exist, that's okay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, verify the source image is complete and valid
|
||||||
|
const metadata = await sharp(imagePath).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error('Invalid image metadata - file may be incomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail with memory-efficient settings and error handling
|
||||||
await sharp(imagePath, {
|
await sharp(imagePath, {
|
||||||
limitInputPixels: 268402689, // ~16k x 16k max
|
limitInputPixels: 268402689, // ~16k x 16k max
|
||||||
sequentialRead: true // More memory efficient for large images
|
sequentialRead: true, // More memory efficient for large images
|
||||||
|
failOnError: false // Don't fail on minor issues
|
||||||
})
|
})
|
||||||
.resize(THUMBNAIL_WIDTH, null, {
|
.resize(THUMBNAIL_WIDTH, null, {
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
|
|||||||
})
|
})
|
||||||
.toFile(thumbnailPath);
|
.toFile(thumbnailPath);
|
||||||
|
|
||||||
|
// Verify the thumbnail was created successfully
|
||||||
|
const stats = await fs.stat(thumbnailPath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
throw new Error('Generated thumbnail is empty');
|
||||||
|
}
|
||||||
|
|
||||||
return path.relative(getStoragePath(), thumbnailPath);
|
return path.relative(getStoragePath(), thumbnailPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate thumbnail for ${filename}:`, error);
|
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
|
||||||
|
|
||||||
|
// Clean up any partially created file
|
||||||
|
try {
|
||||||
|
await fs.unlink(thumbnailPath);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
// Ignore unlink errors
|
||||||
|
}
|
||||||
|
|
||||||
// Return null if thumbnail generation fails, don't fail the whole upload
|
// Return null if thumbnail generation fails, don't fail the whole upload
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateThumbnail };
|
/**
|
||||||
|
* Check if a thumbnail exists and is valid
|
||||||
|
*/
|
||||||
|
async function isThumbnailValid(thumbnailPath) {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(getStoragePath(), thumbnailPath);
|
||||||
|
const stats = await fs.stat(fullPath);
|
||||||
|
|
||||||
|
// Check if file exists and has content
|
||||||
|
if (stats.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read metadata to ensure it's a valid image
|
||||||
|
await sharp(fullPath).metadata();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate thumbnail if it's broken or missing
|
||||||
|
*/
|
||||||
|
async function ensureThumbnail(photo) {
|
||||||
|
const storagePath = getStoragePath();
|
||||||
|
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||||
|
|
||||||
|
// Check if thumbnail exists and is valid
|
||||||
|
if (photo.thumbnail_path) {
|
||||||
|
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||||
|
if (isValid) {
|
||||||
|
return photo.thumbnail_path;
|
||||||
|
}
|
||||||
|
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new thumbnail
|
||||||
|
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||||
|
|
||||||
|
if (newThumbnailPath) {
|
||||||
|
// Update database with new thumbnail path
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
await db('photos')
|
||||||
|
.where({ id: photo.id })
|
||||||
|
.update({ thumbnail_path: newThumbnailPath });
|
||||||
|
|
||||||
|
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||||
|
return newThumbnailPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Cache for rate limit settings
|
||||||
|
let settingsCache = null;
|
||||||
|
let cacheExpiry = 0;
|
||||||
|
const CACHE_DURATION = 60000; // 1 minute cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit settings from database with caching
|
||||||
|
*/
|
||||||
|
async function getRateLimitSettings() {
|
||||||
|
try {
|
||||||
|
// Check cache
|
||||||
|
if (settingsCache && Date.now() < cacheExpiry) {
|
||||||
|
return settingsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from database
|
||||||
|
const settings = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'rate_limit_enabled',
|
||||||
|
'rate_limit_window_minutes',
|
||||||
|
'rate_limit_max_requests',
|
||||||
|
'rate_limit_auth_max_requests',
|
||||||
|
'rate_limit_skip_authenticated',
|
||||||
|
'rate_limit_public_endpoints_only'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse settings into object
|
||||||
|
const config = {
|
||||||
|
enabled: true,
|
||||||
|
windowMinutes: 15,
|
||||||
|
maxRequests: 100,
|
||||||
|
authMaxRequests: 5,
|
||||||
|
skipAuthenticated: true,
|
||||||
|
publicEndpointsOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.forEach(setting => {
|
||||||
|
const value = JSON.parse(setting.setting_value);
|
||||||
|
switch (setting.setting_key) {
|
||||||
|
case 'rate_limit_enabled':
|
||||||
|
config.enabled = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_window_minutes':
|
||||||
|
config.windowMinutes = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_max_requests':
|
||||||
|
config.maxRequests = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_auth_max_requests':
|
||||||
|
config.authMaxRequests = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_skip_authenticated':
|
||||||
|
config.skipAuthenticated = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_public_endpoints_only':
|
||||||
|
config.publicEndpointsOnly = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
settingsCache = config;
|
||||||
|
cacheExpiry = Date.now() + CACHE_DURATION;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch rate limit settings:', error);
|
||||||
|
// Return defaults on error
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
windowMinutes: 15,
|
||||||
|
maxRequests: 100,
|
||||||
|
authMaxRequests: 5,
|
||||||
|
skipAuthenticated: true,
|
||||||
|
publicEndpointsOnly: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear settings cache (call when settings are updated)
|
||||||
|
*/
|
||||||
|
function clearSettingsCache() {
|
||||||
|
settingsCache = null;
|
||||||
|
cacheExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request has valid authentication
|
||||||
|
*/
|
||||||
|
function isAuthenticated(req) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Check if token is valid
|
||||||
|
if (!decoded || typeof decoded !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid token found - check type
|
||||||
|
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||||
|
req.tokenPayload = decoded;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if rate limiting should be applied to this request
|
||||||
|
*/
|
||||||
|
function shouldSkipRateLimit(req, config) {
|
||||||
|
// If rate limiting is disabled globally
|
||||||
|
if (!config.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never skip rate limiting for auth endpoints
|
||||||
|
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should skip authenticated requests
|
||||||
|
if (config.skipAuthenticated && isAuthenticated(req)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we only rate limit public endpoints
|
||||||
|
if (config.publicEndpointsOnly) {
|
||||||
|
const isPublicEndpoint = req.path.startsWith('/api/public/') ||
|
||||||
|
req.path.startsWith('/api/gallery/') ||
|
||||||
|
isAuthEndpoint;
|
||||||
|
return !isPublicEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create dynamic rate limiter
|
||||||
|
*/
|
||||||
|
async function createRateLimiter() {
|
||||||
|
const config = await getRateLimitSettings();
|
||||||
|
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: config.windowMinutes * 60 * 1000,
|
||||||
|
max: async (req) => {
|
||||||
|
// Refresh config for each request
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
|
||||||
|
// Different limits for auth endpoints
|
||||||
|
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||||
|
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||||
|
},
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Use correct client IP when behind proxy
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
},
|
||||||
|
skip: async (req) => {
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
return shouldSkipRateLimit(req, currentConfig);
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||||
|
logger.warn('Rate limit exceeded', {
|
||||||
|
ip: clientIp,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
authenticated: isAuthenticated(req),
|
||||||
|
tokenType: req.tokenType
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many requests, please try again later.',
|
||||||
|
retryAfter: res.getHeader('Retry-After')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
standardHeaders: true, // Return rate limit info in headers
|
||||||
|
legacyHeaders: false, // Disable X-RateLimit headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create auth-specific rate limiter
|
||||||
|
*/
|
||||||
|
async function createAuthRateLimiter() {
|
||||||
|
const config = await getRateLimitSettings();
|
||||||
|
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: config.windowMinutes * 60 * 1000,
|
||||||
|
max: config.authMaxRequests,
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Use correct client IP when behind proxy
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
},
|
||||||
|
skip: async () => {
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
return !currentConfig.enabled;
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||||
|
logger.warn('Auth rate limit exceeded', {
|
||||||
|
ip: clientIp,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many authentication attempts, please try again later.',
|
||||||
|
retryAfter: res.getHeader('Retry-After')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRateLimitSettings,
|
||||||
|
clearSettingsCache,
|
||||||
|
createRateLimiter,
|
||||||
|
createAuthRateLimiter,
|
||||||
|
isAuthenticated,
|
||||||
|
shouldSkipRateLimit
|
||||||
|
};
|
||||||
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
// Ensure proper date parsing
|
||||||
|
let dateObj;
|
||||||
|
if (date instanceof Date) {
|
||||||
|
dateObj = date;
|
||||||
|
} else if (typeof date === 'string') {
|
||||||
|
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
|
||||||
|
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
// Parse YYYY-MM-DD format as local date
|
||||||
|
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
|
||||||
|
dateObj = new Date(year, month - 1, day);
|
||||||
|
} else {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
console.error('Invalid date provided to formatDate:', date);
|
||||||
|
throw new Error('Invalid date');
|
||||||
|
}
|
||||||
|
|
||||||
// Use appropriate locale based on language
|
// Use appropriate locale based on language
|
||||||
let locale = dateConfig.locale || 'en-GB';
|
let locale = dateConfig.locale || 'en-GB';
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function escapeLikePattern(input) {
|
|||||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
.replace(/%/g, '\\%') // Escape percent signs
|
.replace(/%/g, '\\%') // Escape percent signs
|
||||||
.replace(/_/g, '\\_') // Escape underscores
|
.replace(/_/g, '\\_') // Escape underscores
|
||||||
.replace(/'/g, "''"); // Escape single quotes for safety
|
.replace(/'/g, '\'\''); // Escape single quotes for safety
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user