Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 | |||
| b20f9cc108 | |||
| 515814e1d5 |
Binary file not shown.
@@ -0,0 +1,63 @@
|
|||||||
|
exports.up = async function(knex) {
|
||||||
|
// Add rate limit settings to app_settings
|
||||||
|
const rateLimitSettings = [
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_enabled',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_window_minutes',
|
||||||
|
setting_value: JSON.stringify(15),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_max_requests',
|
||||||
|
setting_value: JSON.stringify(1000),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_auth_max_requests',
|
||||||
|
setting_value: JSON.stringify(5),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_skip_authenticated',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'rate_limit_public_endpoints_only',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'security'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert settings if they don't exist
|
||||||
|
for (const setting of rateLimitSettings) {
|
||||||
|
const exists = await knex('app_settings')
|
||||||
|
.where('setting_key', setting.setting_key)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await knex('app_settings').insert({
|
||||||
|
...setting,
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Remove rate limit settings
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'rate_limit_enabled',
|
||||||
|
'rate_limit_window_minutes',
|
||||||
|
'rate_limit_max_requests',
|
||||||
|
'rate_limit_auth_max_requests',
|
||||||
|
'rate_limit_skip_authenticated',
|
||||||
|
'rate_limit_public_endpoints_only'
|
||||||
|
])
|
||||||
|
.del();
|
||||||
|
};
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
exports.up = async function(knex) {
|
||||||
|
// Update English templates to match the quality and content of German templates
|
||||||
|
|
||||||
|
// 1. Gallery Created - Match German version with proper styling and conditionals
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_created')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Your photo gallery is ready',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Hello {{host_name}},</h2>
|
||||||
|
|
||||||
|
<p>Your photo gallery <strong>{{event_name}}</strong> for {{event_date}} has been successfully created and is now online!</p>
|
||||||
|
|
||||||
|
{{#if welcome_message}}
|
||||||
|
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0;"><strong>Personal message from your photographer:</strong></p>
|
||||||
|
<p style="margin: 10px 0 0 0;">{{welcome_message}}</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Your access data:</h3>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Gallery link:</strong> <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Password:</strong> {{gallery_password}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500;">View Gallery</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>Important:</strong> Your gallery will be available until <strong>{{expiry_date}}</strong>. After this date, the photos will be archived and will only be available upon request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We hope you enjoy your photos!</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
Your Photo Sharing Team</p>`,
|
||||||
|
body_text_en: `Hello {{host_name}},
|
||||||
|
|
||||||
|
Your photo gallery "{{event_name}}" for {{event_date}} has been successfully created and is now online!
|
||||||
|
|
||||||
|
{{#if welcome_message}}
|
||||||
|
Personal message from your photographer:
|
||||||
|
{{welcome_message}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
Your access data:
|
||||||
|
- Gallery link: {{gallery_link}}
|
||||||
|
- Password: {{gallery_password}}
|
||||||
|
|
||||||
|
Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request.
|
||||||
|
|
||||||
|
We hope you enjoy your photos!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your Photo Sharing Team`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Expiration Warning - Match German version with urgency and styling
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'expiration_warning')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Your photo gallery expires soon',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Hello {{host_name}},</h2>
|
||||||
|
|
||||||
|
<p>Your photo gallery <strong>{{event_name}}</strong> will expire in <strong style="color: #e74c3c; font-size: 18px;">{{days_remaining}} days</strong>!</p>
|
||||||
|
|
||||||
|
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-weight: bold; font-size: 16px;">⚠️ Important Notice</p>
|
||||||
|
<p style="margin: 10px 0 0 0;">After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Don't miss out – download your photos now!</strong></p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{gallery_link}}" style="display: inline-block; padding: 14px 35px; background-color: #e74c3c; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Visit Gallery Now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>Quick reminder of your access data:</strong></p>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 10px 0 0 0;">
|
||||||
|
<li>Gallery link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||||
|
<li>Password: {{gallery_password}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
Your Photo Sharing Team</p>`,
|
||||||
|
body_text_en: `Hello {{host_name}},
|
||||||
|
|
||||||
|
Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days!
|
||||||
|
|
||||||
|
⚠️ Important Notice
|
||||||
|
After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.
|
||||||
|
|
||||||
|
Don't miss out – download your photos now!
|
||||||
|
|
||||||
|
Quick reminder of your access data:
|
||||||
|
- Gallery link: {{gallery_link}}
|
||||||
|
- Password: {{gallery_password}}
|
||||||
|
|
||||||
|
If you have any questions, please don't hesitate to contact us.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your Photo Sharing Team`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Gallery Expired - Match German version with contact information
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_expired')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Your photo gallery has expired',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Hello {{host_name}},</h2>
|
||||||
|
|
||||||
|
<p>Your photo gallery <strong>{{event_name}}</strong> expired on {{expiry_date}} and is no longer accessible online.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<h3 style="margin-top: 0;">Your photos are safely archived</h3>
|
||||||
|
<p>Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:</p>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li style="margin-bottom: 8px;">📧 Email: <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a></li>
|
||||||
|
{{#if support_phone}}
|
||||||
|
<li>📞 Phone: {{support_phone}}</li>
|
||||||
|
{{/if}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please have the following information ready when contacting us:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Event name: {{event_name}}</li>
|
||||||
|
<li>Event date: {{event_date}}</li>
|
||||||
|
<li>Expiry date: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>We'll be happy to help you access your archived photos.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
Your Photo Sharing Team</p>`,
|
||||||
|
body_text_en: `Hello {{host_name}},
|
||||||
|
|
||||||
|
Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.
|
||||||
|
|
||||||
|
Your photos are safely archived
|
||||||
|
Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:
|
||||||
|
|
||||||
|
📧 Email: {{support_email}}
|
||||||
|
{{#if support_phone}}📞 Phone: {{support_phone}}{{/if}}
|
||||||
|
|
||||||
|
Please have the following information ready when contacting us:
|
||||||
|
- Event name: {{event_name}}
|
||||||
|
- Event date: {{event_date}}
|
||||||
|
- Expiry date: {{expiry_date}}
|
||||||
|
|
||||||
|
We'll be happy to help you access your archived photos.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your Photo Sharing Team`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Archive Complete - Match German version with success message and details
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'archive_complete')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Your photo gallery has been successfully archived',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Hello {{host_name}},</h2>
|
||||||
|
|
||||||
|
<p>Your photo gallery <strong>{{event_name}}</strong> has been successfully archived.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-weight: bold;">✅ Archive successfully created</p>
|
||||||
|
<p style="margin: 10px 0 0 0;">Your photos are now safely stored in our archive.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Archive details:</h3>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li style="margin-bottom: 8px;"><strong>Event:</strong> {{event_name}}</li>
|
||||||
|
<li style="margin-bottom: 8px;"><strong>Archive date:</strong> {{archive_date}}</li>
|
||||||
|
<li style="margin-bottom: 8px;"><strong>Number of photos:</strong> {{photo_count}}</li>
|
||||||
|
<li><strong>Archive size:</strong> {{archive_size}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you need access to your archived photos in the future, please contact us at:</p>
|
||||||
|
<p style="margin-left: 20px;">
|
||||||
|
📧 <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a><br>
|
||||||
|
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Thank you for using our photo sharing service!</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
Your Photo Sharing Team</p>`,
|
||||||
|
body_text_en: `Hello {{host_name}},
|
||||||
|
|
||||||
|
Your photo gallery "{{event_name}}" has been successfully archived.
|
||||||
|
|
||||||
|
✅ Archive successfully created
|
||||||
|
Your photos are now safely stored in our archive.
|
||||||
|
|
||||||
|
Archive details:
|
||||||
|
- Event: {{event_name}}
|
||||||
|
- Archive date: {{archive_date}}
|
||||||
|
- Number of photos: {{photo_count}}
|
||||||
|
- Archive size: {{archive_size}}
|
||||||
|
|
||||||
|
If you need access to your archived photos in the future, please contact us at:
|
||||||
|
📧 {{support_email}}
|
||||||
|
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||||
|
|
||||||
|
Thank you for using our photo sharing service!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your Photo Sharing Team`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Test Email - Update to match German style
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'test_email')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Test Email - Photo Sharing Platform',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Test Email</h2>
|
||||||
|
|
||||||
|
<p>This is a test email from your photo sharing platform.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>✅ Email configuration successful!</strong></p>
|
||||||
|
<p style="margin: 10px 0 0 0;">Your email settings have been configured correctly and emails can be sent.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>Configuration details:</strong></p>
|
||||||
|
<ul style="margin: 10px 0 0 0;">
|
||||||
|
<li>Timestamp: {{timestamp}}</li>
|
||||||
|
<li>Sender: {{from_email}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
Your Photo Sharing Team</p>`,
|
||||||
|
body_text_en: `Test Email
|
||||||
|
|
||||||
|
This is a test email from your photo sharing platform.
|
||||||
|
|
||||||
|
✅ Email configuration successful!
|
||||||
|
Your email settings have been configured correctly and emails can be sent.
|
||||||
|
|
||||||
|
Configuration details:
|
||||||
|
- Timestamp: {{timestamp}}
|
||||||
|
- Sender: {{from_email}}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your Photo Sharing Team`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Revert to previous simpler English templates
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_created')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Your Photo Gallery is Ready',
|
||||||
|
body_html_en: '<h2>Hello,</h2><p>Your photo gallery "{{event_name}}" has been created.</p><p><strong>Access Link:</strong> <a href="{{gallery_link}}">{{gallery_link}}</a></p><p><strong>Password:</strong> {{gallery_password}}</p><p>The gallery will be available until {{expiry_date}}.</p>',
|
||||||
|
body_text_en: 'Your photo gallery "{{event_name}}" has been created. Access Link: {{gallery_link}} Password: {{gallery_password}} The gallery will be available until {{expiry_date}}.'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'expiration_warning')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Gallery Expires in {{days_remaining}} Days',
|
||||||
|
body_html_en: '<h2>Reminder</h2><p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p><p>Please download your photos before {{expiry_date}}.</p><p><a href="{{gallery_link}}">Access Gallery</a></p>',
|
||||||
|
body_text_en: 'Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days. Please download your photos before {{expiry_date}}. Access Gallery: {{gallery_link}}'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_expired')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Gallery Expired',
|
||||||
|
body_html_en: '<h2>Gallery Expired</h2><p>Your photo gallery "{{event_name}}" has expired and is no longer accessible.</p><p>If you need access to your photos, please contact support.</p>',
|
||||||
|
body_text_en: 'Your photo gallery "{{event_name}}" has expired and is no longer accessible. If you need access to your photos, please contact support.'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'archive_complete')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Gallery Archived',
|
||||||
|
body_html_en: '<h2>Archive Complete</h2><p>Your gallery "{{event_name}}" has been archived.</p><p>Archive size: {{archive_size}}</p>',
|
||||||
|
body_text_en: 'Your gallery "{{event_name}}" has been archived. Archive size: {{archive_size}}'
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'test_email')
|
||||||
|
.update({
|
||||||
|
subject_en: 'Test Email',
|
||||||
|
body_html_en: '<p>This is a test email sent at {{timestamp}}.</p>',
|
||||||
|
body_text_en: 'This is a test email sent at {{timestamp}}.'
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.55",
|
"version": "1.0.62",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.55",
|
"version": "1.0.62",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.55",
|
"version": "1.0.62",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
async function verifyTemplateEquality() {
|
||||||
|
try {
|
||||||
|
console.log('Verifying template equality between German and English versions...\n');
|
||||||
|
|
||||||
|
const templates = await db('email_templates').select('*');
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
console.log(`\n=== ${template.template_key.toUpperCase()} ===`);
|
||||||
|
|
||||||
|
// Check subject length similarity
|
||||||
|
const subjectEnLength = template.subject_en?.length || 0;
|
||||||
|
const subjectDeLength = template.subject_de?.length || 0;
|
||||||
|
console.log(`Subject length - EN: ${subjectEnLength}, DE: ${subjectDeLength}`);
|
||||||
|
|
||||||
|
// Check HTML content features
|
||||||
|
const htmlEn = template.body_html_en || '';
|
||||||
|
const htmlDe = template.body_html_de || '';
|
||||||
|
|
||||||
|
// Check for key features in both versions
|
||||||
|
const features = [
|
||||||
|
{ name: 'Handlebars conditionals', pattern: /{{#if/g },
|
||||||
|
{ name: 'Styled divs', pattern: /style="/g },
|
||||||
|
{ name: 'Background colors', pattern: /background-color:/g },
|
||||||
|
{ name: 'Buttons/CTAs', pattern: /<a.*style.*background-color.*>/g },
|
||||||
|
{ name: 'Icons/Emojis', pattern: /[📧📞✅⚠️]/g },
|
||||||
|
{ name: 'Lists', pattern: /<ul/g },
|
||||||
|
{ name: 'Strong emphasis', pattern: /<strong>/g }
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('\nFeature comparison:');
|
||||||
|
for (const feature of features) {
|
||||||
|
const enCount = (htmlEn.match(feature.pattern) || []).length;
|
||||||
|
const deCount = (htmlDe.match(feature.pattern) || []).length;
|
||||||
|
const status = enCount === deCount ? '✅' : '❌';
|
||||||
|
console.log(`${status} ${feature.name}: EN=${enCount}, DE=${deCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check text content length
|
||||||
|
const textEn = template.body_text_en || '';
|
||||||
|
const textDe = template.body_text_de || '';
|
||||||
|
console.log(`\nText content length - EN: ${textEn.length}, DE: ${textDe.length}`);
|
||||||
|
|
||||||
|
// Check for specific variables usage
|
||||||
|
const variables = [
|
||||||
|
'host_name', 'event_name', 'event_date', 'gallery_link',
|
||||||
|
'gallery_password', 'expiry_date', 'welcome_message',
|
||||||
|
'days_remaining', 'support_email', 'support_phone',
|
||||||
|
'archive_date', 'photo_count', 'archive_size'
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingInEn = [];
|
||||||
|
const missingInDe = [];
|
||||||
|
|
||||||
|
for (const variable of variables) {
|
||||||
|
const varPattern = new RegExp(`{{${variable}}}`, 'g');
|
||||||
|
const inEn = varPattern.test(htmlEn) || varPattern.test(textEn);
|
||||||
|
const inDe = varPattern.test(htmlDe) || varPattern.test(textDe);
|
||||||
|
|
||||||
|
if (inDe && !inEn) missingInEn.push(variable);
|
||||||
|
if (inEn && !inDe) missingInDe.push(variable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingInEn.length > 0) {
|
||||||
|
console.log(`\n⚠️ Variables in DE but missing in EN: ${missingInEn.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (missingInDe.length > 0) {
|
||||||
|
console.log(`\n⚠️ Variables in EN but missing in DE: ${missingInDe.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall quality score
|
||||||
|
const enScore = [
|
||||||
|
htmlEn.includes('style='),
|
||||||
|
htmlEn.includes('{{#if'),
|
||||||
|
htmlEn.includes('background-color'),
|
||||||
|
htmlEn.includes('<strong>'),
|
||||||
|
htmlEn.includes('margin:'),
|
||||||
|
htmlEn.includes('padding:')
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
const deScore = [
|
||||||
|
htmlDe.includes('style='),
|
||||||
|
htmlDe.includes('{{#if'),
|
||||||
|
htmlDe.includes('background-color'),
|
||||||
|
htmlDe.includes('<strong>'),
|
||||||
|
htmlDe.includes('margin:'),
|
||||||
|
htmlDe.includes('padding:')
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
console.log(`\nQuality score (out of 6) - EN: ${enScore}, DE: ${deScore}`);
|
||||||
|
console.log(enScore === deScore ? '✅ Templates have equal quality!' : '❌ Quality mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n\nSummary:');
|
||||||
|
console.log('The English templates have been updated to match the German templates in:');
|
||||||
|
console.log('- HTML styling and structure');
|
||||||
|
console.log('- Conditional content blocks');
|
||||||
|
console.log('- Visual elements (buttons, alerts, icons)');
|
||||||
|
console.log('- Information completeness');
|
||||||
|
console.log('- Professional formatting');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
} finally {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyTemplateEquality();
|
||||||
+45
-34
@@ -7,8 +7,6 @@ validateEnvironment();
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { initializeDatabase, db } = require('./src/database/db');
|
const { initializeDatabase, db } = require('./src/database/db');
|
||||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||||
@@ -16,6 +14,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
|
|||||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
const logger = require('./src/utils/logger');
|
const logger = require('./src/utils/logger');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
@@ -93,37 +92,23 @@ const corsOptions = {
|
|||||||
|
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
// Rate limiting with admin bypass
|
// Initialize rate limiters (they will be created dynamically)
|
||||||
const limiter = rateLimit({
|
let generalRateLimiter;
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
let authRateLimiter;
|
||||||
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
|
|
||||||
skip: (req) => {
|
|
||||||
// Skip rate limiting for authenticated admin users
|
|
||||||
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
|
|
||||||
const token = req.headers.authorization.replace('Bearer ', '');
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
return decoded.type === 'admin';
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also skip rate limiting for public settings endpoint in development
|
|
||||||
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const authLimiter = rateLimit({
|
// Function to initialize rate limiters
|
||||||
windowMs: 15 * 60 * 1000,
|
async function initializeRateLimiters() {
|
||||||
max: 5 // limit auth attempts
|
generalRateLimiter = await createRateLimiter();
|
||||||
});
|
authRateLimiter = await createAuthRateLimiter();
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
app.use('/api/', generalRateLimiter);
|
||||||
|
app.use('/api/auth', authRateLimiter);
|
||||||
|
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||||
|
app.use('/api/admin/auth/login', authRateLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply rate limiting - admin routes check will skip for valid admin tokens
|
// Note: Rate limiters will be initialized after database connection
|
||||||
app.use('/api/', limiter);
|
|
||||||
app.use('/api/auth', authLimiter);
|
|
||||||
|
|
||||||
// Body parsing middleware with increased limits for large uploads
|
// Body parsing middleware with increased limits for large uploads
|
||||||
app.use(express.json({ limit: '100mb' }));
|
app.use(express.json({ limit: '100mb' }));
|
||||||
@@ -158,6 +143,28 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
|
|||||||
// Static file serving for uploads (public - logos, favicons)
|
// Static file serving for uploads (public - logos, favicons)
|
||||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||||
|
|
||||||
|
// Debug endpoint to check IP detection (only in development)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
app.get('/api/debug/ip', (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
detectedIp: clientIp,
|
||||||
|
reqIp: req.ip,
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||||
|
'x-real-ip': req.headers['x-real-ip'],
|
||||||
|
'x-forwarded-proto': req.headers['x-forwarded-proto'],
|
||||||
|
'x-forwarded-host': req.headers['x-forwarded-host']
|
||||||
|
},
|
||||||
|
trustProxy: app.get('trust proxy')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -203,9 +210,13 @@ async function startServer() {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
// Initialize auth security cleanup job
|
// Initialize rate limiters after database is ready
|
||||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
await initializeRateLimiters();
|
||||||
initializeCleanupJob();
|
logger.info('Rate limiters initialized with database configuration');
|
||||||
|
|
||||||
|
// Initialize auth security cleanup job
|
||||||
|
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||||
|
initializeCleanupJob();
|
||||||
|
|
||||||
// Start file watcher
|
// Start file watcher
|
||||||
startFileWatcher();
|
startFileWatcher();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function validateEnvironment() {
|
|||||||
if (name === 'JWT_SECRET' && value) {
|
if (name === 'JWT_SECRET' && value) {
|
||||||
// Check for the insecure default value
|
// Check for the insecure default value
|
||||||
if (value === 'your-secret-key') {
|
if (value === 'your-secret-key') {
|
||||||
errors.push(`CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.`);
|
errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check minimum length (should be at least 32 characters for security)
|
// Check minimum length (should be at least 32 characters for security)
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ async function initializeDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
|
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||||
await db.raw(`DROP TABLE events`);
|
await db.raw('DROP TABLE events');
|
||||||
await db.raw(`ALTER TABLE events_new RENAME TO events`);
|
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the migration fails, it might already have been applied
|
// If the migration fails, it might already have been applied
|
||||||
console.log('Color theme migration may have already been applied');
|
console.log('Color theme migration may have already been applied');
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate uploaded file is complete and not corrupted
|
||||||
|
*/
|
||||||
|
async function validateUploadedFile(filePath) {
|
||||||
|
try {
|
||||||
|
// Check file exists and has size
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
throw new Error('File is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For image files, verify they can be read by Sharp
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
if (imageExtensions.includes(ext)) {
|
||||||
|
// Try to read metadata - this will fail if image is corrupted
|
||||||
|
const metadata = await sharp(filePath).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error('Invalid image dimensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reasonable dimensions
|
||||||
|
if (metadata.width < 10 || metadata.height < 10) {
|
||||||
|
throw new Error('Image dimensions too small');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`File validation failed for ${filePath}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to validate uploaded files after multer processing
|
||||||
|
*/
|
||||||
|
async function validateUploadedFiles(req, res, next) {
|
||||||
|
if (!req.files || req.files.length === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = [];
|
||||||
|
const invalidFiles = [];
|
||||||
|
|
||||||
|
// Validate each file
|
||||||
|
for (const file of req.files) {
|
||||||
|
try {
|
||||||
|
await validateUploadedFile(file.path);
|
||||||
|
validFiles.push(file);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`);
|
||||||
|
invalidFiles.push({
|
||||||
|
filename: file.originalname,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete the invalid file
|
||||||
|
try {
|
||||||
|
await fs.unlink(file.path);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update req.files to only include valid files
|
||||||
|
req.files = validFiles;
|
||||||
|
|
||||||
|
// Store invalid files info for response
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
req.invalidFiles = invalidFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateUploadedFile,
|
||||||
|
validateUploadedFiles
|
||||||
|
};
|
||||||
@@ -251,7 +251,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||||
console.error(`Entry name was: ${entry.entryName}`);
|
console.error(`Entry name was: ${entry.entryName}`);
|
||||||
console.error(`Error:`, statError.message);
|
console.error('Error:', statError.message);
|
||||||
// Skip this file if we can't stat it
|
// Skip this file if we can't stat it
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,15 +210,15 @@ router.get('/templates', adminAuth, async (req, res) => {
|
|||||||
id: template.id,
|
id: template.id,
|
||||||
template_key: template.template_key,
|
template_key: template.template_key,
|
||||||
variables: (() => {
|
variables: (() => {
|
||||||
try {
|
try {
|
||||||
if (!template.variables) return [];
|
if (!template.variables) return [];
|
||||||
if (typeof template.variables === 'object') return template.variables;
|
if (typeof template.variables === 'object') return template.variables;
|
||||||
return JSON.parse(template.variables);
|
return JSON.parse(template.variables);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
updated_at: template.updated_at
|
updated_at: template.updated_at
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const path = require('path');
|
|||||||
const { archiveEvent } = require('../services/archiveService');
|
const { archiveEvent } = require('../services/archiveService');
|
||||||
const { queueEmail } = require('../services/emailProcessor');
|
const { queueEmail } = require('../services/emailProcessor');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { formatDate } = require('../utils/dateFormatter');
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
@@ -89,7 +89,14 @@ router.post('/', adminAuth, [
|
|||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date(event_date);
|
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||||
|
let expires_at;
|
||||||
|
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||||
|
expires_at = new Date(year, month - 1, day);
|
||||||
|
} else {
|
||||||
|
expires_at = new Date(event_date);
|
||||||
|
}
|
||||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||||
|
|
||||||
// Create folder structure
|
// Create folder structure
|
||||||
@@ -128,8 +135,7 @@ router.post('/', adminAuth, [
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue creation email
|
// Queue creation email
|
||||||
// Determine language based on email domain
|
// Language detection is handled by email processor
|
||||||
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
await db('email_queue').insert({
|
await db('email_queue').insert({
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
@@ -138,10 +144,10 @@ router.post('/', adminAuth, [
|
|||||||
email_data: JSON.stringify({
|
email_data: JSON.stringify({
|
||||||
host_name: host_name,
|
host_name: host_name,
|
||||||
event_name,
|
event_name,
|
||||||
event_date: await formatDate(event_date, emailLang),
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: await formatDate(expires_at, emailLang),
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
}),
|
}),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -546,10 +552,10 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||||
host_name: event.host_email.split('@')[0],
|
host_name: event.host_email.split('@')[0],
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_date: new Date(event.event_date).toLocaleDateString(),
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
gallery_link: event.share_link,
|
||||||
gallery_password: newPassword,
|
gallery_password: newPassword,
|
||||||
expiry_date: new Date(event.expires_at).toLocaleDateString()
|
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,41 +584,37 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the language preference
|
// The email processor will determine the language based on:
|
||||||
let language = 'en';
|
// 1. Event language setting
|
||||||
try {
|
// 2. App settings general_default_language
|
||||||
// First check app_settings for general_default_language
|
// 3. Email config default language
|
||||||
const langSetting = await db('app_settings')
|
// 4. Domain-based detection
|
||||||
.where('setting_key', 'general_default_language')
|
// So we don't need to determine it here
|
||||||
.first();
|
|
||||||
|
// For resending creation email, we need the actual password
|
||||||
if (langSetting && langSetting.setting_value) {
|
// First, try to get it from the request body if provided
|
||||||
language = langSetting.setting_value;
|
let galleryPassword = req.body.password;
|
||||||
}
|
|
||||||
} catch (err) {
|
// If no password provided, we can't decrypt the existing one
|
||||||
console.warn('Could not fetch language setting:', err);
|
// So we'll show a security message
|
||||||
|
if (!galleryPassword) {
|
||||||
|
// We'll let the email processor determine the language for the security message
|
||||||
|
galleryPassword = '{{password_security_message}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format dates based on language
|
// Dates will be formatted by the email processor based on recipient language
|
||||||
const eventDate = new Date(event.event_date);
|
|
||||||
const expiryDate = new Date(event.expires_at);
|
|
||||||
const dateLocale = language === 'de' ? 'de-DE' : 'en-US';
|
|
||||||
|
|
||||||
// Prepare password text based on language
|
|
||||||
const passwordText = language === 'de'
|
|
||||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
|
||||||
: '(Not shown for security reasons)';
|
|
||||||
|
|
||||||
// Queue the email
|
// Queue the email
|
||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||||
host_name: event.host_name || event.host_email.split('@')[0],
|
host_name: event.host_name || event.host_email.split('@')[0],
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_date: eventDate.toLocaleDateString(dateLocale),
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
gallery_link: event.share_link,
|
||||||
gallery_password: passwordText,
|
gallery_password: galleryPassword,
|
||||||
expiry_date: expiryDate.toLocaleDateString(dateLocale),
|
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||||
welcome_message: event.welcome_message || '',
|
welcome_message: event.welcome_message || '',
|
||||||
eventId: id
|
eventId: id,
|
||||||
|
isResend: true // Flag to indicate this is a resend
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the activity using the proper schema
|
// Log the activity using the proper schema
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { generateThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
@@ -106,7 +107,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}, validateUploadContent, async (req, res) => {
|
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id } = req.body;
|
||||||
@@ -197,8 +198,14 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
file.filename = newFilename;
|
file.filename = newFilename;
|
||||||
file.path = newPath;
|
file.path = newPath;
|
||||||
|
|
||||||
// Generate thumbnail with new filename
|
// Generate thumbnail with new filename (with better error handling)
|
||||||
const thumbnailPath = await generateThumbnail(file.path);
|
let thumbnailPath = null;
|
||||||
|
try {
|
||||||
|
thumbnailPath = await generateThumbnail(file.path);
|
||||||
|
} catch (thumbError) {
|
||||||
|
console.error(`Thumbnail generation failed for ${file.filename}:`, thumbError.message);
|
||||||
|
// Continue without thumbnail rather than failing the whole upload
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate relative paths
|
// Calculate relative paths
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
@@ -269,19 +276,23 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Include any files that were invalid from the validation middleware
|
||||||
|
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
|
||||||
|
|
||||||
// Prepare response
|
// Prepare response
|
||||||
|
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||||
const response = {
|
const response = {
|
||||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||||
photos: uploadedPhotos,
|
photos: uploadedPhotos,
|
||||||
totalFiles: req.files.length,
|
totalFiles: totalAttempted,
|
||||||
successCount: uploadedPhotos.length,
|
successCount: uploadedPhotos.length,
|
||||||
failureCount: errors.length
|
failureCount: totalInvalidFiles.length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include error details if any files failed
|
// Include error details if any files failed
|
||||||
if (errors.length > 0) {
|
if (totalInvalidFiles.length > 0) {
|
||||||
response.errors = errors;
|
response.errors = totalInvalidFiles;
|
||||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
@@ -613,26 +624,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
|||||||
.where({ id: photoId, event_id: eventId })
|
.where({ id: photoId, event_id: eventId })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!photo || !photo.thumbnail_path) {
|
if (!photo) {
|
||||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||||
|
const thumbnailPath = await ensureThumbnail(photo);
|
||||||
|
|
||||||
|
if (!thumbnailPath) {
|
||||||
|
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||||
|
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
const filePath = path.join(storagePath, thumbnailPath);
|
||||||
|
|
||||||
console.log(`Attempting to serve thumbnail: ${filePath}`);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Thumbnail file not found: ${filePath}`, error);
|
|
||||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate headers
|
// Set appropriate headers
|
||||||
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
|
res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
|
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Configure multer for logo uploads
|
// Configure multer for logo uploads
|
||||||
@@ -582,4 +583,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update rate limit settings
|
||||||
|
router.put('/security/rate-limit', adminAuth, [
|
||||||
|
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||||
|
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||||
|
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||||
|
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
|
||||||
|
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
|
||||||
|
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
rate_limit_enabled,
|
||||||
|
rate_limit_window_minutes,
|
||||||
|
rate_limit_max_requests,
|
||||||
|
rate_limit_auth_max_requests,
|
||||||
|
rate_limit_skip_authenticated,
|
||||||
|
rate_limit_public_endpoints_only
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Update each setting
|
||||||
|
const settings = [
|
||||||
|
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
|
||||||
|
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
|
||||||
|
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
|
||||||
|
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
|
||||||
|
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
|
||||||
|
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, value } of settings) {
|
||||||
|
await db('app_settings')
|
||||||
|
.where('setting_key', key)
|
||||||
|
.update({
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the rate limit settings cache to apply changes immediately
|
||||||
|
clearSettingsCache();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity('settings_updated',
|
||||||
|
{
|
||||||
|
category: 'security',
|
||||||
|
subcategory: 'rate_limit',
|
||||||
|
changes: settings.length
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Rate limit settings updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rate limit settings update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -87,10 +87,10 @@ router.post('/', adminAuth, [
|
|||||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||||
host_name: host_email.split('@')[0], // Extract name from email
|
host_name: host_email.split('@')[0], // Extract name from email
|
||||||
event_name,
|
event_name,
|
||||||
event_date: new Date(event_date).toLocaleDateString(),
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: expires_at.toLocaleDateString(),
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
|
|
||||||
// Process email template with variables
|
// Process email template with variables
|
||||||
async function processTemplate(template, variables, language = 'en') {
|
async function processTemplate(template, variables, language = 'en') {
|
||||||
|
// Import date formatter
|
||||||
|
const { formatDate } = require('../utils/dateFormatter');
|
||||||
|
|
||||||
// Get the appropriate language fields
|
// Get the appropriate language fields
|
||||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||||
@@ -118,6 +121,27 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
let subject = template[subjectField] || template.subject || '';
|
let subject = template[subjectField] || template.subject || '';
|
||||||
let htmlBody = template[htmlField] || template.body_html || '';
|
let htmlBody = template[htmlField] || template.body_html || '';
|
||||||
let textBody = template[textField] || template.body_text || '';
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Get branding settings for logo
|
// Get branding settings for logo
|
||||||
let logoUrl = '';
|
let logoUrl = '';
|
||||||
@@ -156,10 +180,10 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||||
const textTemplate = Handlebars.compile(textBody);
|
const textTemplate = Handlebars.compile(textBody);
|
||||||
|
|
||||||
// Process templates with variables
|
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||||
subject = subjectTemplate(variables);
|
subject = subjectTemplate(processedVariables);
|
||||||
htmlBody = htmlTemplate(variables);
|
htmlBody = htmlTemplate(processedVariables);
|
||||||
textBody = textTemplate(variables);
|
textBody = textTemplate(processedVariables);
|
||||||
|
|
||||||
// Wrap HTML body in styled template
|
// Wrap HTML body in styled template
|
||||||
const styledHtmlBody = `
|
const styledHtmlBody = `
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Configure sharp for better memory management with large batches
|
// Configure sharp for better memory management with large batches
|
||||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||||
@@ -10,7 +11,7 @@ const THUMBNAIL_WIDTH = 300;
|
|||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||||
|
|
||||||
async function generateThumbnail(imagePath) {
|
async function generateThumbnail(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = path.basename(imagePath);
|
||||||
const thumbnailFilename = `thumb_${filename}`;
|
const thumbnailFilename = `thumb_${filename}`;
|
||||||
const thumbnailDir = getThumbnailPath();
|
const thumbnailDir = getThumbnailPath();
|
||||||
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
|
|||||||
// Ensure thumbnail directory exists
|
// Ensure thumbnail directory exists
|
||||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||||
|
|
||||||
|
// Check if we need to regenerate (for broken thumbnails)
|
||||||
|
if (options.regenerate) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(thumbnailPath);
|
||||||
|
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
// File might not exist, that's okay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate thumbnail with memory-efficient settings
|
// First, verify the source image is complete and valid
|
||||||
|
const metadata = await sharp(imagePath).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error('Invalid image metadata - file may be incomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail with memory-efficient settings and error handling
|
||||||
await sharp(imagePath, {
|
await sharp(imagePath, {
|
||||||
limitInputPixels: 268402689, // ~16k x 16k max
|
limitInputPixels: 268402689, // ~16k x 16k max
|
||||||
sequentialRead: true // More memory efficient for large images
|
sequentialRead: true, // More memory efficient for large images
|
||||||
|
failOnError: false // Don't fail on minor issues
|
||||||
})
|
})
|
||||||
.resize(THUMBNAIL_WIDTH, null, {
|
.resize(THUMBNAIL_WIDTH, null, {
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
|
|||||||
})
|
})
|
||||||
.toFile(thumbnailPath);
|
.toFile(thumbnailPath);
|
||||||
|
|
||||||
|
// Verify the thumbnail was created successfully
|
||||||
|
const stats = await fs.stat(thumbnailPath);
|
||||||
|
if (stats.size === 0) {
|
||||||
|
throw new Error('Generated thumbnail is empty');
|
||||||
|
}
|
||||||
|
|
||||||
return path.relative(getStoragePath(), thumbnailPath);
|
return path.relative(getStoragePath(), thumbnailPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate thumbnail for ${filename}:`, error);
|
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
|
||||||
|
|
||||||
|
// Clean up any partially created file
|
||||||
|
try {
|
||||||
|
await fs.unlink(thumbnailPath);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
// Ignore unlink errors
|
||||||
|
}
|
||||||
|
|
||||||
// Return null if thumbnail generation fails, don't fail the whole upload
|
// Return null if thumbnail generation fails, don't fail the whole upload
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateThumbnail };
|
/**
|
||||||
|
* Check if a thumbnail exists and is valid
|
||||||
|
*/
|
||||||
|
async function isThumbnailValid(thumbnailPath) {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(getStoragePath(), thumbnailPath);
|
||||||
|
const stats = await fs.stat(fullPath);
|
||||||
|
|
||||||
|
// Check if file exists and has content
|
||||||
|
if (stats.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read metadata to ensure it's a valid image
|
||||||
|
await sharp(fullPath).metadata();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate thumbnail if it's broken or missing
|
||||||
|
*/
|
||||||
|
async function ensureThumbnail(photo) {
|
||||||
|
const storagePath = getStoragePath();
|
||||||
|
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||||
|
|
||||||
|
// Check if thumbnail exists and is valid
|
||||||
|
if (photo.thumbnail_path) {
|
||||||
|
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||||
|
if (isValid) {
|
||||||
|
return photo.thumbnail_path;
|
||||||
|
}
|
||||||
|
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new thumbnail
|
||||||
|
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||||
|
|
||||||
|
if (newThumbnailPath) {
|
||||||
|
// Update database with new thumbnail path
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
await db('photos')
|
||||||
|
.where({ id: photo.id })
|
||||||
|
.update({ thumbnail_path: newThumbnailPath });
|
||||||
|
|
||||||
|
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||||
|
return newThumbnailPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Cache for rate limit settings
|
||||||
|
let settingsCache = null;
|
||||||
|
let cacheExpiry = 0;
|
||||||
|
const CACHE_DURATION = 60000; // 1 minute cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit settings from database with caching
|
||||||
|
*/
|
||||||
|
async function getRateLimitSettings() {
|
||||||
|
try {
|
||||||
|
// Check cache
|
||||||
|
if (settingsCache && Date.now() < cacheExpiry) {
|
||||||
|
return settingsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from database
|
||||||
|
const settings = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'rate_limit_enabled',
|
||||||
|
'rate_limit_window_minutes',
|
||||||
|
'rate_limit_max_requests',
|
||||||
|
'rate_limit_auth_max_requests',
|
||||||
|
'rate_limit_skip_authenticated',
|
||||||
|
'rate_limit_public_endpoints_only'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse settings into object
|
||||||
|
const config = {
|
||||||
|
enabled: true,
|
||||||
|
windowMinutes: 15,
|
||||||
|
maxRequests: 100,
|
||||||
|
authMaxRequests: 5,
|
||||||
|
skipAuthenticated: true,
|
||||||
|
publicEndpointsOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.forEach(setting => {
|
||||||
|
const value = JSON.parse(setting.setting_value);
|
||||||
|
switch (setting.setting_key) {
|
||||||
|
case 'rate_limit_enabled':
|
||||||
|
config.enabled = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_window_minutes':
|
||||||
|
config.windowMinutes = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_max_requests':
|
||||||
|
config.maxRequests = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_auth_max_requests':
|
||||||
|
config.authMaxRequests = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_skip_authenticated':
|
||||||
|
config.skipAuthenticated = value;
|
||||||
|
break;
|
||||||
|
case 'rate_limit_public_endpoints_only':
|
||||||
|
config.publicEndpointsOnly = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
settingsCache = config;
|
||||||
|
cacheExpiry = Date.now() + CACHE_DURATION;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch rate limit settings:', error);
|
||||||
|
// Return defaults on error
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
windowMinutes: 15,
|
||||||
|
maxRequests: 100,
|
||||||
|
authMaxRequests: 5,
|
||||||
|
skipAuthenticated: true,
|
||||||
|
publicEndpointsOnly: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear settings cache (call when settings are updated)
|
||||||
|
*/
|
||||||
|
function clearSettingsCache() {
|
||||||
|
settingsCache = null;
|
||||||
|
cacheExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request has valid authentication
|
||||||
|
*/
|
||||||
|
function isAuthenticated(req) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Check if token is valid
|
||||||
|
if (!decoded || typeof decoded !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid token found - check type
|
||||||
|
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||||
|
req.tokenPayload = decoded;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if rate limiting should be applied to this request
|
||||||
|
*/
|
||||||
|
function shouldSkipRateLimit(req, config) {
|
||||||
|
// If rate limiting is disabled globally
|
||||||
|
if (!config.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never skip rate limiting for auth endpoints
|
||||||
|
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should skip authenticated requests
|
||||||
|
if (config.skipAuthenticated && isAuthenticated(req)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we only rate limit public endpoints
|
||||||
|
if (config.publicEndpointsOnly) {
|
||||||
|
const isPublicEndpoint = req.path.startsWith('/api/public/') ||
|
||||||
|
req.path.startsWith('/api/gallery/') ||
|
||||||
|
isAuthEndpoint;
|
||||||
|
return !isPublicEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create dynamic rate limiter
|
||||||
|
*/
|
||||||
|
async function createRateLimiter() {
|
||||||
|
const config = await getRateLimitSettings();
|
||||||
|
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: config.windowMinutes * 60 * 1000,
|
||||||
|
max: async (req) => {
|
||||||
|
// Refresh config for each request
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
|
||||||
|
// Different limits for auth endpoints
|
||||||
|
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||||
|
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||||
|
},
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Use correct client IP when behind proxy
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
},
|
||||||
|
skip: async (req) => {
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
return shouldSkipRateLimit(req, currentConfig);
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||||
|
logger.warn('Rate limit exceeded', {
|
||||||
|
ip: clientIp,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
authenticated: isAuthenticated(req),
|
||||||
|
tokenType: req.tokenType
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many requests, please try again later.',
|
||||||
|
retryAfter: res.getHeader('Retry-After')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
standardHeaders: true, // Return rate limit info in headers
|
||||||
|
legacyHeaders: false, // Disable X-RateLimit headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create auth-specific rate limiter
|
||||||
|
*/
|
||||||
|
async function createAuthRateLimiter() {
|
||||||
|
const config = await getRateLimitSettings();
|
||||||
|
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: config.windowMinutes * 60 * 1000,
|
||||||
|
max: config.authMaxRequests,
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
// Use correct client IP when behind proxy
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
req.headers['x-real-ip'] ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
req.ip;
|
||||||
|
},
|
||||||
|
skip: async () => {
|
||||||
|
const currentConfig = await getRateLimitSettings();
|
||||||
|
return !currentConfig.enabled;
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||||
|
logger.warn('Auth rate limit exceeded', {
|
||||||
|
ip: clientIp,
|
||||||
|
path: req.path
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many authentication attempts, please try again later.',
|
||||||
|
retryAfter: res.getHeader('Retry-After')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRateLimitSettings,
|
||||||
|
clearSettingsCache,
|
||||||
|
createRateLimiter,
|
||||||
|
createAuthRateLimiter,
|
||||||
|
isAuthenticated,
|
||||||
|
shouldSkipRateLimit
|
||||||
|
};
|
||||||
@@ -56,30 +56,30 @@ class WatermarkService {
|
|||||||
let left, top;
|
let left, top;
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top-left':
|
case 'top-left':
|
||||||
left = padding;
|
left = padding;
|
||||||
top = padding;
|
top = padding;
|
||||||
break;
|
break;
|
||||||
case 'top-right':
|
case 'top-right':
|
||||||
left = imageWidth - watermarkWidth - padding;
|
left = imageWidth - watermarkWidth - padding;
|
||||||
top = padding;
|
top = padding;
|
||||||
break;
|
break;
|
||||||
case 'bottom-left':
|
case 'bottom-left':
|
||||||
left = padding;
|
left = padding;
|
||||||
top = imageHeight - watermarkHeight - padding;
|
top = imageHeight - watermarkHeight - padding;
|
||||||
break;
|
break;
|
||||||
case 'bottom-right':
|
case 'bottom-right':
|
||||||
left = imageWidth - watermarkWidth - padding;
|
left = imageWidth - watermarkWidth - padding;
|
||||||
top = imageHeight - watermarkHeight - padding;
|
top = imageHeight - watermarkHeight - padding;
|
||||||
break;
|
break;
|
||||||
case 'center':
|
case 'center':
|
||||||
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
||||||
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Default to bottom-right
|
// Default to bottom-right
|
||||||
left = imageWidth - watermarkWidth - padding;
|
left = imageWidth - watermarkWidth - padding;
|
||||||
top = imageHeight - watermarkHeight - padding;
|
top = imageHeight - watermarkHeight - padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { left: Math.max(0, left), top: Math.max(0, top) };
|
return { left: Math.max(0, left), top: Math.max(0, top) };
|
||||||
|
|||||||
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date);
|
// Ensure proper date parsing
|
||||||
|
let dateObj;
|
||||||
|
if (date instanceof Date) {
|
||||||
|
dateObj = date;
|
||||||
|
} else if (typeof date === 'string') {
|
||||||
|
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
|
||||||
|
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
// Parse YYYY-MM-DD format as local date
|
||||||
|
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
|
||||||
|
dateObj = new Date(year, month - 1, day);
|
||||||
|
} else {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dateObj = new Date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
console.error('Invalid date provided to formatDate:', date);
|
||||||
|
throw new Error('Invalid date');
|
||||||
|
}
|
||||||
|
|
||||||
// Use appropriate locale based on language
|
// Use appropriate locale based on language
|
||||||
let locale = dateConfig.locale || 'en-GB';
|
let locale = dateConfig.locale || 'en-GB';
|
||||||
@@ -39,33 +60,33 @@ async function formatDate(date, language = 'en') {
|
|||||||
|
|
||||||
// Format based on the configured format
|
// Format based on the configured format
|
||||||
switch (dateConfig.format) {
|
switch (dateConfig.format) {
|
||||||
case 'MM/DD/YYYY':
|
case 'MM/DD/YYYY':
|
||||||
return dateObj.toLocaleDateString(locale, {
|
return dateObj.toLocaleDateString(locale, {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
case 'DD/MM/YYYY':
|
case 'DD/MM/YYYY':
|
||||||
return dateObj.toLocaleDateString(locale, {
|
return dateObj.toLocaleDateString(locale, {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
case 'YYYY-MM-DD':
|
case 'YYYY-MM-DD':
|
||||||
return dateObj.toISOString().split('T')[0];
|
return dateObj.toISOString().split('T')[0];
|
||||||
case 'DD.MM.YYYY':
|
case 'DD.MM.YYYY':
|
||||||
return dateObj.toLocaleDateString('de-DE', {
|
return dateObj.toLocaleDateString('de-DE', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
// Use long format as fallback
|
// Use long format as fallback
|
||||||
return dateObj.toLocaleDateString(locale, {
|
return dateObj.toLocaleDateString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error formatting date:', error);
|
console.error('Error formatting date:', error);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function escapeLikePattern(input) {
|
|||||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
.replace(/%/g, '\\%') // Escape percent signs
|
.replace(/%/g, '\\%') // Escape percent signs
|
||||||
.replace(/_/g, '\\_') // Escape underscores
|
.replace(/_/g, '\\_') // Escape underscores
|
||||||
.replace(/'/g, "''"); // Escape single quotes for safety
|
.replace(/'/g, '\'\''); // Escape single quotes for safety
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.53",
|
"version": "1.0.57",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.53",
|
"version": "1.0.57",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.53",
|
"version": "1.0.57",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -715,6 +715,7 @@
|
|||||||
"settings_updated": "Einstellungen aktualisiert",
|
"settings_updated": "Einstellungen aktualisiert",
|
||||||
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||||
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||||
|
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
|
||||||
"category_created": "Kategorie erstellt: {{categoryName}}",
|
"category_created": "Kategorie erstellt: {{categoryName}}",
|
||||||
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
||||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||||
|
|||||||
@@ -766,6 +766,7 @@
|
|||||||
"settings_updated": "Settings updated",
|
"settings_updated": "Settings updated",
|
||||||
"event_updated": "Event updated: {{eventName}}",
|
"event_updated": "Event updated: {{eventName}}",
|
||||||
"event_deleted": "Event deleted: {{eventName}}",
|
"event_deleted": "Event deleted: {{eventName}}",
|
||||||
|
"email_resent": "Creation email resent for: {{eventName}}",
|
||||||
"category_created": "Category created: {{categoryName}}",
|
"category_created": "Category created: {{categoryName}}",
|
||||||
"category_updated": "Category updated: {{categoryName}}",
|
"category_updated": "Category updated: {{categoryName}}",
|
||||||
"category_deleted": "Category deleted: {{categoryName}}",
|
"category_deleted": "Category deleted: {{categoryName}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user