Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped

Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 19:25:15 +02:00
commit 1773ed5f95
354 changed files with 61508 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
const archiver = require('archiver');
const fs = require('fs').promises;
const path = require('path');
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active');
const ARCHIVE_PATH = () => path.join(getStoragePath(), 'events/archived');
async function archiveEvent(event) {
try {
const eventPath = path.join(ACTIVE_PATH(), event.slug);
const archiveName = `${event.slug}.zip`;
const archivePath = path.join(ARCHIVE_PATH(), archiveName);
// Ensure archive directory exists
await fs.mkdir(ARCHIVE_PATH(), { recursive: true });
// Create archive
const output = require('fs').createWriteStream(archivePath);
const archive = archiver('zip', {
zlib: { level: 9 } // Maximum compression
});
archive.on('error', (err) => {
throw err;
});
output.on('close', async () => {
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
// Update database
await db('events').where('id', event.id).update({
is_archived: true,
archive_path: path.relative(getStoragePath(), archivePath),
archived_at: new Date()
});
// Delete original files
await fs.rm(eventPath, { recursive: true });
// Delete thumbnails
const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) {
if (photo.thumbnail_path) {
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted
}
}
// Queue completion email
await queueEmail(event.id, event.admin_email, 'archive_complete', {
event_name: event.event_name,
archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB'
});
});
archive.pipe(output);
archive.directory(eventPath, false);
await archive.finalize();
} catch (error) {
logger.error(`Error archiving event ${event.slug}:`, error);
throw error;
}
}
module.exports = { archiveEvent };
+549
View File
@@ -0,0 +1,549 @@
const nodemailer = require('nodemailer');
const Handlebars = require('handlebars');
const { db } = require('../database/db');
const logger = require('../utils/logger');
let transporter = null;
let lastConfigHash = null;
// Generate hash from config for change detection
function generateConfigHash(config) {
const crypto = require('crypto');
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
return crypto.createHash('md5').update(configString).digest('hex');
}
// Initialize transporter from database config
async function initializeTransporter(forceReinit = false) {
try {
const config = await db('email_configs').first();
if (!config) {
logger.warn('No email configuration found');
return null;
}
// Check if configuration has changed
const currentConfigHash = generateConfigHash(config);
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
// Configuration hasn't changed, return existing transporter
return transporter;
}
// Configuration has changed or first initialization
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
secure: config.smtp_secure,
auth: config.smtp_user ? {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined
});
// Verify configuration
await transporter.verify();
logger.info('Email transporter initialized successfully');
// Update the config hash
lastConfigHash = currentConfigHash;
return transporter;
} catch (error) {
logger.error('Failed to initialize email transporter:', error);
transporter = null;
lastConfigHash = null;
return null;
}
}
// Get the appropriate language for a recipient
async function getRecipientLanguage(email, eventId = null) {
// First priority: Check event language setting if eventId is provided
if (eventId) {
try {
const event = await db('events').where('id', eventId).first();
if (event && event.language) {
return event.language;
}
} catch (error) {
logger.error('Error fetching event language:', error);
}
}
// Second priority: Check app_settings for general default language
try {
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
.first();
if (langSetting && langSetting.setting_value) {
return langSetting.setting_value;
}
} catch (error) {
logger.error('Error fetching app settings language:', error);
}
// Third priority: Check email configs for default language
try {
const emailConfig = await db('email_configs').first();
if (emailConfig && emailConfig.default_language) {
return emailConfig.default_language;
}
} catch (error) {
logger.error('Error fetching email config language:', error);
}
// Fourth priority: Check if the email domain suggests German
if (email) {
const germanDomains = ['.de', '.at', '.ch', '.li'];
const domain = email.toLowerCase();
if (germanDomains.some(d => domain.endsWith(d))) {
return 'de';
}
}
return 'en'; // Default to English
}
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
// Fall back to non-language-specific fields for backward compatibility
let subject = template[subjectField] || template.subject || '';
let htmlBody = template[htmlField] || template.body_html || '';
let textBody = template[textField] || template.body_text || '';
// Process variables before template compilation
const processedVariables = { ...variables };
// Handle password security message
if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = language === 'de'
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
}
// Format dates if they exist
if (processedVariables.event_date) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
}
if (processedVariables.expiry_date) {
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
}
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
let logoUrl = '';
let companyName = 'PicPeak';
try {
const brandingSettings = await db('app_settings')
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
.select('setting_key', 'setting_value');
brandingSettings.forEach(setting => {
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
try {
logoUrl = JSON.parse(setting.setting_value);
} catch (e) {
logoUrl = setting.setting_value;
}
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
try {
companyName = JSON.parse(setting.setting_value);
} catch (e) {
companyName = setting.setting_value;
}
}
});
} catch (error) {
logger.error('Error fetching branding settings:', error);
}
// If no custom logo, use default PicPeak logo
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
// Compile templates with Handlebars
const subjectTemplate = Handlebars.compile(subject);
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Process templates with processedVariables (includes formatted dates and security messages)
subject = subjectTemplate(processedVariables);
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
// Wrap HTML body in styled template
const styledHtmlBody = `
<!DOCTYPE html>
<html lang="${language}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.email-wrapper {
background-color: #f5f5f5;
padding: 40px 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.email-header {
background-color: #5C8762;
padding: 30px;
text-align: center;
}
.logo {
max-width: 180px;
height: auto;
margin-bottom: 10px;
}
.email-content {
padding: 40px 30px;
}
.email-content h2 {
color: #5C8762;
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
}
.email-content p {
line-height: 1.6;
margin-bottom: 15px;
}
.email-content ul {
background-color: #f9f9f9;
padding: 20px 20px 20px 40px;
border-radius: 5px;
margin: 20px 0;
}
.email-content li {
margin-bottom: 10px;
}
.button {
display: inline-block;
padding: 12px 30px;
background-color: #5C8762;
color: white !important;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
margin: 20px 0;
}
.button:hover {
background-color: #4a6f4f;
}
.email-footer {
background-color: #f9f9f9;
padding: 30px;
text-align: center;
border-top: 1px solid #eee;
}
.email-footer img {
max-width: 120px;
height: auto;
margin-bottom: 15px;
opacity: 0.8;
}
.email-footer p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
a {
color: #5C8762;
text-decoration: underline;
}
a:hover {
color: #4a6f4f;
}
strong {
color: #333;
}
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 20px 10px;
}
.email-content {
padding: 30px 20px;
}
.email-header {
padding: 20px;
}
.logo {
max-width: 150px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<img src="${logoFullUrl}" alt="${companyName}" class="logo">
</div>
<div class="email-content">
${htmlBody}
</div>
<div class="email-footer">
<img src="${logoFullUrl}" alt="${companyName}">
<p>${companyName}</p>
<p style="font-size: 12px; color: #999;">© ${new Date().getFullYear()} ${companyName}. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>`;
return { subject, htmlBody: styledHtmlBody, textBody };
}
// Send email using template
async function sendTemplateEmail(to, templateKey, variables) {
try {
// Always check for configuration changes before sending
transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
// Get email template
const template = await db('email_templates')
.where('template_key', templateKey)
.first();
if (!template) {
throw new Error(`Email template '${templateKey}' not found`);
}
// Get email config for from address
const config = await db('email_configs').first();
if (!config) {
throw new Error('Email configuration not found');
}
// Determine recipient language (pass eventId if available in variables)
const language = await getRecipientLanguage(to, variables.eventId || null);
// Process template with variables
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
// Send email
const info = await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: to,
subject: subject,
html: htmlBody,
text: textBody || htmlBody.replace(/<[^>]*>/g, '') // Strip HTML if no text version
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
return { success: true, messageId: info.messageId, language };
} catch (error) {
logger.error('Error sending template email:', error);
throw error;
}
}
// Process email queue
async function processEmailQueue() {
logger.info('Email queue processor: Checking for pending emails...');
try {
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
logger.info('Transporter not initialized, attempting to initialize...');
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
}
}
let pendingEmails = [];
try {
pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.orderBy('created_at', 'asc')
.limit(10);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
return;
}
if (pendingEmails.length === 0) {
logger.info('Email queue processor: No pending emails found');
return;
}
logger.info(`Processing ${pendingEmails.length} emails from queue`);
for (const email of pendingEmails) {
try {
const emailData = typeof email.email_data === 'string'
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
// Mark as sent
await db('email_queue')
.where('id', email.id)
.update({
status: 'sent',
sent_at: new Date()
});
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
// Increment retry count
try {
await db('email_queue')
.where('id', email.id)
.update({
retry_count: email.retry_count + 1,
error_message: error.message
});
} catch (updateError) {
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
// If update fails due to column issue, try without any potential auto-added fields
if (updateError.message && updateError.message.includes('updated_at')) {
logger.warn('Detected updated_at column issue, attempting raw query...');
await db.raw(
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
[email.retry_count + 1, error.message, email.id]
);
}
}
logger.error(`Failed to send email ${email.id}:`, error);
}
}
} catch (error) {
logger.error('Error processing email queue:', error);
}
}
// Queue an email for sending
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
await db('email_queue').insert({
event_id: eventId,
recipient_email: recipientEmail,
email_type: emailType,
email_data: JSON.stringify(emailData),
status: 'pending',
retry_count: 0,
created_at: new Date()
});
logger.info(`Email queued: ${emailType} to ${recipientEmail}`);
} catch (error) {
logger.error('Error queueing email:', error);
throw error;
}
}
// Test email connection
async function testEmailConnection() {
try {
if (!transporter) {
await initializeTransporter();
}
if (!transporter) {
return false;
}
await transporter.verify();
return true;
} catch (error) {
logger.error('Email connection test failed:', error);
return false;
}
}
// Start email queue processor
let emailQueueInterval = null;
function startEmailQueueProcessor() {
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
}
function stopEmailQueueProcessor() {
if (emailQueueInterval) {
clearInterval(emailQueueInterval);
emailQueueInterval = null;
logger.info('Email queue processor stopped');
}
}
// Initialize on module load - DISABLED for production startup
// This will be called from server.js after database is ready
// initializeTransporter().then(() => {
// startEmailQueueProcessor();
// });
module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
testEmailConnection
};
+65
View File
@@ -0,0 +1,65 @@
const nodemailer = require('nodemailer');
const { db } = require('../database/db');
const { emailTemplates } = require('./emailTemplates');
const logger = require('../utils/logger');
// Create transporter
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
async function sendEmail(to, type, data) {
try {
const template = emailTemplates[type](data);
const info = await transporter.sendMail({
from: process.env.EMAIL_FROM,
to: to,
subject: template.subject,
html: template.html,
text: template.text
});
logger.info(`Email sent: ${info.messageId}`);
return info;
} catch (error) {
logger.error('Error sending email:', error);
throw error;
}
}
// Process email queue
async function processEmailQueue() {
const pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.limit(10);
for (const email of pendingEmails) {
try {
const emailData = JSON.parse(email.email_data);
await sendEmail(email.recipient_email, email.email_type, emailData);
await db('email_queue').where('id', email.id).update({
status: 'sent',
sent_at: new Date()
});
} catch (error) {
await db('email_queue').where('id', email.id).update({
retry_count: email.retry_count + 1,
error_message: error.message
});
}
}
}
// Start email queue processor
setInterval(processEmailQueue, 60000); // Process every minute
module.exports = { sendEmail, processEmailQueue };
+101
View File
@@ -0,0 +1,101 @@
const cron = require('node-cron');
const { db } = require('../database/db');
const { archiveEvent } = require('./archiveService');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatDate } = require('../utils/dateFormatter');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
logger.info('Expiration checker started');
}
async function checkExpirations() {
try {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Check for events needing warning emails
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
for (const event of eventsNeedingWarning) {
// Check if warning email already sent
const existingWarning = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'expiration_warning')
.first();
if (!existingWarning) {
await queueExpirationWarning(event);
}
}
// Check for expired events
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
await handleExpiredEvent(event);
}
} catch (error) {
logger.error('Error checking expirations:', error);
}
}
async function queueExpirationWarning(event) {
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
// Determine language based on email domain
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en';
// Queue email to host
await queueEmail(event.id, event.host_email, 'expiration_warning', {
host_name: event.host_name || event.host_email.split('@')[0],
event_name: event.event_name,
days_remaining: daysRemaining.toString(),
expiration_date: await formatDate(event.expires_at, emailLang),
gallery_link: event.share_link
});
logger.info(`Queued expiration warning for event ${event.slug}`);
}
async function handleExpiredEvent(event) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
event_name: event.event_name,
admin_email: event.admin_email
});
// Also notify admin
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
event_name: event.event_name,
admin_email: event.admin_email
});
// Start archiving process
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
}
}
module.exports = { startExpirationChecker };
+105
View File
@@ -0,0 +1,105 @@
const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
function startFileWatcher() {
const watcher = chokidar.watch(WATCH_PATH(), {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100
}
});
watcher
.on('add', async (filePath) => {
try {
await processNewPhoto(filePath);
} catch (error) {
logger.error('Error processing new photo:', error);
}
})
.on('unlink', async (filePath) => {
try {
await removePhoto(filePath);
} catch (error) {
logger.error('Error removing photo:', error);
}
});
logger.info('File watcher started');
}
async function processNewPhoto(filePath) {
const relativePath = path.relative(WATCH_PATH(), filePath);
const pathParts = relativePath.split(path.sep);
if (pathParts.length < 2) return; // Not in correct folder structure
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
if (filename.startsWith('temp_')) {
logger.debug(`Skipping temporary upload file: ${filename}`);
return;
}
// Find the event
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return;
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Check if photo already exists
const existingPhoto = await db('photos')
.where({ event_id: event.id, filename: path.basename(filePath) })
.first();
if (!existingPhoto) {
// Add to database
await db('photos').insert({
event_id: event.id,
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
});
logger.info(`Added new photo: ${relativePath}`);
} else {
logger.debug(`Photo already exists: ${relativePath}`);
}
}
async function removePhoto(filePath) {
const relativePath = path.relative(WATCH_PATH(), filePath);
// Remove from database
await db('photos').where({ path: relativePath }).delete();
logger.info(`Removed photo: ${relativePath}`);
}
module.exports = { startFileWatcher };
+134
View File
@@ -0,0 +1,134 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
const THUMBNAIL_WIDTH = 300;
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
async function generateThumbnail(imagePath, options = {}) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailDir = getThumbnailPath();
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
// Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true });
// Check if we need to regenerate (for broken thumbnails)
if (options.regenerate) {
try {
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, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true, // More memory efficient for large images
failOnError: false // Don't fail on minor issues
})
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.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);
} catch (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;
}
}
/**
* 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 };
+112
View File
@@ -0,0 +1,112 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
const uploadedPhotos = [];
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
throw new Error('Event not found');
}
// Process each file
for (const file of files) {
const trx = await db.transaction();
try {
// Get category info if provided
let category = null;
let counter = 1;
const parsedCategoryId = categoryId ? parseInt(categoryId) : null;
if (parsedCategoryId) {
// Get category and update counter
category = await trx('photo_categories')
.where({ id: parsedCategoryId })
.first();
if (category) {
counter = (category.photo_counter || 0) + 1;
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
}
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Move file to event folder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(destPath, { recursive: true });
const newPath = path.join(destPath, newFilename);
// Use copyFile and unlink instead of rename to avoid cross-device issues
await fs.copyFile(file.path, newPath);
await fs.unlink(file.path);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database with uploaded_by field
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual',
size_bytes: file.size,
uploaded_by: uploadedBy
});
// Commit transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: newFilename,
size: file.size,
category_id: parsedCategoryId || null,
uploaded_by: uploadedBy
});
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
if (trx) await trx.rollback();
// Continue with other files
}
}
return uploadedPhotos;
}
module.exports = {
processUploadedPhotos
};
+283
View File
@@ -0,0 +1,283 @@
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.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
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.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'
},
requestUrl: req.originalUrl,
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
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
};
+58
View File
@@ -0,0 +1,58 @@
const axios = require('axios');
const { db } = require('../database/db');
async function verifyRecaptcha(token) {
// Check if reCAPTCHA is enabled
const settings = await db('app_settings')
.whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key'])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(setting => {
try {
settingsMap[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
settingsMap[setting.setting_key] = setting.setting_value;
}
});
const isEnabled = settingsMap.security_enable_recaptcha === true ||
settingsMap.security_enable_recaptcha === 'true';
const secretKey = settingsMap.security_recaptcha_secret_key;
// If reCAPTCHA is not enabled, always return true
if (!isEnabled) {
return true;
}
// If enabled but no token provided, fail
if (!token) {
return false;
}
// If no secret key configured, log warning but pass
if (!secretKey) {
console.warn('reCAPTCHA enabled but no secret key configured');
return true;
}
try {
const response = await axios.post(
'https://www.google.com/recaptcha/api/siteverify',
null,
{
params: {
secret: secretKey,
response: token
}
}
);
return response.data.success === true;
} catch (error) {
console.error('reCAPTCHA verification error:', error);
return false;
}
}
module.exports = { verifyRecaptcha };
+226
View File
@@ -0,0 +1,226 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
class WatermarkService {
constructor() {
this.cache = new Map();
this.cacheMaxAge = 3600000; // 1 hour in milliseconds
}
/**
* Get watermark settings from database
*/
async getWatermarkSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_watermark_enabled',
'branding_watermark_logo_path',
'branding_watermark_position',
'branding_watermark_opacity',
'branding_watermark_size',
'branding_company_name'
])
.select('setting_key', 'setting_value');
const settingsObj = {};
settings.forEach(setting => {
try {
settingsObj[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
settingsObj[setting.setting_key] = setting.setting_value;
}
});
return {
enabled: settingsObj.branding_watermark_enabled || false,
logoPath: settingsObj.branding_watermark_logo_path || null,
position: settingsObj.branding_watermark_position || 'bottom-right',
opacity: parseInt(settingsObj.branding_watermark_opacity || 50),
size: parseInt(settingsObj.branding_watermark_size || 15),
companyName: settingsObj.branding_company_name || 'Photo Gallery'
};
} catch (error) {
console.error('Error fetching watermark settings:', error);
return null;
}
}
/**
* Calculate position coordinates based on position string
*/
getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) {
const padding = 20;
let left, top;
switch (position) {
case 'top-left':
left = padding;
top = padding;
break;
case 'top-right':
left = imageWidth - watermarkWidth - padding;
top = padding;
break;
case 'bottom-left':
left = padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'bottom-right':
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2);
break;
default:
// Default to bottom-right
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
}
return { left: Math.max(0, left), top: Math.max(0, top) };
}
/**
* Apply watermark to an image
*/
async applyWatermark(imagePath, settings) {
try {
if (!settings || !settings.enabled) {
// Return original image if watermarking is disabled
return await fs.readFile(imagePath);
}
// Check cache first
const cacheKey = `${imagePath}_${JSON.stringify(settings)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
return cached.buffer;
}
// Load the main image
const image = sharp(imagePath);
const metadata = await image.metadata();
let watermarkBuffer;
let watermarkMetadata;
// Try to use logo watermark first
if (settings.logoPath) {
try {
const watermarkImage = sharp(settings.logoPath);
watermarkMetadata = await watermarkImage.metadata();
// Calculate watermark size based on percentage of main image
const scaleFactor = settings.size / 100;
const targetWidth = Math.floor(metadata.width * scaleFactor);
const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width));
// Resize watermark and apply opacity
watermarkBuffer = await watermarkImage
.resize(targetWidth, targetHeight, { fit: 'inside' })
.composite([{
input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]),
raw: {
width: 1,
height: 1,
channels: 4
},
tile: true,
blend: 'dest-in'
}])
.toBuffer();
watermarkMetadata = { width: targetWidth, height: targetHeight };
} catch (error) {
console.error('Error processing watermark logo:', error);
watermarkBuffer = null;
}
}
// If no logo or logo failed, create text watermark
if (!watermarkBuffer) {
const fontSize = Math.max(16, Math.floor(metadata.width * 0.03));
const padding = 10;
// Create SVG text watermark
const svg = `
<svg width="${settings.companyName.length * fontSize * 0.6 + padding * 2}" height="${fontSize + padding * 2}">
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.5" rx="5"/>
<text x="${padding}" y="${fontSize + padding/2}"
font-family="Arial, sans-serif"
font-size="${fontSize}"
fill="white"
opacity="${settings.opacity / 100}">
${settings.companyName}
</text>
</svg>
`;
watermarkBuffer = Buffer.from(svg);
watermarkMetadata = {
width: settings.companyName.length * fontSize * 0.6 + padding * 2,
height: fontSize + padding * 2
};
}
// Calculate position
const position = this.getPositionCoordinates(
metadata.width,
metadata.height,
watermarkMetadata.width,
watermarkMetadata.height,
settings.position
);
// Apply watermark
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.toBuffer();
// Cache the result
this.cache.set(cacheKey, {
buffer: watermarkedBuffer,
timestamp: Date.now()
});
// Clean old cache entries
this.cleanCache();
return watermarkedBuffer;
} catch (error) {
console.error('Error applying watermark:', error);
// Return original image on error
return await fs.readFile(imagePath);
}
}
/**
* Clean old cache entries
*/
cleanCache() {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.cacheMaxAge) {
this.cache.delete(key);
}
}
}
/**
* Clear entire cache
*/
clearCache() {
this.cache.clear();
}
}
module.exports = new WatermarkService();