Merge remote-tracking branch 'upstream/main'

This commit is contained in:
2025-11-25 22:02:23 +02:00
104 changed files with 3858 additions and 3874 deletions
+4 -4
View File
@@ -1831,8 +1831,8 @@
}
},
"nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"overridden": false
},
"nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1"
},
"tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"overridden": false
},
"tunnel-agent": {
@@ -0,0 +1,48 @@
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
exports.up = async function up(knex) {
const settingKey = 'general_max_files_per_upload';
const existing = await knex('app_settings')
.where({ setting_key: settingKey })
.first();
if (existing) {
// Normalize existing value into allowed bounds
let parsedValue;
try {
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
} catch {
parsedValue = existing.setting_value;
}
const numeric = Number(parsedValue);
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
if (Number.isFinite(numeric) && numeric >= 1) {
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
}
if (normalized !== numeric) {
await knex('app_settings')
.where({ setting_key: settingKey })
.update({
setting_value: JSON.stringify(normalized),
updated_at: new Date()
});
}
return;
}
await knex('app_settings').insert({
setting_key: settingKey,
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
setting_type: 'general',
updated_at: new Date()
});
};
exports.down = async function down(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.del();
};
@@ -0,0 +1,44 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
table.string('customer_name');
});
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
table.string('customer_email');
});
// Backfill new columns from legacy host_* fields
const client = knex?.client?.config?.client;
if (client === 'pg') {
await knex.raw(`
UPDATE events
SET customer_name = COALESCE(customer_name, host_name),
customer_email = COALESCE(customer_email, host_email)
`);
} else {
// SQLite fallback
await knex('events').update({
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
customer_email: knex.raw('COALESCE(customer_email, host_email)')
});
}
};
exports.down = async function down(knex) {
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
if (hasCustomerName) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_name');
});
}
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
if (hasCustomerEmail) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_email');
});
}
};
@@ -1,23 +1,56 @@
exports.up = async function(knex) {
// Add user upload settings to events table
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
// Add user upload settings to events table (check if columns exist first)
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (!hasAllowUserUploads) {
console.log('Adding allow_user_uploads column to events table...');
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
});
} else {
console.log('Column allow_user_uploads already exists in events table, skipping...');
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (!hasUploadCategoryId) {
console.log('Adding upload_category_id column to events table...');
await knex.schema.alterTable('events', function(table) {
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
} else {
console.log('Column upload_category_id already exists in events table, skipping...');
}
// Add uploaded_by field to photos table to track who uploaded
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (!hasUploadedBy) {
console.log('Adding uploaded_by column to photos table...');
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
} else {
console.log('Column uploaded_by already exists in photos table, skipping...');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
table.dropColumn('upload_category_id');
});
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (hasAllowUserUploads) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
});
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (hasUploadCategoryId) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('upload_category_id');
});
}
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (hasUploadedBy) {
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
}
};
+2 -25
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.5",
"version": "1.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.5",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -5154,29 +5154,6 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.5",
"version": "1.1.14",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -55,5 +55,10 @@
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10",
"supertest": "^6.3.3"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
}
}
}
+3 -3
View File
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
// Note: Rate limiters will be initialized after database connection
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Body parsing middleware with increased limits for large batch uploads
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
+40
View File
@@ -3,6 +3,7 @@ const path = require('path');
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs)
try {
@@ -63,12 +64,16 @@ async function initializeDatabase() {
table.string('event_type').notNullable();
table.string('event_name').notNullable();
table.date('event_date').notNullable();
table.string('customer_name');
table.string('customer_email');
table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable();
table.string('password_hash').notNullable();
table.text('welcome_message');
table.text('color_theme');
table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true);
@@ -99,12 +104,16 @@ async function initializeDatabase() {
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
customer_name TEXT,
customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
@@ -157,6 +166,37 @@ async function initializeDatabase() {
}
}
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
if (!hasShareTokenColumn) {
await db.schema.table('events', (table) => {
table.string('share_token').unique();
});
}
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
if (!hasHostNameColumn) {
await db.schema.table('events', (table) => {
table.string('host_name');
});
}
try {
const eventsWithoutToken = await db('events')
.whereNull('share_token')
.select('id', 'share_link');
for (const event of eventsWithoutToken) {
const token = extractShareToken(event.share_link);
if (token) {
await db('events')
.where({ id: event.id })
.update({ share_token: token });
}
}
} catch (error) {
logger.warn('Share token backfill skipped', { error: error.message });
}
// Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) {
+87
View File
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
const router = express.Router();
// Change password
router.get('/profile', adminAuth, async (req, res) => {
try {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
if (!admin) {
return res.status(404).json({ error: 'Admin user not found' });
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
}
});
router.put('/profile', [
adminAuth,
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be between 3 and 50 characters'),
body('email')
.trim()
.isEmail()
.withMessage('A valid email address is required')
.normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' });
}
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email address is already in use' });
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
res.json({
message: 'Admin profile updated successfully',
user: updatedAdmin
});
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
router.post('/change-password', [
adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
+39 -8
View File
@@ -173,26 +173,57 @@ router.post('/test', adminAuth, async (req, res) => {
} catch (error) {
console.error('Test email error:', error);
console.error('Error stack:', error.stack);
// Provide more specific error messages
let errorMessage = 'Failed to send test email';
// Provide more specific error messages with translation keys
let errorMessage = 'Error sending email';
let errorKey = 'email.errors.sendFailed';
let details = error.message;
let detailsKey = 'email.errors.unknownError';
if (error.code === 'ECONNREFUSED') {
errorMessage = 'Failed to connect to SMTP server';
errorKey = 'email.errors.connectionRefused';
details = 'Please check your SMTP host and port settings';
detailsKey = 'email.errors.checkHostPort';
} else if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed';
errorKey = 'email.errors.authFailed';
details = 'Please check your SMTP username and password';
detailsKey = 'email.errors.checkCredentials';
} else if (error.code === 'ESOCKET') {
errorMessage = 'Network error';
errorMessage = 'Network error connecting to SMTP server';
errorKey = 'email.errors.networkError';
details = 'Could not establish connection to SMTP server';
detailsKey = 'email.errors.connectionFailed';
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection to SMTP server timed out';
errorKey = 'email.errors.timeout';
details = 'The server took too long to respond. Please check your network and SMTP settings.';
detailsKey = 'email.errors.timeoutDetails';
} else if (error.code === 'ENOTFOUND') {
errorMessage = 'SMTP server not found';
errorKey = 'email.errors.serverNotFound';
details = 'The SMTP host could not be resolved. Please verify the hostname.';
detailsKey = 'email.errors.checkHostname';
} else if (error.responseCode >= 500) {
errorMessage = 'SMTP server error';
errorKey = 'email.errors.serverError';
details = `Server returned error code ${error.responseCode}`;
detailsKey = 'email.errors.serverErrorDetails';
} else if (error.responseCode >= 400) {
errorMessage = 'Email rejected by server';
errorKey = 'email.errors.rejected';
details = error.response || 'The email was rejected. Check recipient address and settings.';
detailsKey = 'email.errors.rejectedDetails';
}
res.status(500).json({
res.status(500).json({
error: errorMessage,
errorKey: errorKey,
details: details,
code: error.code
detailsKey: detailsKey,
code: error.code,
responseCode: error.responseCode
});
}
});
+14 -10
View File
@@ -2,13 +2,14 @@
// Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Enhanced event creation with password validation
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('password').notEmpty(), // Remove the weak isLength validation
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim()
body('customer_name').notEmpty().trim()
], async (req, res) => {
try {
console.log('Create event request body:', req.body);
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
customer_name,
customer_email,
admin_email,
password,
welcome_message = '',
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds());
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
customer_name,
customer_email,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' });
}
});
});
+135 -30
View File
@@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
@@ -37,12 +38,67 @@ const parseBooleanInput = (value, defaultValue = true) => {
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
@@ -73,7 +129,6 @@ router.post('/', adminAuth, [
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim(),
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
@@ -91,8 +146,6 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
admin_email,
password,
welcome_message = '',
@@ -115,7 +168,16 @@ router.post('/', adminAuth, [
moderate_comments = true,
show_feedback_to_guests = true
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerColumnsAvailable = await hasCustomerContactColumns();
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
// Debug logging
@@ -133,7 +195,6 @@ router.post('/', adminAuth, [
});
let passwordValidation = null;
let galleryPassword = password;
if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
@@ -148,8 +209,6 @@ router.post('/', adminAuth, [
feedback: passwordValidation.feedback
});
}
} else {
galleryPassword = '';
}
// Generate unique slug
@@ -167,11 +226,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
@@ -201,13 +258,15 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
@@ -251,13 +310,15 @@ router.post('/', adminAuth, [
await db('email_queue').insert({
event_id: eventId,
recipient_email: host_email,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
host_name: host_name,
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
@@ -272,8 +333,10 @@ router.post('/', adminAuth, [
slug,
event_name,
event_type,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareLink,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString()
});
@@ -356,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => {
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
}));
})).map(mapEventForApi);
res.json({
events: eventsWithCounts,
@@ -418,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
.where('event_id', id)
.countDistinct('ip_address as uniqueVisitors');
res.json({
res.json(mapEventForApi({
...event,
photo_count: parseInt(photoCount) || 0,
total_size: parseInt(totalSize) || 0,
@@ -426,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos
});
}));
} catch (error) {
console.error('Error fetching event:', error);
res.status(500).json({ error: 'Failed to fetch event details' });
@@ -442,7 +505,8 @@ router.put('/:id', adminAuth, [
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
body('host_name').optional().trim().notEmpty(),
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
if (value === null || value === undefined) return true;
@@ -481,6 +545,39 @@ router.put('/:id', adminAuth, [
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
@@ -715,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
// Queue email notification if requested
if (sendEmail) {
// For password reset, we'll need to create a template or use a different approach
// For now, let's use the gallery_created template with updated password
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_email.split('@')[0],
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
@@ -773,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
// Dates will be formatted by the email processor based on recipient language
// Queue the email
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_name || event.host_email.split('@')[0],
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
@@ -789,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
try {
await logActivity('email_resent', {
email_type: 'gallery_created',
recipient: event.host_email,
recipient: recipientEmail,
ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
}, id, {
+43 -20
View File
@@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
// Use database-agnostic date calculation
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const deletedCount = await db('activity_logs')
.whereNotNull('read_at')
.where('created_at', '<', thirtyDaysAgo)
.delete();
let deletedCount = 0;
const client = db?.client?.config?.client;
if (client === 'pg') {
const primaryResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
WHERE read_at IS NOT NULL OR created_at < ?
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`,
[thirtyDaysAgo.toISOString()]
);
deletedCount = primaryResult.rows?.[0]?.count || 0;
if (deletedCount === 0) {
const fallbackResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`
);
deletedCount = fallbackResult.rows?.[0]?.count || 0;
}
} else {
deletedCount = await db('activity_logs')
.where(function () {
this.whereNotNull('read_at')
.orWhere('created_at', '<', thirtyDaysAgo);
})
.delete();
if (deletedCount === 0) {
deletedCount = await db('activity_logs').delete();
}
}
res.json({
message: 'Old notifications cleared',
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
deletedCount
});
} catch (error) {
@@ -119,18 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
}
});
// Delete all notifications
router.delete('/clear-all', adminAuth, async (req, res) => {
try {
const deletedCount = await db('activity_logs').delete();
res.json({
message: 'All notifications cleared',
deletedCount
});
} catch (error) {
console.error('Clear all notifications error:', error);
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
module.exports = router;
+81 -22
View File
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router();
// Get storage path from environment or default
@@ -66,7 +67,7 @@ const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
// Add part size limits to prevent incomplete uploads
@@ -117,17 +118,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
};
// Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
upload.array('photos', 500)(req, res, (err) => {
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch (error) {
console.error('Failed to resolve max files per upload:', error);
return res.status(500).json({ error: 'Unable to determine upload limits' });
}
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
@@ -484,24 +493,55 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Prepare update data
const updateData = {
updated_at: new Date()
};
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') {
updateData.type = category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (category_id === null || category_id === undefined) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
// Update photo
const normalizedCategoryId = parseCategoryId(category_id);
await db('photos')
.where({ id: photoId, event_id: eventId })
.update(updateData);
// Fetch and return updated photo for confirmation
const updatedPhoto = await db('photos')
.where({ id: photoId })
.update({ category_id: normalizedCategoryId });
res.json({ message: 'Photo updated successfully' });
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
@@ -581,33 +621,52 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
// Verify all photos belong to the event
const photoCount = await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.count('id as count')
.first();
if (photoCount.count !== photoIds.length) {
if (parseInt(photoCount.count) !== photoIds.length) {
return res.status(400).json({ error: 'Some photos do not belong to this event' });
}
// Update photos
const updateData = {};
// Prepare update data
const updateData = {
updated_at: new Date()
};
if (updates.category_id !== undefined) {
updateData.category_id = parseCategoryId(updates.category_id);
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
updateData.type = updates.category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (updates.category_id === null) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(updates.category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
}
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.update(updateData);
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
+30 -2
View File
@@ -18,7 +18,10 @@ const {
getRawPublicSiteSettings,
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -188,7 +191,8 @@ router.put('/branding', adminAuth, async (req, res) => {
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
logo_display_mode,
hide_powered_by
} = req.body;
const brandingSettings = {
@@ -208,7 +212,8 @@ router.put('/branding', adminAuth, async (req, res) => {
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
logo_display_mode,
hide_powered_by
};
// Handle favicon deletion if empty string or null is provided
@@ -472,9 +477,24 @@ router.put('/theme', adminAuth, async (req, res) => {
router.put('/general', adminAuth, async (req, res) => {
try {
const settings = { ...req.body };
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_files_per_upload);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
return res.status(400).json({
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
});
}
settings.general_max_files_per_upload = normalizedValue;
}
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -529,6 +549,12 @@ router.put('/general', adminAuth, async (req, res) => {
if (publicSiteKeysTouched) {
clearPublicSiteCache();
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Log activity
await db('activity_logs').insert({
@@ -567,6 +593,8 @@ router.put('/security', adminAuth, async (req, res) => {
});
}
resetSecurityConfigCache();
// Log activity
await db('activity_logs').insert({
activity_type: 'security_settings_updated',
+6 -5
View File
@@ -12,13 +12,14 @@ const {
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const {
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -33,7 +34,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: req.ip
ip: ipAddress
});
res.json({
@@ -229,14 +230,14 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
+16 -10
View File
@@ -22,6 +22,8 @@ const {
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -36,7 +38,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -171,7 +173,7 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
@@ -185,7 +187,7 @@ router.post('/gallery/verify', [
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
@@ -281,21 +283,25 @@ router.post('/gallery/share-login', [
}
const { slug, token } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
let expectedToken = event.share_link;
if (expectedToken && expectedToken.includes('/')) {
expectedToken = expectedToken.split('/').pop();
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
@@ -312,7 +318,7 @@ router.post('/gallery/share-login', [
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
+125 -16
View File
@@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
@@ -32,12 +33,66 @@ const parseBooleanInput = (value, defaultValue = true) => {
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('host_email').isEmail(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
@@ -62,7 +117,6 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_email,
admin_email,
password,
require_password: requirePasswordInput = true,
@@ -71,6 +125,15 @@ router.post('/', adminAuth, [
expiration_days = 30
} = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
@@ -98,12 +161,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link (just slug/token, not full URL)
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const shareLinkSlug = `${slug}/${shareToken}`;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password (or placeholder when not required)
const password_hash = requirePassword
@@ -126,12 +186,15 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_email,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkSlug,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
@@ -141,11 +204,13 @@ router.post('/', adminAuth, [
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, host_email, 'gallery_created', {
host_name: host_email.split('@')[0], // Extract name from email
await queueEmail(eventId, customerEmail, 'gallery_created', {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: fullShareLink,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
@@ -154,9 +219,11 @@ router.post('/', adminAuth, [
res.json({
id: eventId,
slug,
share_link: fullShareLink,
share_link: shareUrl,
expires_at,
require_password: requirePassword
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
});
} catch (error) {
console.error(error);
@@ -185,17 +252,27 @@ router.get('/', adminAuth, async (req, res) => {
event.photo_count = photoCount.count;
}
res.json(events);
res.json(events.map(mapEventForApi));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Update event
router.put('/:id', adminAuth, async (req, res) => {
router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('require_password').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
@@ -203,6 +280,38 @@ router.put('/:id', adminAuth, async (req, res) => {
delete updates.created_at;
delete updates.password_confirmation;
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
+55 -15
View File
@@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
if (!result) {
return res.status(404).json({ error: 'Gallery not found' });
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
} catch (error) {
logger.error('Error resolving gallery identifier:', error);
res.status(500).json({ error: 'Failed to resolve gallery link' });
}
});
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
@@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// Extract token from share link and verify
const expectedToken = event.share_link.split('/').pop();
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
@@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => {
'is_active',
'is_archived',
'share_link',
'share_token',
'allow_downloads',
'disable_right_click',
'watermark_downloads',
@@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
let expectedToken = event.share_link;
// Handle both formats: full URL or just token
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}
@@ -773,22 +800,35 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
// Verify the event matches the token
if (req.event.id !== eventId) {
return res.status(403).json({ error: 'Access denied' });
}
// Check if user uploads are allowed
if (!req.event.allow_user_uploads) {
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
}
// Ensure temp upload directory exists
const fs = require('fs');
const tempUploadDir = '/tmp/uploads/';
if (!fs.existsSync(tempUploadDir)) {
try {
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
logger.error('Failed to create temp upload directory:', mkdirErr);
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
}
}
// Import multer and photo processing
const multer = require('multer');
const upload = multer({
dest: '/tmp/uploads/',
limits: {
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
files: 10 // Max 10 files at once
},
+15 -6
View File
@@ -58,11 +58,15 @@ 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';
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const emailLang = recipientEmail && recipientEmail.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],
// Queue email to customer
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
days_remaining: daysRemaining.toString(),
expiration_date: await formatDate(event.expires_at, emailLang),
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
event_name: event.event_name,
admin_email: event.admin_email
admin_email: event.admin_email,
customer_name: recipientName,
customer_email: recipientEmail
});
// Also notify admin
+30 -4
View File
@@ -18,6 +18,29 @@ const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
return null;
}
// Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"')
try {
return JSON.parse(value);
} catch (e) {
// If it's not valid JSON, return the raw value
return value;
}
}
// Validate that fit value is valid for Sharp
function validateFitValue(fit) {
const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside'];
if (fit && validFitValues.includes(fit)) {
return fit;
}
return DEFAULT_THUMBNAIL_FIT;
}
// Get thumbnail settings from database
async function getThumbnailSettings() {
try {
@@ -30,16 +53,19 @@ async function getThumbnailSettings() {
'thumbnail_format'
])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(s => {
settingsMap[s.setting_key] = s.setting_value;
settingsMap[s.setting_key] = parseSettingValue(s.setting_value);
});
// Parse and validate fit value
const fitValue = validateFitValue(settingsMap.thumbnail_fit);
return {
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
fit: fitValue,
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
};
+85 -13
View File
@@ -8,20 +8,46 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
function normalizeFiles(files) {
if (!files) return [];
if (Array.isArray(files)) return files.filter(Boolean);
// Multer may expose files as an iterable object
if (typeof files[Symbol.iterator] === 'function') {
return Array.from(files).filter(Boolean);
// Handle null, undefined, or falsy values
if (!files) {
console.log('[normalizeFiles] No files provided');
return [];
}
// Handle arrays
if (Array.isArray(files)) {
const validFiles = files.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
return validFiles;
}
// Handle iterable objects (some multer configurations)
try {
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
const validFiles = Array.from(files).filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
return validFiles;
}
} catch (err) {
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
}
// Handle plain objects (multer fieldname mapping)
if (typeof files === 'object') {
return Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
try {
const validFiles = Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
return validFiles;
} catch (err) {
console.warn('[normalizeFiles] Failed to process files object:', err.message);
return [];
}
}
// Unexpected type
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
return [];
}
@@ -80,18 +106,46 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
if (!tempPath) {
throw new Error('Uploaded file is missing a temporary path');
const fileInfo = JSON.stringify({
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
availableKeys: Object.keys(file || {})
});
throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`);
}
// Verify temp file exists before copying
try {
await fs.access(tempPath);
} catch (accessErr) {
console.error(`Temp file not accessible: ${tempPath}`, {
originalname: file?.originalname,
error: accessErr.message
});
throw new Error(`Uploaded file not found at temporary location: ${tempPath}`);
}
// Use copyFile and unlink instead of rename to avoid cross-device issues
try {
await fs.copyFile(tempPath, newPath);
console.log(`Successfully copied ${file.originalname} to ${newPath}`);
} catch (copyErr) {
console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr);
throw new Error(`Failed to copy uploaded file: ${copyErr.message}`);
} finally {
// Clean up temp file with better error handling
try {
await fs.unlink(tempPath);
console.log(`Cleaned up temp file: ${tempPath}`);
} catch (unlinkErr) {
// Only warn if file exists but couldn't be deleted
// ENOENT means file was already deleted, which is fine
if (unlinkErr?.code !== 'ENOENT') {
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
error: unlinkErr.message,
code: unlinkErr.code
});
}
}
}
@@ -154,10 +208,28 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
size: file.size,
type: photoType
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
if (trx) await trx.rollback();
console.error(`Error processing file ${file.originalname}:`, {
error: error.message,
stack: error.stack,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
tempPath: file?.path || file?.filepath || file?.tempFilePath
});
if (trx) {
try {
await trx.rollback();
} catch (rollbackErr) {
console.error('Failed to rollback transaction:', rollbackErr);
}
}
// Continue with other files
// Note: Individual file failures don't stop the entire upload batch
}
}
+6 -4
View File
@@ -12,10 +12,12 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
function resolvePhotoFilePath(event, photo) {
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
const isExternal = photo.source_origin === 'external' ||
(!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external'));
if (isExternal) {
// IMPORTANT: photo.source_origin takes precedence over event.source_mode
// This allows events in "reference" mode to have mixed sources:
// - Imported photos: source_origin = 'external'
// - Uploaded photos: source_origin = 'managed'
const mode = (photo.source_origin || event.source_mode || 'managed');
if (mode === 'reference' || mode === 'external') {
if (!photo.external_relpath) {
throw new Error('Missing external_relpath for external photo');
}
+181
View File
@@ -0,0 +1,181 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
let cachedSetting = null;
let cacheExpiresAt = 0;
const parseSettingValue = (rawValue) => {
if (rawValue === undefined || rawValue === null) {
return null;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
if (typeof rawValue === 'number') {
return rawValue !== 0;
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parseSettingValue(parsed);
} catch {
const normalized = trimmed.toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false;
}
return null;
}
}
if (typeof rawValue === 'object') {
try {
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
} catch {
return null;
}
}
return null;
};
const getRawSettingValue = async () => {
try {
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
return setting?.setting_value ?? null;
} catch (error) {
console.error('Failed to read gallery URL setting:', error.message);
return null;
}
};
const isShortGalleryUrlsEnabled = async () => {
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
return cachedSetting;
}
const rawValue = await getRawSettingValue();
const parsed = parseSettingValue(rawValue);
cachedSetting = parsed === null ? false : Boolean(parsed);
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return cachedSetting;
};
const clearShareLinkSettingsCache = () => {
cachedSetting = null;
cacheExpiresAt = 0;
};
const buildShareLinkVariants = async ({ slug, shareToken }) => {
if (!shareToken) {
throw new Error('shareToken is required to build share link variants');
}
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
shortEnabled,
sharePath,
shareUrl,
shareLinkToStore: sharePath
};
};
const getEventShareToken = (event) => {
if (!event) {
return null;
}
if (event.share_token) {
return event.share_token;
}
return extractShareToken(event.share_link);
};
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier) => {
if (!identifier) {
return null;
}
const trimmed = String(identifier).trim();
if (!trimmed) {
return null;
}
const baseQuery = db('events')
.select(
'id',
'slug',
'share_link',
'share_token',
'require_password',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived'
)
.where(ACTIVE_EVENT_FILTER);
let event = await baseQuery.clone().where({ slug: trimmed }).first();
if (event) {
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_token: trimmed }).first();
if (event) {
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_link: trimmed }).first();
if (event) {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}
// As a final fallback, if identifier looks like a token but we did not match via share_token
if (isPotentialShareToken(trimmed)) {
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
if (event) {
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
}
}
return null;
};
module.exports = {
isShortGalleryUrlsEnabled,
clearShareLinkSettingsCache,
buildShareLinkVariants,
getEventShareToken,
resolveShareIdentifier
};
+87
View File
@@ -0,0 +1,87 @@
const { db } = require('../database/db');
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
const CACHE_TTL_MS = 60_000;
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
const parseSettingValue = (setting) => {
if (!setting || setting.setting_value == null) {
return null;
}
let rawValue = setting.setting_value;
if (typeof rawValue === 'string') {
try {
rawValue = JSON.parse(rawValue);
} catch {
// keep original string
}
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof rawValue === 'number') {
return rawValue;
}
return null;
};
const normalizeLimit = (value) => {
if (!Number.isFinite(value)) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
const intValue = Math.floor(value);
if (intValue < 1) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
return MAX_ALLOWED_FILES_PER_UPLOAD;
}
return intValue;
};
const getMaxFilesPerUpload = async () => {
if (Date.now() < cacheExpiresAt) {
return cachedValue;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.first();
const parsedValue = normalizeLimit(parseSettingValue(setting));
cachedValue = parsedValue;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return parsedValue;
} catch (error) {
console.error('Failed to read max files per upload setting:', error.message);
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
};
const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Worker Manager - Background service for PicPeak
*
* This service runs as a separate process to handle:
* - File watching for new photos
* - Expiration checking for events
* - Other background tasks
*/
const path = require('path');
const logger = require('../utils/logger');
// Load environment variables
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
// Import services
const { startFileWatcher } = require('./fileWatcher');
const { startExpirationChecker } = require('./expirationChecker');
let isShuttingDown = false;
async function startWorkers() {
logger.info('Starting PicPeak background workers...');
try {
// Start file watcher for automatic photo processing
startFileWatcher();
logger.info('File watcher started successfully');
// Start expiration checker for event lifecycle management
startExpirationChecker();
logger.info('Expiration checker started successfully');
logger.info('All background workers started successfully');
} catch (error) {
logger.error('Failed to start background workers:', error);
process.exit(1);
}
}
function handleShutdown(signal) {
if (isShuttingDown) {
logger.info('Shutdown already in progress...');
return;
}
isShuttingDown = true;
logger.info(`Received ${signal}. Shutting down gracefully...`);
// Give time for cleanup
setTimeout(() => {
logger.info('Worker manager shutdown complete');
process.exit(0);
}, 1000);
}
// Handle shutdown signals
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception in worker manager:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection in worker manager:', reason);
});
// Start workers
startWorkers();
+157 -16
View File
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger');
// Configuration constants
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
const DEFAULT_SECURITY_CONFIG = Object.freeze({
maxAttempts: 5,
lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
attemptWindowMs: 15 * 60 * 1000 // 15 minutes
});
const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache
let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
let cachedConfigFetchedAt = 0;
function parseStoredValue(rawValue) {
if (rawValue === undefined || rawValue === null) {
return undefined;
}
if (typeof rawValue !== 'string') {
return rawValue;
}
try {
return JSON.parse(rawValue);
} catch (error) {
logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`);
return rawValue;
}
}
function normalizePositiveInteger(name, value, fallback, options = {}) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`);
return fallback;
}
let adjustedValue = Math.floor(numericValue);
if (options.min !== undefined && adjustedValue < options.min) {
logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`);
adjustedValue = options.min;
}
if (options.max !== undefined && adjustedValue > options.max) {
logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`);
adjustedValue = options.max;
}
if (adjustedValue <= 0) {
logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`);
return fallback;
}
return adjustedValue;
}
async function loadSecurityConfigFromSettings() {
const rows = await db('app_settings').whereIn('setting_key', [
'security_max_login_attempts',
'security_lockout_duration_minutes',
'security_attempt_window_minutes'
]);
const config = { ...DEFAULT_SECURITY_CONFIG };
rows.forEach(row => {
const value = parseStoredValue(row.setting_value);
switch (row.setting_key) {
case 'security_max_login_attempts': {
config.maxAttempts = normalizePositiveInteger(
'security_max_login_attempts',
value,
DEFAULT_SECURITY_CONFIG.maxAttempts,
{ min: 1, max: 50 }
);
break;
}
case 'security_lockout_duration_minutes': {
const minutes = normalizePositiveInteger(
'security_lockout_duration_minutes',
value,
DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000),
{ min: 1, max: 24 * 60 }
);
config.lockoutDurationMs = minutes * 60 * 1000;
break;
}
case 'security_attempt_window_minutes': {
const minutes = normalizePositiveInteger(
'security_attempt_window_minutes',
value,
DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000),
{ min: 1, max: 24 * 60 }
);
config.attemptWindowMs = minutes * 60 * 1000;
break;
}
default:
break;
}
});
return config;
}
async function getSecurityConfig(options = {}) {
const now = Date.now();
const forceRefresh = options.forceRefresh === true;
if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) {
return cachedSecurityConfig;
}
try {
const config = await loadSecurityConfigFromSettings();
cachedSecurityConfig = config;
cachedConfigFetchedAt = now;
return cachedSecurityConfig;
} catch (error) {
logger.error('Error loading security configuration:', error);
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
cachedConfigFetchedAt = now;
return cachedSecurityConfig;
}
}
function resetSecurityConfigCache() {
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
cachedConfigFetchedAt = 0;
}
/**
* Track failed login attempt
@@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
if (!tableExists) {
return;
}
const { attemptWindowMs } = await getSecurityConfig();
await db('login_attempts').insert({
identifier,
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
});
// Clear old failed attempts for this user
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
const cutoffTime = new Date(Date.now() - attemptWindowMs);
await db('login_attempts')
.where('identifier', identifier)
.where('success', formatBoolean(false))
@@ -83,30 +215,39 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
/**
* Check if account is locked due to too many failed attempts
* @param {string} identifier - Username or email
* @param {string} [ipAddress] - Optional IP address scope
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
*/
async function checkAccountLockout(identifier) {
async function checkAccountLockout(identifier, ipAddress) {
try {
// Check if table exists first
const tableExists = await db.schema.hasTable('login_attempts');
if (!tableExists) {
return { isLocked: false };
}
const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig();
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
const recentWindow = new Date(Date.now() - attemptWindowMs);
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
const failedAttemptsQuery = db('login_attempts')
.where('identifier', identifier)
.where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
.where('attempt_time', '>=', recentWindow.toISOString());
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
if (ipAddress) {
failedAttemptsQuery.andWhere('ip_address', ipAddress);
}
const failedAttempts = await failedAttemptsQuery
.orderBy('attempt_time', 'desc')
.limit(maxAttempts);
if (failedAttempts.length >= maxAttempts) {
// Check if still within lockout period
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs;
const now = Date.now();
if (now < lockoutEnd) {
@@ -216,6 +357,6 @@ module.exports = {
checkSuspiciousActivity,
getGenericAuthError,
initializeCleanupJob,
MAX_LOGIN_ATTEMPTS,
LOCKOUT_DURATION
};
getSecurityConfig,
resetSecurityConfigCache
};
+36
View File
@@ -0,0 +1,36 @@
/**
* Resolve the originating client IP address, accounting for reverse proxies.
* Returns the first entry from X-Forwarded-For when available, otherwise falls back
* to Express/Node connection properties.
* @param {import('express').Request} req
* @returns {string}
*/
function getClientIp(req) {
if (!req) {
return '';
}
const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
if (firstIp) {
return firstIp;
}
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
const [firstIp] = forwardedFor;
if (firstIp) {
return firstIp.trim();
}
}
return (
req.ip ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
''
);
}
module.exports = { getClientIp };
+63
View File
@@ -0,0 +1,63 @@
const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/;
/**
* Extracts the share token portion from a stored share link.
* Supports full URLs, absolute paths, and legacy slug/token formats.
* @param {string|null|undefined} shareLink
* @returns {string|null}
*/
function extractShareToken(shareLink) {
if (!shareLink) {
return null;
}
const trimmed = String(shareLink).trim();
if (!trimmed) {
return null;
}
// Remove protocol + host when a full URL is stored
const path = trimmed.replace(/^https?:\/\/[^/]+/i, '');
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) {
return null;
}
const candidate = segments[segments.length - 1];
return candidate || null;
}
/**
* Returns true if the provided identifier looks like a generated share token.
* @param {string|null|undefined} identifier
* @returns {boolean}
*/
function isPotentialShareToken(identifier) {
if (!identifier) {
return false;
}
return SHARE_TOKEN_REGEX.test(String(identifier).trim());
}
/**
* Builds the gallery share path depending on whether short URLs are enabled.
* @param {string} slug
* @param {string} shareToken
* @param {boolean} useShort
* @returns {string}
*/
function buildSharePath(slug, shareToken, useShort) {
if (!shareToken) {
throw new Error('shareToken is required to build share path');
}
if (useShort || !slug) {
return `/gallery/${shareToken}`;
}
return `/gallery/${slug}/${shareToken}`;
}
module.exports = {
extractShareToken,
isPotentialShareToken,
buildSharePath
};