Compare commits

...

4 Commits

Author SHA1 Message Date
Gitea Actions Bot ffcfd9766d chore: bump backend version to 1.0.60
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-16 21:41:10 +00:00
paul 3501a52f0e fix: rate limiting issues with reverse proxy setup
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Add keyGenerator function to properly detect client IP behind proxy
- Support X-Forwarded-For and X-Real-IP headers from Traefik/nginx
- Add custom handlers with better error messages
- Add debug endpoint (dev only) to verify IP detection
- Improve logging for rate limit debugging

This fixes the 429 errors when multiple requests come from same proxy IP.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 23:36:04 +02:00
Gitea Actions Bot 5ca598b80a chore: bump backend version to 1.0.59
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 21:19:09 +00:00
paul 4e2075c638 fix: prevent double date formatting in email templates
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Remove pre-formatting of dates before passing to email processor
- Let email processor handle all date formatting based on recipient language
- Fix Invalid Date errors by passing raw date values instead of formatted strings
- Remove unused formatDate imports and variables

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 23:14:32 +02:00
11 changed files with 125 additions and 57 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.58", "version": "1.0.60",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.58", "version": "1.0.60",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.58", "version": "1.0.60",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+72 -1
View File
@@ -97,6 +97,38 @@ app.use(cors(corsOptions));
const limiter = rateLimit({ const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
// Use correct client IP when behind proxy
keyGenerator: (req) => {
// Get the real client IP from proxy headers
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Log rate limit key for debugging (only in development)
if (process.env.NODE_ENV === 'development' && req.path.includes('/api/')) {
logger.debug('Rate limit key generated', {
path: req.path,
clientIp,
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
}
});
}
return clientIp;
},
handler: (req, res) => {
logger.warn('Rate limit exceeded', {
ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip,
path: req.path,
method: req.method
});
res.status(429).json({
error: 'Too many requests, please try again later.'
});
},
skip: (req) => { skip: (req) => {
// Skip rate limiting for authenticated admin users // Skip rate limiting for authenticated admin users
if (req.path.startsWith('/api/admin/') && req.headers.authorization) { if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
@@ -118,7 +150,24 @@ const limiter = rateLimit({
const authLimiter = rateLimit({ const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 5 // limit auth attempts max: 5, // limit auth attempts
// Use correct client IP when behind proxy
keyGenerator: (req) => {
// Get the real client IP from proxy headers
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
handler: (req, res) => {
logger.warn('Auth rate limit exceeded', {
ip: req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip,
path: req.path
});
res.status(429).json({
error: 'Too many authentication attempts, please try again later.'
});
}
}); });
// Apply rate limiting - admin routes check will skip for valid admin tokens // Apply rate limiting - admin routes check will skip for valid admin tokens
@@ -158,6 +207,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 {
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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');
+1 -1
View File
@@ -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;
} }
+9 -9
View File
@@ -210,15 +210,15 @@ router.get('/templates', adminAuth, async (req, res) => {
id: template.id, id: template.id,
template_key: template.template_key, template_key: template.template_key,
variables: (() => { variables: (() => {
try { try {
if (!template.variables) return []; if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables; if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables); return JSON.parse(template.variables);
} catch (e) { } catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message); console.warn('Failed to parse variables for template:', template.template_key, e.message);
return []; return [];
} }
})(), })(),
updated_at: template.updated_at updated_at: template.updated_at
}; };
+9 -12
View File
@@ -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');
@@ -135,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,
@@ -145,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',
@@ -553,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
}); });
} }
@@ -603,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
+2 -2
View File
@@ -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 || ''
}); });
+24 -24
View File
@@ -56,30 +56,30 @@ class WatermarkService {
let left, top; let left, top;
switch (position) { switch (position) {
case 'top-left': case 'top-left':
left = padding; left = padding;
top = padding; top = padding;
break; break;
case 'top-right': case 'top-right':
left = imageWidth - watermarkWidth - padding; left = imageWidth - watermarkWidth - padding;
top = padding; top = padding;
break; break;
case 'bottom-left': case 'bottom-left':
left = padding; left = padding;
top = imageHeight - watermarkHeight - padding; top = imageHeight - watermarkHeight - padding;
break; break;
case 'bottom-right': case 'bottom-right':
left = imageWidth - watermarkWidth - padding; left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding; top = imageHeight - watermarkHeight - padding;
break; break;
case 'center': case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2); left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2); top = Math.floor((imageHeight - watermarkHeight) / 2);
break; break;
default: default:
// Default to bottom-right // Default to bottom-right
left = imageWidth - watermarkWidth - padding; left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding; top = imageHeight - watermarkHeight - padding;
} }
return { left: Math.max(0, left), top: Math.max(0, top) }; return { left: Math.max(0, left), top: Math.max(0, top) };
+1 -1
View File
@@ -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
} }
/** /**