diff --git a/backend/migrations/core/044_add_event_password_toggle.js b/backend/migrations/core/044_add_event_password_toggle.js new file mode 100644 index 0000000..2d26d57 --- /dev/null +++ b/backend/migrations/core/044_add_event_password_toggle.js @@ -0,0 +1,18 @@ +exports.up = async function (knex) { + const hasColumn = await knex.schema.hasColumn('events', 'require_password'); + if (!hasColumn) { + await knex.schema.table('events', (table) => { + table.boolean('require_password').notNullable().defaultTo(true); + }); + await knex('events').update({ require_password: true }); + } +}; + +exports.down = async function (knex) { + const hasColumn = await knex.schema.hasColumn('events', 'require_password'); + if (hasColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('require_password'); + }); + } +}; diff --git a/backend/src/database/db.js b/backend/src/database/db.js index b7752ac..ab2e137 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -82,6 +82,7 @@ async function initializeDatabase() { table.boolean('watermark_downloads').defaultTo(false); table.text('watermark_text'); table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); + table.boolean('require_password').defaultTo(true); }); } else { // Check if color_theme needs to be updated to TEXT type @@ -116,7 +117,8 @@ async function initializeDatabase() { disable_right_click BOOLEAN DEFAULT 0, watermark_downloads BOOLEAN DEFAULT 0, watermark_text TEXT, - hero_photo_id INTEGER + hero_photo_id INTEGER, + require_password BOOLEAN DEFAULT 1 ) `); @@ -138,6 +140,8 @@ async function initializeDatabase() { return 'watermark_text'; case 'hero_photo_id': return 'hero_photo_id'; + case 'require_password': + return 'COALESCE(require_password, 1) as require_password'; default: return col; } diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 0e25f8f..c809d7a 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken'); const { db, withRetry } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); +const logger = require('../utils/logger'); // Middleware to verify gallery access async function verifyGalleryAccess(req, res, next) { try { const requestedSlug = req.params.slug || req.requestedSlug; const token = getGalleryTokenFromRequest(req, requestedSlug); + let event; + if (!token) { + if (!requestedSlug) { + return res.status(401).json({ error: 'No token provided' }); + } + + event = await withRetry(async () => { + return await db('events') + .where({ + slug: requestedSlug, + is_active: formatBoolean(true), + is_archived: formatBoolean(false) + }) + .select('*') + .first(); + }); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + if (!requiresPassword) { + req.event = event; + req.sessionID = `gallery_public_${event.id}_${Date.now()}`; + req.clientInfo = { + ip: req.ip || req.connection.remoteAddress || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), + timestamp: Date.now() + }; + return next(); + } + return res.status(401).json({ error: 'No token provided' }); } @@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) { throw error; } } - console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId); + logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug }); // If we have a slug in the URL params or from pre-middleware, verify it matches - let event; if (requestedSlug) { // Verify by slug and ensure it matches the token's event event = await withRetry(async () => { @@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) { } if (!event) { - console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId); + logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId }); return res.status(404).json({ error: 'Gallery not found or expired' }); } - console.log('[verifyGalleryAccess] Event found:', event.id, event.slug); + logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug }); req.event = event; req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; @@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) { timestamp: Date.now() }; - console.log('[verifyGalleryAccess] Access granted for event:', event.id); + logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug }); next(); } catch (error) { - console.error('Error verifying gallery access:', error); + logger.error('Error verifying gallery access', { error: error.message, stack: error.stack }); res.status(401).json({ error: 'Invalid token' }); } } diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 2e3546f..6f832aa 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); +const logger = require('../utils/logger'); async function photoAuth(req, res, next) { try { // Extract event slug from the path let eventSlug; - console.log('PhotoAuth middleware - path:', req.path); - // For thumbnails, we need to parse the filename to get the event info if (req.path.startsWith('/thumb_')) { // For now, we'll rely on JWT token for thumbnail access @@ -80,29 +79,36 @@ async function photoAuth(req, res, next) { // For both thumbnails and photos with admin token, allow access return next(); } - } catch (err) { - // Token invalid, fall through to password check - console.error('JWT verification failed:', err.message); + } catch (err) { + // Token invalid, fall through to password check + logger.warn('JWT verification failed in photoAuth', { error: err.message }); } } // Check for password header (legacy support) const password = req.headers['x-gallery-password']; - if (!password && !tokenFromRequest) { - return res.status(401).json({ error: 'Authentication required' }); - } - // If no eventSlug (thumbnails), and we don't have valid auth yet, deny access - if (!eventSlug && !password) { + if (!eventSlug && !password && !tokenFromRequest) { return res.status(401).json({ error: 'Authentication required for thumbnails' }); } - + const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } + + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + if (!requiresPassword) { + req.event = event; + return next(); + } + if (!password && !tokenFromRequest) { + return res.status(401).json({ error: 'Authentication required' }); + } + if (password) { const validPassword = await bcrypt.compare(password, event.password_hash); if (!validPassword) { @@ -122,7 +128,7 @@ async function photoAuth(req, res, next) { req.event = event; next(); } catch (error) { - console.error('Photo auth error:', error); + logger.error('Photo auth error', { error: error.message, stack: error.stack }); res.status(500).json({ error: 'Authentication error' }); } } diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index a8b9e52..cde9ecf 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor'); 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 parseBooleanInput = (value, defaultValue = true) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['false', '0', 'no', 'off'].includes(normalized)) { + return false; + } + if (['true', '1', 'yes', 'on'].includes(normalized)) { + return true; + } + } + return defaultValue; +}; // Create new event router.post('/', adminAuth, [ @@ -21,7 +44,30 @@ router.post('/', adminAuth, [ body('event_date').isDate(), body('host_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(), - body('password').isLength({ min: 6 }), + body('require_password').optional().isBoolean(), + body('password').optional().isString().custom((value, { req }) => { + const input = req.body.require_password; + const normalizeBoolean = (val, defaultValue = true) => { + if (val === undefined || val === null) return defaultValue; + if (typeof val === 'boolean') return val; + if (typeof val === 'number') return val !== 0; + if (typeof val === 'string') { + const normalized = val.trim().toLowerCase(); + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + } + return defaultValue; + }; + + const requirePassword = normalizeBoolean(input, true); + if (!requirePassword) { + return true; + } + if (typeof value !== 'string' || value.trim().length < 6) { + throw new Error('Password must be at least 6 characters long'); + } + return true; + }), body('expiration_days').isInt({ min: 1, max: 365 }).optional(), body('welcome_message').optional().trim(), body('color_theme').optional().trim(), @@ -34,7 +80,7 @@ router.post('/', adminAuth, [ body('watermark_text').optional().trim() ], async (req, res) => { try { - console.log('Create event request body:', req.body); + logger.debug('Create event request body', { body: req.body }); const errors = validationResult(req); if (!errors.isEmpty()) { console.error('Validation errors:', errors.array()); @@ -58,6 +104,7 @@ router.post('/', adminAuth, [ disable_right_click = false, watermark_downloads = false, watermark_text = null, + require_password: requirePasswordInput = true, // Feedback settings feedback_enabled = false, allow_ratings = true, @@ -69,12 +116,15 @@ router.post('/', adminAuth, [ show_feedback_to_guests = true } = req.body; + const requirePassword = parseBooleanInput(requirePasswordInput, true); + // Debug logging - console.log('Download control values:', { + logger.debug('Download control values', { allow_downloads, disable_right_click, watermark_downloads, watermark_text, + require_password: requirePassword, types: { allow_downloads: typeof allow_downloads, disable_right_click: typeof disable_right_click, @@ -82,18 +132,24 @@ router.post('/', adminAuth, [ } }); - // Validate password strength - const passwordValidation = await validatePasswordInContext(password, 'gallery', { - eventName: event_name - }); - - if (!passwordValidation.valid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback + let passwordValidation = null; + let galleryPassword = password; + + if (requirePassword) { + passwordValidation = await validatePasswordInContext(password, 'gallery', { + eventName: event_name }); + + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + } else { + galleryPassword = ''; } // Generate unique slug @@ -113,10 +169,14 @@ router.post('/', adminAuth, [ // Generate share link const shareToken = crypto.randomBytes(16).toString('hex'); - const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + const sharePath = `/gallery/${slug}/${shareToken}`; + const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); + const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; - // Hash password with configurable rounds - const password_hash = await bcrypt.hash(password, getBcryptRounds()); + // Hash password with configurable rounds (random placeholder when not required) + const password_hash = requirePassword + ? await bcrypt.hash(password, getBcryptRounds()) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); // Calculate expiration date (days after event date) // Parse YYYY-MM-DD format as local date to avoid timezone issues @@ -155,7 +215,8 @@ router.post('/', adminAuth, [ allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), - watermark_text + watermark_text, + require_password: formatBoolean(requirePassword) }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -180,7 +241,7 @@ router.post('/', adminAuth, [ // Log activity await logActivity('event_created', - { event_type, expires_at }, + { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score }, eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); @@ -197,7 +258,7 @@ router.post('/', adminAuth, [ event_name, event_date: event_date, // Pass raw date - will be formatted by email processor gallery_link: shareLink, - gallery_password: password, + 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 || '' }), @@ -211,6 +272,7 @@ router.post('/', adminAuth, [ slug, event_name, event_type, + require_password: requirePassword, share_link: shareLink, expires_at: expires_at.toISOString(), created_at: new Date().toISOString() @@ -398,19 +460,45 @@ router.put('/:id', adminAuth, [ body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), body('source_mode').optional().isIn(['managed', 'reference']), - body('external_path').optional({ nullable: true }).isString().trim() + body('external_path').optional({ nullable: true }).isString().trim(), + body('require_password').optional().isBoolean(), + body('password').optional().isString().custom((value, { req }) => { + if (value === undefined || value === null || value === '') { + return true; + } + if (typeof value !== 'string' || value.trim().length < 6) { + throw new Error('Password must be at least 6 characters long'); + } + return true; + }) ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { - console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2)); - console.log('Request body:', req.body); + logger.debug('Update event validation errors', { errors: errors.array(), body: req.body }); return res.status(400).json({ errors: errors.array() }); } const { id } = req.params; const updates = { ...req.body }; + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); + let requirePasswordUpdate; + if (hasRequirePasswordUpdate) { + requirePasswordUpdate = parseBooleanInput(updates.require_password, true); + updates.require_password = formatBoolean(requirePasswordUpdate); + } + + let newPasswordPlain; + if (Object.prototype.hasOwnProperty.call(updates, 'password')) { + if (updates.password === undefined || updates.password === null || updates.password === '') { + delete updates.password; + } else { + newPasswordPlain = updates.password; + delete updates.password; + } + } + if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) { updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; } @@ -429,7 +517,7 @@ router.put('/:id', adminAuth, [ } // Log the update request for debugging - console.log('Update event request:', { + logger.debug('Update event request', { id, updates, color_theme_length: updates.color_theme ? updates.color_theme.length : 0, @@ -444,6 +532,18 @@ router.put('/:id', adminAuth, [ return res.status(404).json({ error: 'Event not found' }); } + const currentRequirePassword = parseBooleanInput(event.require_password, true); + + if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) { + return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' }); + } + + if (newPasswordPlain) { + updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds()); + } else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) { + updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + } + // Update event await db('events') .where('id', id) diff --git a/backend/src/routes/auth-enhanced-v2.js b/backend/src/routes/auth-enhanced-v2.js index 2f8eba7..4bbf55c 100644 --- a/backend/src/routes/auth-enhanced-v2.js +++ b/backend/src/routes/auth-enhanced-v2.js @@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => { // Gallery password verification with enhanced security router.post('/gallery/verify', [ body('slug').notEmpty().trim(), - body('password').notEmpty() + body('password').optional().isString() ], async (req, res) => { try { const errors = validationResult(req); @@ -232,53 +232,69 @@ router.post('/gallery/verify', [ const ipAddress = req.ip || req.connection.remoteAddress; const userAgent = req.headers['user-agent'] || ''; - // Check gallery-specific lockout - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); - if (lockoutStatus.isLocked) { - logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); - return res.status(423).json({ - error: 'Too many failed attempts. Please try again later.', - retryAfter: lockoutStatus.remainingTime - }); - } - - // Verify reCAPTCHA - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - 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}`); + if (lockoutStatus.isLocked) { + logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); + return res.status(423).json({ + error: 'Too many failed attempts. Please try again later.', + retryAfter: lockoutStatus.remainingTime + }); + } + + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + } + if (!event) { // Don't reveal if gallery exists await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); return res.status(401).json({ error: 'Invalid gallery or password' }); } - - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + + if (requiresPassword) { + if (!password) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ event_id: event.id, ip_address: ipAddress, user_agent: userAgent, - action: 'login_fail' + action: 'login_success' + }); + } else { + logger.info('Public gallery access granted without password', { slug, ipAddress }); + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' }); - return res.status(401).json({ error: 'Invalid gallery or password' }); } - // Successful access - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - - // Log successful access - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - // Generate session token with additional security info const token = jwt.sign({ eventId: event.id, @@ -302,7 +318,8 @@ router.post('/gallery/verify', [ color_theme: event.color_theme, expires_at: event.expires_at, allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id + upload_category_id: event.upload_category_id, + require_password: requiresPassword } }); } catch (error) { @@ -372,4 +389,4 @@ router.post('/password-strength', [ } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/auth-enhanced.js b/backend/src/routes/auth-enhanced.js index dced5fc..f5bbc03 100644 --- a/backend/src/routes/auth-enhanced.js +++ b/backend/src/routes/auth-enhanced.js @@ -162,7 +162,7 @@ router.post('/logout', async (req, res) => { // Gallery password verification with enhanced security router.post('/gallery/verify', [ body('slug').notEmpty().trim(), - body('password').notEmpty() + body('password').optional().isString() ], async (req, res) => { try { const errors = validationResult(req); @@ -173,55 +173,68 @@ router.post('/gallery/verify', [ const { slug, password, recaptchaToken } = req.body; const ipAddress = req.ip || req.connection.remoteAddress; const userAgent = req.headers['user-agent'] || ''; - - // Check gallery-specific lockout - const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); - if (lockoutStatus.isLocked) { - logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); - return res.status(423).json({ - error: 'Too many failed attempts. Please try again later.', - retryAfter: lockoutStatus.remainingTime - }); - } - - // Verify reCAPTCHA - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } - - const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .first(); + if (!event) { - // Don't reveal if gallery exists await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); return res.status(401).json({ error: 'Invalid gallery or password' }); } - - const validPassword = await bcrypt.compare(password, event.password_hash); - if (!validPassword) { - await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + if (requiresPassword) { + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); + if (lockoutStatus.isLocked) { + logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); + return res.status(423).json({ + error: 'Too many failed attempts. Please try again later.', + retryAfter: lockoutStatus.remainingTime + }); + } + + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + if (!password) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); await db('access_logs').insert({ event_id: event.id, ip_address: ipAddress, user_agent: userAgent, - action: 'login_fail' + action: 'login_success' + }); + } else { + logger.info('Public gallery access granted without password', { slug, ipAddress }); + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' }); - return res.status(401).json({ error: 'Invalid gallery or password' }); } - - // Successful access - await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); - - // Log successful access - await db('access_logs').insert({ - event_id: event.id, - ip_address: ipAddress, - user_agent: userAgent, - action: 'login_success' - }); - - // Generate session token with additional security info + const token = jwt.sign({ eventId: event.id, eventSlug: event.slug, @@ -246,7 +259,8 @@ router.post('/gallery/verify', [ color_theme: event.color_theme, expires_at: event.expires_at, allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id + upload_category_id: event.upload_category_id, + require_password: requiresPassword } }); } catch (error) { @@ -301,6 +315,8 @@ router.post('/gallery/share-login', [ await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); setGalleryAuthCookies(res, jwtToken, event.slug); + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + res.json({ token: jwtToken, event: { @@ -312,7 +328,8 @@ router.post('/gallery/share-login', [ color_theme: event.color_theme, expires_at: event.expires_at, allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id + upload_category_id: event.upload_category_id, + require_password: requiresPassword } }); } catch (error) { diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index b7ae2cb..b525148 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -4,11 +4,34 @@ const bcrypt = require('bcrypt'); const crypto = require('crypto'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { adminAuth } = require('../middleware/auth-enhanced-v2'); const fs = require('fs').promises; const path = require('path'); const router = express.Router(); +const parseBooleanInput = (value, defaultValue = true) => { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['false', '0', 'no', 'off'].includes(normalized)) { + return false; + } + if (['true', '1', 'yes', 'on'].includes(normalized)) { + return true; + } + } + return defaultValue; +}; + // Create new event router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), @@ -16,7 +39,17 @@ router.post('/', adminAuth, [ body('event_date').isDate(), body('host_email').isEmail(), body('admin_email').isEmail(), - body('password').isLength({ min: 6 }), + body('require_password').optional().isBoolean(), + body('password').optional().isString().custom((value, { req }) => { + const requirePassword = parseBooleanInput(req.body.require_password, true); + if (!requirePassword) { + return true; + } + if (typeof value !== 'string' || value.trim().length < 6) { + throw new Error('Password must be at least 6 characters long'); + } + return true; + }), body('expiration_days').isInt({ min: 1, max: 365 }).optional() ], async (req, res) => { try { @@ -32,10 +65,28 @@ router.post('/', adminAuth, [ host_email, admin_email, password, + require_password: requirePasswordInput = true, welcome_message, color_theme, expiration_days = 30 } = req.body; + + const requirePassword = parseBooleanInput(requirePasswordInput, true); + + if (requirePassword) { + const passwordValidation = await validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + } // Generate unique slug const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; @@ -49,10 +100,15 @@ router.post('/', adminAuth, [ // Generate share link (just slug/token, not full URL) const shareToken = crypto.randomBytes(16).toString('hex'); - const shareLink = `${slug}/${shareToken}`; + const sharePath = `/gallery/${slug}/${shareToken}`; + const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); + const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; + const shareLinkSlug = `${slug}/${shareToken}`; - // Hash password - const password_hash = await bcrypt.hash(password, 10); + // Hash password (or placeholder when not required) + const password_hash = requirePassword + ? await bcrypt.hash(password, getBcryptRounds()) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); // Calculate expiration date (days after event date) const expires_at = new Date(event_date); @@ -75,8 +131,9 @@ router.post('/', adminAuth, [ password_hash, welcome_message, color_theme, - share_link: shareLink, - expires_at + share_link: shareLinkSlug, + expires_at, + require_password: formatBoolean(requirePassword) }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -88,17 +145,18 @@ router.post('/', adminAuth, [ host_name: host_email.split('@')[0], // Extract name from email event_name, event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareLink, - gallery_password: password, + gallery_link: fullShareLink, + 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 || '' }); - + res.json({ id: eventId, slug, - share_link: shareLink, - expires_at + share_link: fullShareLink, + expires_at, + require_password: requirePassword }); } catch (error) { console.error(error); @@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => { router.put('/:id', adminAuth, async (req, res) => { try { const { id } = req.params; - const updates = req.body; + const updates = { ...req.body }; // Don't allow updating certain fields delete updates.id; delete updates.slug; delete updates.created_at; - - // If updating password, hash it - if (updates.password) { - updates.password_hash = await bcrypt.hash(updates.password, 10); - delete updates.password; + delete updates.password_confirmation; + + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); + let requirePasswordUpdate; + if (hasRequirePasswordUpdate) { + requirePasswordUpdate = parseBooleanInput(updates.require_password, true); + updates.require_password = formatBoolean(requirePasswordUpdate); + } + + let newPasswordPlain; + if (Object.prototype.hasOwnProperty.call(updates, 'password')) { + if (updates.password === undefined || updates.password === null || updates.password === '') { + delete updates.password; + } else { + newPasswordPlain = updates.password; + delete updates.password; + } + } + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const currentRequirePassword = parseBooleanInput(event.require_password, true); + + if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) { + return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' }); + } + + if (newPasswordPlain) { + updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds()); + } else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) { + updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); } await db('events').where('id', id).update(updates); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 6c3af3c..3ca4bac 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -20,7 +20,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => { const { slug, token } = req.params; const event = await db('events') - .where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .select('id', 'share_link') .first(); @@ -50,7 +50,7 @@ router.get('/:slug/info', async (req, res) => { const event = await db('events') .where({ slug: slug }) .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', - 'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text') + 'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text', 'require_password', 'color_theme') .first(); if (!event) { @@ -74,6 +74,8 @@ router.get('/:slug/info', async (req, res) => { } } + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + res.json({ event_name: event.event_name, event_type: event.event_type, @@ -81,11 +83,11 @@ router.get('/:slug/info', async (req, res) => { expires_at: event.expires_at, is_active: event.is_active, is_expired: !event.is_active || new Date(event.expires_at) < new Date(), - requires_password: true, + requires_password: requiresPassword, color_theme: event.color_theme, - allow_downloads: event.allow_downloads !== false, - disable_right_click: event.disable_right_click === true, - watermark_downloads: event.watermark_downloads === true, + allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'), + disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1', + watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', watermark_text: event.watermark_text }); } catch (error) { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index ba53c8f..0942637 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') { ? '(Aus Sicherheitsgründen nicht angezeigt)' : '(Not shown for security reasons)'; } + + if (processedVariables.gallery_password === 'No password required') { + processedVariables.gallery_password = language === 'de' + ? 'Kein Passwort erforderlich' + : 'No password required'; + } // Format dates if they exist if (processedVariables.event_date) { @@ -546,4 +552,4 @@ module.exports = { queueEmail, stopEmailQueueProcessor, testEmailConnection -}; \ No newline at end of file +}; diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index b23f940..238570e 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { api } from '../config/api'; import { authService, galleryService } from '../services'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; +import { normalizeRequirePassword } from '../utils/accessControl'; import { clearActiveGallerySlug, clearGalleryToken, @@ -18,12 +19,24 @@ interface GalleryEvent { welcome_message?: string; color_theme?: string; expires_at: string; + require_password?: boolean; } +const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => { + if (!incoming) { + return null; + } + + return { + ...incoming, + require_password: normalizeRequirePassword(incoming.require_password, true), + }; +}; + interface GalleryAuthContextType { isAuthenticated: boolean; event: GalleryEvent | null; - login: (slug: string, password: string, recaptchaToken?: string | null) => Promise; + login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise; logout: () => void; isLoading: boolean; error: string | null; @@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC = ({ childr try { const parsed = JSON.parse(storedEvent); if (parsed && parsed.id) { - setEvent(parsed); + const normalizedStored = normalizeEvent(parsed); + setEvent(normalizedStored); + if (normalizedStored) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored)); + } } } catch (err) { sessionStorage.removeItem(`gallery_event_${currentSlug}`); @@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC = ({ childr // Fetch gallery details to hydrate context const galleryData = await galleryService.getGalleryPhotos(currentSlug); if (galleryData?.event) { - setEvent(galleryData.event); - sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event)); + const normalizedEvent = normalizeEvent(galleryData.event); + setEvent(normalizedEvent); + if (normalizedEvent) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); + } } } @@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC = ({ childr if (verify?.valid) { const response = await authService.shareLinkLogin(currentSlug, urlToken); if (response?.event) { - setEvent(response.event); + const normalizedEvent = normalizeEvent(response.event); + setEvent(normalizedEvent); setIsAuthenticated(true); - sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); + if (normalizedEvent) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); + } if (response.token) { storeGalleryToken(currentSlug, response.token); } @@ -154,12 +177,13 @@ export const GalleryAuthProvider: React.FC = ({ childr }; }, []); - const login = async (slug: string, password: string, recaptchaToken?: string | null) => { + const login = async (slug: string, password?: string, recaptchaToken?: string | null) => { try { setError(null); setIsLoading(true); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); - setEvent(response.event); + const normalizedEvent = normalizeEvent(response.event); + setEvent(normalizedEvent); setIsAuthenticated(true); if (response.token) { storeGalleryToken(slug, response.token); @@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC = ({ childr setActiveGallerySlug(slug); // Store event data for quick reloads (non-sensitive) - sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); + if (normalizedEvent) { + sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent)); + } } catch (err: any) { setError(err.response?.data?.error || 'Invalid password'); throw err; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 250c7af..4e2b9e6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -496,6 +496,8 @@ "expiresIn": "Galerie läuft in {{count}} Tag ab", "expiresIn_plural": "Galerie läuft in {{count}} Tagen ab", "downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.", + "publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich", + "publicGallerySubtitle": "Fotos werden geladen...", "viewGallery": "Galerie anzeigen", "downloadAll": "Alle herunterladen", "downloading": "Lade {{count}} Foto herunter...", @@ -607,6 +609,7 @@ "created": "Erstellt", "expires": "Läuft ab", "shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.", + "shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.", "resetGalleryPassword": "Galerie-Passwort zurücksetzen", "resendCreationEmail": "Erstellungs-E-Mail erneut senden", "creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt", @@ -632,10 +635,14 @@ "adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen", "securityAccess": "Sicherheit & Zugriff", "galleryPassword": "Galerie-Passwort", + "requirePasswordToggle": "Galerie mit Passwort schützen", + "requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.", + "publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.", "passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden", "passwordPlaceholder": "Sicheres Passwort eingeben", "confirmPassword": "Passwort bestätigen", "showPasswords": "Passwörter anzeigen", + "newPasswordLabel": "Neues Galerie-Passwort", "gallerySettings": "Galerie-Einstellungen", "colorTheme": "Farbthema", "galleryExpiration": "Galerie-Ablauf", @@ -735,6 +742,9 @@ "noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.", "eventsSelected": "{{count}} Veranstaltung ausgewählt", "eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt", + "publicAccess": "Öffentlicher Zugriff", + "passwordProtected": "Passwortgeschützt", + "newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.", "viewDetails": "Details anzeigen", "archiveEventAction": "Veranstaltung archivieren", "downloadArchiveAction": "Archiv herunterladen", @@ -853,7 +863,6 @@ "security": { "title": "Sicherheit", "passwordSettings": "Passworteinstellungen", - "requirePassword": "Passwort für alle Galerien erforderlich", "minPasswordLength": "Minimale Passwortlänge", "minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter", "passwordComplexity": "Passwort-Komplexität", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0569c20..0b75324 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -161,6 +161,8 @@ "expiresIn": "Gallery expires in {{count}} day", "expiresIn_plural": "Gallery expires in {{count}} days", "downloadBefore": "Download your photos before they're no longer available.", + "publicGalleryTitle": "This gallery is publicly accessible", + "publicGallerySubtitle": "Loading the photos now...", "viewGallery": "View Gallery", "downloadAll": "Download All", "downloading": "Downloading {{count}} photo...", @@ -290,6 +292,7 @@ "created": "Created", "expires": "Expires", "shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.", + "shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.", "resetGalleryPassword": "Reset Gallery Password", "resendCreationEmail": "Resend Creation Email", "creationEmailResent": "Creation email has been queued for sending", @@ -316,9 +319,13 @@ "adminEmailHelp": "Will receive system notifications and archive confirmations", "securityAccess": "Security & Access", "galleryPassword": "Gallery Password", + "requirePasswordToggle": "Require password for this gallery", + "requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.", + "publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.", "passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters", "confirmPassword": "Confirm Password", "showPasswords": "Show passwords", + "newPasswordLabel": "New Gallery Password", "gallerySettings": "Gallery Settings", "themeAndStyle": "Theme & Style", "colorTheme": "Color Theme", @@ -373,6 +380,9 @@ "eventsSelected_plural": "{{count}} events selected", "clear": "Clear", "archiveSelected": "Archive Selected", + "publicAccess": "Public access", + "passwordProtected": "Password protected", + "newPasswordRequired": "Please set a password before enabling protection.", "event": "Event", "type": "Type", "date": "Date", @@ -533,7 +543,6 @@ "security": { "title": "Security", "passwordSettings": "Password Settings", - "requirePassword": "Require password for all galleries", "minPasswordLength": "Minimum Password Length", "minPasswordLengthHelp": "Minimum number of characters for gallery passwords", "passwordComplexity": "Password Complexity", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index dbc7497..36277df 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service'; import { api } from '../config/api'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { buildResourceUrl } from '../utils/url'; +import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; export const GalleryPage: React.FC = () => { const { slug, token } = useParams<{ slug: string; token?: string }>(); @@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => { const [isLoggingIn, setIsLoggingIn] = useState(false); const [loginError, setLoginError] = useState(null); const [recaptchaToken, setRecaptchaToken] = useState(null); + const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); // Fetch gallery info (public data) const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); + const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true); // Fetch branding settings const { data: settingsData } = useQuery({ @@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => { } }, [galleryInfo, settingsData, isAuthenticated, setTheme]); + React.useEffect(() => { + if (!slug) { + return; + } + + if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) { + setAutoLoginAttempted(true); + setIsLoggingIn(true); + login(slug, '') + .then(() => { + setLoginError(null); + }) + .catch((error: any) => { + const message = error?.response?.data?.error; + if (message) { + setLoginError(message); + } + }) + .finally(() => { + setIsLoggingIn(false); + }); + } + }, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]); + // Calculate days until expiration const daysUntilExpiration = galleryInfo ? differenceInDays(parseISO(galleryInfo.expires_at), new Date()) @@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => { e.preventDefault(); e.stopPropagation(); // Prevent any bubbling - if (!password.trim()) { + if (requiresPassword && !password.trim()) { setLoginError(t('auth.pleaseEnterPassword')); return; } @@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => { try { setIsLoggingIn(true); setLoginError(null); - await login(slug!, password, recaptchaToken); + await login(slug!, requiresPassword ? password : '', recaptchaToken); - // Track successful password entry - analyticsService.trackGalleryEvent('password_entry', { - gallery: slug, - success: true - }); + if (requiresPassword) { + analyticsService.trackGalleryEvent('password_entry', { + gallery: slug, + success: true + }); + } } catch (error: any) { console.error('Login error:', error); const errorMessage = error.response?.data?.error || 'Invalid password'; @@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => { } // Track failed password entry - analyticsService.trackGalleryEvent('password_entry', { - gallery: slug, - success: false, - statusCode - }); + if (requiresPassword) { + analyticsService.trackGalleryEvent('password_entry', { + gallery: slug, + success: false, + statusCode + }); + } // Keep the password field to allow retry // Do not clear the password @@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => { )} - {/* Login Card */} -

{t('auth.enterPassword')}

- -
- setPassword(e.target.value)} - error={loginError || undefined} - autoFocus - className="text-sm sm:text-base" - /> - - setRecaptchaToken(null)} - /> - - - + {requiresPassword ? ( + <> +

{t('auth.enterPassword')}

+ +
+ setPassword(e.target.value)} + error={loginError || undefined} + autoFocus + className="text-sm sm:text-base" + /> + + setRecaptchaToken(null)} + /> + + + -

- {t('auth.passwordHint')} -

+

+ {t('auth.passwordHint')} +

+ + ) : ( +
+

+ {t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} +

+

+ {t('gallery.publicGallerySubtitle', 'Loading the photos now...')} +

+
+ +
+ {loginError && ( +

{loginError}

+ )} +
+ )}
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index effaf83..8161d54 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -27,6 +27,7 @@ interface FormData { event_date: string; host_email: string; admin_email: string; + require_password: boolean; password: string; confirm_password: string; welcome_message: string; @@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => { event_date: format(new Date(), 'yyyy-MM-dd'), host_email: '', admin_email: '', + require_password: true, password: '', confirm_password: '', welcome_message: '', @@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => { newErrors.admin_email = t('validation.invalidEmailFormat'); } - if (!formData.password) { - newErrors.password = t('validation.passwordRequired'); - } else if (formData.password.length < 6) { - newErrors.password = t('validation.passwordMinLength'); - } else if (/^\d{1,6}$/.test(formData.password)) { - // Prevent simple numeric passwords like "123456" - newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); - } + if (formData.require_password) { + if (!formData.password) { + newErrors.password = t('validation.passwordRequired'); + } else if (formData.password.length < 6) { + newErrors.password = t('validation.passwordMinLength'); + } else if (/^\d{1,6}$/.test(formData.password)) { + newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); + } - if (formData.password !== formData.confirm_password) { - newErrors.confirm_password = t('validation.passwordsDoNotMatch'); + if (formData.password !== formData.confirm_password) { + newErrors.confirm_password = t('validation.passwordsDoNotMatch'); + } } if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { @@ -244,7 +247,8 @@ export const CreateEventPage: React.FC = () => { event_date: formData.event_date, host_email: formData.host_email, admin_email: formData.admin_email, - password: formData.password, + require_password: formData.require_password, + password: formData.require_password ? formData.password : '', welcome_message: formData.welcome_message || '', color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined, expiration_days: formData.expires_in_days, @@ -426,81 +430,109 @@ export const CreateEventPage: React.FC = () => {

{t('events.securityAndAccess')}

-
- {/* Password */} -
- -
- } - className="pr-10" - /> - +
+
+ - {/* Confirm Password */} -
- -
- } - className="pr-10" - /> - + {!formData.require_password && ( +
+ {t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
-
+ )} + + {formData.require_password && ( +
+
+ +
+ } + className="pr-10" + /> + +
+ +
+ +
+
+ +
+ +
+ } + className="pr-10" + /> + +
+
+
+ )}
@@ -645,4 +677,4 @@ export const CreateEventPage: React.FC = () => { ); }; -CreateEventPage.displayName = 'CreateEventPage'; \ No newline at end of file +CreateEventPage.displayName = 'CreateEventPage'; diff --git a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx index 03a124c..feaf931 100644 --- a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx +++ b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx @@ -30,6 +30,7 @@ interface FormData { host_name: string; host_email: string; admin_email: string; + require_password: boolean; password: string; confirm_password: string; welcome_message: string; @@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => { host_name: '', host_email: '', admin_email: '', + require_password: true, password: '', confirm_password: '', welcome_message: '', @@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => { newErrors.admin_email = t('validation.invalidEmailFormat'); } - if (!formData.password) { - newErrors.password = t('validation.passwordRequired'); - } else if (formData.password.length < 6) { - newErrors.password = t('validation.passwordMinLength'); - } else if (/^\d{1,6}$/.test(formData.password)) { - // Prevent simple numeric passwords like "123456" - newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); - } + if (formData.require_password) { + if (!formData.password) { + newErrors.password = t('validation.passwordRequired'); + } else if (formData.password.length < 6) { + newErrors.password = t('validation.passwordMinLength'); + } else if (/^\d{1,6}$/.test(formData.password)) { + // Prevent simple numeric passwords like "123456" + newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); + } - if (formData.password !== formData.confirm_password) { - newErrors.confirm_password = t('validation.passwordsDoNotMatch'); + if (formData.password !== formData.confirm_password) { + newErrors.confirm_password = t('validation.passwordsDoNotMatch'); + } } if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { @@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => { host_name: formData.host_name, host_email: formData.host_email, admin_email: formData.admin_email, - password: formData.password, + require_password: formData.require_password, + password: formData.require_password ? formData.password : '', welcome_message: formData.welcome_message || '', color_theme: JSON.stringify(formData.theme_config), expiration_days: formData.expires_in_days, @@ -495,51 +500,87 @@ export const CreateEventPageEnhanced: React.FC = () => { />
-
-
+
+ + + {!formData.require_password && ( +
+ {t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')} +
+ )} +
+ + {formData.require_password && ( +
+
+ } + rightIcon={ + + } + /> + + {/* Password Generator */} +
+ +
+
+ } - rightIcon={ - - } /> - - {/* Password Generator */} -
- -
- - } - /> -
+ )}