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
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:
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user