diff --git a/backend/package-lock.json b/backend/package-lock.json index 7b1d5b5..cbf1dd2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,9 +13,10 @@ "@aws-sdk/s3-request-presigner": "^3.850.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", - "axios": "^1.10.0", + "axios": "^1.12.2", "bcrypt": "6.0.0", "chokidar": "4.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", @@ -3889,13 +3890,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -4727,6 +4728,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/backend/package.json b/backend/package.json index 24b90da..99771b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,9 +17,10 @@ "@aws-sdk/s3-request-presigner": "^3.850.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", - "axios": "^1.10.0", + "axios": "^1.12.2", "bcrypt": "6.0.0", "chokidar": "4.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", diff --git a/backend/server.js b/backend/server.js index e1091a5..9ceac66 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,11 @@ const { startScheduledBackups } = require('./src/services/databaseBackup'); const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); +const cookieParser = require('cookie-parser'); +const { + getAdminTokenFromRequest, + getGalleryTokenFromRequest, +} = require('./src/utils/tokenUtils'); // Import routes const authRoutes = require('./src/routes/auth-enhanced'); @@ -47,14 +52,18 @@ app.set('trust proxy', 'loopback, linklocal, uniquelocal'); const enableHsts = process.env.ENABLE_HSTS === 'true'; const cspDirectives = { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React + scriptSrc: [ + "'self'", + 'https://www.google.com', + 'https://www.gstatic.com' + ], styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images - connectSrc: ["'self'"], // API connections + connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections fontSrc: ["'self'", "https:", "data:"], // Web fonts objectSrc: ["'none'"], // Disable plugins mediaSrc: ["'self'"], // Audio/video - frameSrc: ["'none'"], // Disable iframes + frameSrc: ["'self'", 'https://www.google.com'], }; // Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment) if (enableHsts) { @@ -62,6 +71,25 @@ if (enableHsts) { cspDirectives.upgradeInsecureRequests = []; } +app.use(cookieParser()); + +app.use((req, res, next) => { + if (!req.headers.authorization) { + const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/); + const slug = slugMatch ? slugMatch[1] : req.requestedSlug; + const galleryToken = getGalleryTokenFromRequest(req, slug); + const adminToken = getAdminTokenFromRequest(req); + + if (galleryToken) { + req.headers.authorization = `Bearer ${galleryToken}`; + } else if (adminToken) { + req.headers.authorization = `Bearer ${adminToken}`; + } + } + + next(); +}); + app.use(helmet({ contentSecurityPolicy: { // Avoid helmet adding defaults like upgrade-insecure-requests when not desired diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 67d3612..b7752ac 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -195,11 +195,24 @@ async function initializeDatabase() { table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete' table.json('email_data'); table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed' + table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('scheduled_at').defaultTo(db.fn.now()); table.datetime('sent_at'); table.text('error_message'); table.integer('retry_count').defaultTo(0); }); + } else { + const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at'); + if (!hasCreatedAt) { + await db.schema.alterTable('email_queue', (table) => { + table.datetime('created_at').defaultTo(db.fn.now()); + }); + try { + await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() }); + } catch (updateError) { + logger.debug('Email queue created_at backfill skipped', { error: updateError.message }); + } + } } // Admin users table @@ -217,6 +230,7 @@ async function initializeDatabase() { table.datetime('updated_at').defaultTo(db.fn.now()); table.datetime('last_login'); table.string('last_login_ip'); + table.string('language', 2).defaultTo('en'); }); } else { // Check if updated_at column exists @@ -252,6 +266,13 @@ async function initializeDatabase() { table.string('last_login_ip'); }); } + + const hasLanguage = await db.schema.hasColumn('admin_users', 'language'); + if (!hasLanguage) { + await db.schema.table('admin_users', (table) => { + table.string('language', 2).defaultTo('en'); + }); + } } // Token revocation tables @@ -327,6 +348,18 @@ async function initializeDatabase() { table.datetime('updated_at').defaultTo(db.fn.now()); }); } + + const defaultLanguageSetting = await db('app_settings') + .where('setting_key', 'default_language') + .first(); + if (!defaultLanguageSetting) { + await db('app_settings').insert({ + setting_key: 'default_language', + setting_value: JSON.stringify('en'), + setting_type: 'general', + updated_at: new Date(), + }); + } // Activity logs table const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); @@ -351,6 +384,86 @@ async function initializeDatabase() { }); } } + + await ensureGlobalCategories(); +} + +// Ensure photo categories exist for new deployments +async function ensureGlobalCategories() { + const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories'); + if (!hasPhotoCategoriesTable) { + await db.schema.createTable('photo_categories', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + table.string('slug', 100).notNullable(); + table.boolean('is_global').defaultTo(true); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(db.fn.now()); + table.unique(['slug', 'event_id']); + }); + } + + const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id'); + if (!hasCategoryIdColumn) { + await db.schema.alterTable('photos', (table) => { + table.integer('category_id').references('id').inTable('photo_categories'); + }); + } + + const hasCmsPagesTable = await db.schema.hasTable('cms_pages'); + if (!hasCmsPagesTable) { + await db.schema.createTable('cms_pages', (table) => { + table.increments('id').primary(); + table.string('slug', 100).unique().notNullable(); + table.text('title_en'); + table.text('title_de'); + table.text('content_en'); + table.text('content_de'); + table.timestamp('updated_at').defaultTo(db.fn.now()); + }); + } + + const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first(); + const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0; + if (categoryCount === 0) { + const defaultCategories = [ + { name: 'Ceremony', slug: 'ceremony', is_global: true }, + { name: 'Reception', slug: 'reception', is_global: true }, + { name: 'Portraits', slug: 'portraits', is_global: true }, + { name: 'Group Photos', slug: 'group-photos', is_global: true }, + { name: 'Details', slug: 'details', is_global: true }, + { name: 'Party', slug: 'party', is_global: true }, + ]; + + await db('photo_categories').insert(defaultCategories); + } + + const cmsPages = await db('cms_pages').select('slug'); + const existingSlugs = cmsPages.map((page) => page.slug); + const defaultPages = [ + { + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: '

Legal Notice

Please edit this content in the admin panel.

', + content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date(), + }, + { + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklärung', + content_en: '

Privacy Policy

Please edit this content in the admin panel.

', + content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date(), + }, + ]; + + for (const page of defaultPages) { + if (!existingSlugs.includes(page.slug)) { + await db('cms_pages').insert(page); + } + } } // Helper function to log activities diff --git a/backend/src/middleware/auth-enhanced-v2.js b/backend/src/middleware/auth-enhanced-v2.js index 28176e0..d78ea47 100644 --- a/backend/src/middleware/auth-enhanced-v2.js +++ b/backend/src/middleware/auth-enhanced-v2.js @@ -3,13 +3,14 @@ const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { isTokenRevoked } = require('../utils/tokenRevocation'); const logger = require('../utils/logger'); +const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); /** * Enhanced admin authentication middleware with revocation checking */ async function adminAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const token = getAdminTokenFromRequest(req); if (!token) { return res.status(401).json({ error: 'No token provided' }); } @@ -97,7 +98,8 @@ async function adminAuth(req, res, next) { */ async function galleryAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const slug = req.params?.slug || req.requestedSlug; + const token = getGalleryTokenFromRequest(req, slug); if (!token) { return res.status(401).json({ error: 'No token provided' }); } @@ -164,4 +166,4 @@ module.exports = { adminAuth, galleryAuth, // ... other exports -}; \ No newline at end of file +}; diff --git a/backend/src/middleware/auth-enhanced.js b/backend/src/middleware/auth-enhanced.js index 29c1000..db94283 100644 --- a/backend/src/middleware/auth-enhanced.js +++ b/backend/src/middleware/auth-enhanced.js @@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); +const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); /** * Enhanced admin authentication middleware @@ -9,7 +10,7 @@ const logger = require('../utils/logger'); */ async function adminAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const token = getAdminTokenFromRequest(req); if (!token) { return res.status(401).json({ error: 'No token provided' }); } @@ -89,7 +90,8 @@ async function adminAuth(req, res, next) { */ async function galleryAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const slug = req.params?.slug || req.requestedSlug; + const token = getGalleryTokenFromRequest(req, slug); if (!token) { return res.status(401).json({ error: 'No token provided' }); } @@ -151,7 +153,8 @@ async function galleryAuth(req, res, next) { */ async function photoAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const slug = req.params?.slug || req.requestedSlug; + const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); if (!token) { return res.status(401).json({ error: 'Authentication required' }); } @@ -235,4 +238,4 @@ module.exports = { galleryAuth, photoAuth, verifyGalleryAccess -}; \ No newline at end of file +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 0270d22..4f855ce 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); +const { getAdminTokenFromRequest } = require('../utils/tokenUtils'); async function adminAuth(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const token = getAdminTokenFromRequest(req); if (!token) { const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.headers['x-real-ip'] || diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index e8d2184..0e25f8f 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -1,17 +1,17 @@ const jwt = require('jsonwebtoken'); const { db, withRetry } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); // Middleware to verify gallery access async function verifyGalleryAccess(req, res, next) { try { - const authHeader = req.headers.authorization; - const token = authHeader?.split(' ')[1]; + const requestedSlug = req.params.slug || req.requestedSlug; + const token = getGalleryTokenFromRequest(req, requestedSlug); if (!token) { return res.status(401).json({ error: 'No token provided' }); } - // Try to verify with issuer first, fallback to no issuer for backward compatibility let decoded; try { @@ -29,8 +29,6 @@ async function verifyGalleryAccess(req, res, next) { console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId); // If we have a slug in the URL params or from pre-middleware, verify it matches - const requestedSlug = req.params.slug || req.requestedSlug; - let event; if (requestedSlug) { // Verify by slug and ensure it matches the token's event @@ -84,10 +82,10 @@ async function verifyGalleryAccess(req, res, next) { next(); } catch (error) { console.error('Error verifying gallery access:', error); - res.status(401).json({ error: 'Invalid token', details: error.message }); + res.status(401).json({ error: 'Invalid token' }); } } module.exports = { verifyGalleryAccess -}; \ No newline at end of file +}; diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 9ba8bc8..2e3546f 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -2,6 +2,7 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); async function photoAuth(req, res, next) { try { @@ -20,9 +21,9 @@ async function photoAuth(req, res, next) { } // First check for JWT token (from gallery access) - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.replace('Bearer ', ''); + const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug); + if (tokenFromRequest) { + const token = tokenFromRequest; try { // Try to verify with issuer first, fallback to no issuer for backward compatibility let decoded; @@ -88,7 +89,7 @@ async function photoAuth(req, res, next) { // Check for password header (legacy support) const password = req.headers['x-gallery-password']; - if (!password && !authHeader) { + if (!password && !tokenFromRequest) { return res.status(401).json({ error: 'Authentication required' }); } diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index def5f03..f8fce55 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); +const { getAdminTokenFromRequest } = require('../utils/tokenUtils'); // In-memory session tracking (in production, use Redis) const sessions = new Map(); @@ -67,11 +68,7 @@ async function getSessionTimeout() { async function sessionTimeoutMiddleware(req, res, next) { // Skip for non-authenticated routes - if (!req.headers.authorization) { - return next(); - } - - const token = req.headers.authorization.split(' ')[1]; + const token = getAdminTokenFromRequest(req); if (!token) { return next(); } @@ -150,4 +147,4 @@ module.exports = { sessionTimeoutMiddleware, endSession, getActiveSessions -}; \ No newline at end of file +}; diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index bef0e69..203c6e6 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -221,7 +221,11 @@ router.post('/test-connection', adminAuth, async (req, res) => { await fs.access(config.path, fs.constants.W_OK); res.json({ success: true, message: 'Local path is writable' }); } catch (error) { - res.json({ success: false, message: 'Cannot write to local path: ' + error.message }); + logger.warn('Local backup path not writable', { + path: config.path, + error: error.message + }); + res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); } break; @@ -243,7 +247,11 @@ router.post('/test-connection', adminAuth, async (req, res) => { const { stdout } = await execAsync(testCommand); res.json({ success: true, message: 'Rsync connection successful' }); } catch (error) { - res.json({ success: false, message: 'Rsync connection failed: ' + error.message }); + logger.warn('Rsync connection test failed', { + destination: config.host || config.destination, + error: error.message + }); + res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); } break; @@ -274,7 +282,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to get backup manifest:', error); - res.status(404).json({ error: error.message || 'Backup manifest not found' }); + res.status(404).json({ error: 'Backup manifest not found' }); } }); @@ -327,7 +335,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => { } } catch (error) { logger.error('Failed to download backup manifest:', error); - res.status(404).json({ error: error.message || 'Backup manifest not found' }); + res.status(404).json({ error: 'Backup manifest not found' }); } }); @@ -344,7 +352,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to get backup manifest:', error); - res.status(404).json({ error: error.message || 'Backup manifest not found' }); + res.status(404).json({ error: 'Backup manifest not found' }); } }); @@ -375,7 +383,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => { } } catch (error) { logger.error('Failed to download backup manifest:', error); - res.status(404).json({ error: error.message || 'Backup manifest not found' }); + res.status(404).json({ error: 'Backup manifest not found' }); } }); @@ -436,7 +444,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to list S3 buckets:', error); - res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message }); + res.status(500).json({ error: 'Failed to list S3 buckets' }); } }); @@ -473,7 +481,7 @@ router.get('/s3/files', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to list S3 files:', error); - res.status(500).json({ error: 'Failed to list S3 files: ' + error.message }); + res.status(500).json({ error: 'Failed to list S3 files' }); } }); @@ -535,7 +543,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to cleanup S3 backups:', error); - res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message }); + res.status(500).json({ error: 'Failed to cleanup S3 backups' }); } }); @@ -587,7 +595,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => { }); } catch (error) { logger.error('S3 upload test failed:', error); - res.status(500).json({ error: 'S3 upload test failed: ' + error.message }); + res.status(500).json({ error: 'S3 upload test failed' }); } }); @@ -675,7 +683,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => { } } catch (error) { logger.error('Failed to download backup:', error); - res.status(500).json({ error: 'Failed to download backup: ' + error.message }); + res.status(500).json({ error: 'Failed to download backup' }); } }); @@ -744,7 +752,7 @@ router.get('/checksums', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to get file checksums:', error); - res.status(500).json({ error: 'Failed to get file checksums: ' + error.message }); + res.status(500).json({ error: 'Failed to get file checksums' }); } }); @@ -847,7 +855,7 @@ router.post('/estimate', adminAuth, async (req, res) => { }); } catch (error) { logger.error('Failed to estimate backup size:', error); - res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message }); + res.status(500).json({ error: 'Failed to estimate backup size' }); } }); @@ -945,12 +953,13 @@ async function validateManifestData(manifestData) { } }; } catch (error) { + logger.error('Manifest validation error', { error: error.message }); return { valid: false, - error: `Validation error: ${error.message}`, - details: { error: error.message } + error: 'Validation error encountered while processing manifest', + details: { hint: 'See server logs for diagnostic details.' } }; } } -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 3826da1..d42e72e 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -515,13 +515,11 @@ router.delete('/:id', adminAuth, async (req, res) => { // Provide more specific error messages if (error.message && error.message.includes('foreign key constraint')) { res.status(500).json({ - error: 'Cannot delete event due to existing references. Please contact support.', - details: error.message + error: 'Cannot delete event due to existing references. Please contact support.' }); } else { res.status(500).json({ - error: 'Failed to delete event', - details: process.env.NODE_ENV === 'development' ? error.message : undefined + error: 'Failed to delete event' }); } } @@ -780,7 +778,7 @@ router.post('/bulk-archive', adminAuth, [ results.failed.push({ id: event.id, name: event.event_name, - error: error.message + error: 'Failed to archive event. Check server logs for details.' }); } } @@ -806,4 +804,4 @@ router.post('/bulk-archive', adminAuth, [ } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index c6c5102..7927aa9 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -4,6 +4,7 @@ const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); const router = express.Router(); @@ -14,7 +15,11 @@ router.get('/list', adminAuth, async (req, res) => { const result = await list(relPath); res.json(result); } catch (error) { - res.status(400).json({ error: 'Invalid path', details: error.message }); + logger.warn('Invalid external media path requested', { + path: req.query.path, + error: error.message + }); + res.status(400).json({ error: 'Invalid external media path' }); } }); @@ -104,9 +109,13 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => { res.json({ imported, skipped, thumbnailsQueued: 0 }); } catch (error) { - res.status(500).json({ error: 'Failed to import external media', details: error.message }); + logger.error('External media import failed', { + eventId: req.params.id, + externalPath: req.body?.external_path, + error: error.message + }); + res.status(500).json({ error: 'Failed to import external media' }); } }); module.exports = router; - diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index 3cccf7d..fb4ceeb 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -368,7 +368,7 @@ router.post('/word-filters', res.json({ success: true }); } catch (error) { if (error.message === 'Word filter already exists') { - return res.status(409).json({ error: error.message }); + return res.status(409).json({ error: 'Word filter already exists' }); } logger.error('Error adding word filter:', error); res.status(500).json({ error: 'Failed to add word filter' }); @@ -430,4 +430,4 @@ function convertToCSV(data) { return [csvHeaders, ...csvRows].join('\n'); } -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index f973168..e015467 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -802,7 +802,8 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => { storagePath: getStoragePath() }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error('Error fetching admin photo debug data:', error); + res.status(500).json({ error: 'Failed to fetch photo debug data' }); } }); diff --git a/backend/src/routes/adminRestore.js b/backend/src/routes/adminRestore.js index 063ff58..b1fe3d6 100644 --- a/backend/src/routes/adminRestore.js +++ b/backend/src/routes/adminRestore.js @@ -82,7 +82,7 @@ router.post('/validate', [ logger.error('Restore validation failed:', error); res.status(400).json({ success: false, - error: error.message, + error: 'Restore validation failed', logs: restoreService.restoreLog }); } @@ -162,7 +162,7 @@ router.post('/start', [ logger.error('Failed to start restore:', error); res.status(500).json({ success: false, - error: error.message + error: 'Failed to start restore operation' }); } }); @@ -458,4 +458,4 @@ async function getBackupConfig() { return config; } -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index c726ddd..3362397 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -5,6 +5,7 @@ const fs = require('fs').promises; const path = require('path'); const os = require('os'); const { formatBoolean } = require('../utils/dbCompat'); +const logger = require('../utils/logger'); const router = express.Router(); // Get system version @@ -208,10 +209,14 @@ router.get('/database', adminAuth, async (req, res) => { }); } catch (error) { // Table might not exist + logger.warn('Failed to retrieve table info', { + table, + error: error.message + }); tableInfo.push({ name: table, rows: 0, - error: error.message + error: 'Unable to retrieve table details' }); } } @@ -226,4 +231,4 @@ router.get('/database', adminAuth, async (req, res) => { } }); -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 dbc282d..dced5fc 100644 --- a/backend/src/routes/auth-enhanced.js +++ b/backend/src/routes/auth-enhanced.js @@ -14,6 +14,14 @@ const { } = require('../utils/authSecurity'); const { endSession } = require('../middleware/sessionTimeout'); const logger = require('../utils/logger'); +const { + setAdminAuthCookie, + clearAdminAuthCookie, + setGalleryAuthCookies, + clearGalleryAuthCookies, + getAdminTokenFromRequest, + getGalleryTokenFromRequest, +} = require('../utils/tokenUtils'); const router = express.Router(); // Admin login with enhanced security @@ -91,6 +99,8 @@ router.post('/admin/login', [ expiresIn: '24h', issuer: 'picpeak-auth' }); + + setAdminAuthCookie(res, token); res.json({ token, @@ -110,13 +120,14 @@ router.post('/admin/login', [ // Logout endpoint router.post('/logout', async (req, res) => { try { - const token = req.headers.authorization?.split(' ')[1]; - + const adminToken = getAdminTokenFromRequest(req); + const galleryToken = getGalleryTokenFromRequest(req); + const token = adminToken || galleryToken; + if (token) { // End the session endSession(token); - - // Log the logout + try { const decoded = jwt.verify(token, process.env.JWT_SECRET); logger.info('User logged out', { @@ -124,11 +135,23 @@ router.post('/logout', async (req, res) => { username: decoded.username, type: decoded.type }); + + if (decoded.type === 'admin') { + clearAdminAuthCookie(res); + } else if (decoded.type === 'gallery') { + clearGalleryAuthCookies(res, decoded.eventSlug); + } } catch (err) { - // Token might be invalid, but still process logout + // Token might be invalid, but still process logout and clear cookies + clearAdminAuthCookie(res); + clearGalleryAuthCookies(res); } + } else { + // No token found, but ensure cookies are cleared + clearAdminAuthCookie(res); + clearGalleryAuthCookies(res); } - + res.json({ message: 'Logged out successfully' }); } catch (error) { logger.error('Logout error:', error); @@ -209,6 +232,8 @@ router.post('/gallery/verify', [ expiresIn: '24h', issuer: 'picpeak-auth' }); + + setGalleryAuthCookies(res, token, event.slug); res.json({ token, @@ -230,15 +255,94 @@ router.post('/gallery/verify', [ } }); +// Share link authentication (token-based) +router.post('/gallery/share-login', [ + body('slug').notEmpty().trim(), + body('token').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug, token } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent'] || ''; + + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + let expectedToken = event.share_link; + if (expectedToken && expectedToken.includes('/')) { + expectedToken = expectedToken.split('/').pop(); + } + + if (!expectedToken || token !== expectedToken) { + return res.status(401).json({ error: 'Invalid or expired share link' }); + } + + const jwtToken = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); + setGalleryAuthCookies(res, jwtToken, event.slug); + + res.json({ + token: jwtToken, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id + } + }); + } catch (error) { + logger.error('Share link authentication error:', error); + res.status(500).json({ error: 'Share link login failed' }); + } +}); + +// Gallery logout to clear cookies +router.post('/gallery/logout', async (req, res) => { + try { + const { slug } = req.body || {}; + clearGalleryAuthCookies(res, slug); + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Gallery logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + // Get current session info router.get('/session', async (req, res) => { try { - const token = req.headers.authorization?.split(' ')[1]; + const { slug } = req.query; + const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); if (!token) { return res.status(401).json({ error: 'No token provided' }); } - + try { const decoded = jwt.verify(token, process.env.JWT_SECRET); @@ -250,7 +354,9 @@ router.get('/session', async (req, res) => { valid: true, type: decoded.type, expiresIn: Math.floor(remainingTime), - user: decoded.username || decoded.eventSlug + user: decoded.username || decoded.eventSlug, + eventSlug: decoded.eventSlug, + adminUsername: decoded.username }); } catch (err) { res.json({ @@ -263,4 +369,4 @@ router.get('/session', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 4bd0947..8aa18d8 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -37,7 +37,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => { res.json({ valid: true }); } catch (error) { console.error('Error verifying token:', error); - res.status(500).json({ error: 'Failed to verify token', details: error.message }); + res.status(500).json({ error: 'Failed to verify token' }); } }); @@ -90,7 +90,7 @@ router.get('/:slug/info', async (req, res) => { }); } catch (error) { console.error('Error fetching gallery info:', error); - res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message }); + res.status(500).json({ error: 'Failed to fetch gallery info' }); } }); @@ -239,7 +239,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { }); } catch (error) { console.error('Error fetching photos:', error); - res.status(500).json({ error: 'Failed to fetch photos', details: error.message }); + res.status(500).json({ error: 'Failed to fetch photos' }); } }); @@ -547,7 +547,7 @@ router.get('/:slug/photo/:photoId', photoId: req.params.photoId, eventId: req.event?.id }); - res.status(500).json({ error: 'Failed to serve photo', details: error.message }); + res.status(500).json({ error: 'Failed to serve photo' }); } } ); diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js index 27e0051..2739207 100644 --- a/backend/src/services/rateLimitService.js +++ b/backend/src/services/rateLimitService.js @@ -2,6 +2,7 @@ const rateLimit = require('express-rate-limit'); const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const logger = require('../utils/logger'); +const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils'); // Cache for rate limit settings let settingsCache = null; @@ -95,12 +96,9 @@ function clearSettingsCache() { */ function isAuthenticated(req) { try { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return false; - } - - const token = authHeader.substring(7); + const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/); + const slug = slugMatch ? slugMatch[1] : req.requestedSlug; + const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); const decoded = jwt.verify(token, process.env.JWT_SECRET); // Check if token is valid @@ -280,4 +278,4 @@ module.exports = { createAuthRateLimiter, isAuthenticated, shouldSkipRateLimit -}; \ No newline at end of file +}; diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js new file mode 100644 index 0000000..80059b1 --- /dev/null +++ b/backend/src/utils/tokenUtils.js @@ -0,0 +1,122 @@ +const ADMIN_COOKIE_NAME = 'admin_token'; +const GALLERY_COOKIE_NAME = 'gallery_token'; +const GALLERY_COOKIE_PREFIX = 'gallery_token_'; + +const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + +const secureCookie = process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'; +const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax'; +const cookieDomain = process.env.COOKIE_DOMAIN; + +function buildCookieBaseOptions() { + const options = { + httpOnly: true, + secure: secureCookie, + sameSite: sameSiteDefault, + path: '/', + }; + + if (cookieDomain) { + options.domain = cookieDomain; + } + + return options; +} + +function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) { + return { + ...buildCookieBaseOptions(), + maxAge: maxAgeMs, + }; +} + +function sanitizeSlugForCookie(slug = '') { + return String(slug).replace(/[^A-Za-z0-9_-]/g, '_'); +} + +function setAdminAuthCookie(res, token) { + if (!token) return; + res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry()); +} + +function clearAdminAuthCookie(res) { + res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions()); +} + +function setGalleryAuthCookies(res, token, slug) { + if (!token) return; + const options = buildCookieOptionsWithExpiry(); + res.cookie(GALLERY_COOKIE_NAME, token, options); + if (slug) { + const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; + res.cookie(cookieName, token, options); + } +} + +function clearGalleryAuthCookies(res, slug) { + const baseOptions = buildCookieBaseOptions(); + res.clearCookie(GALLERY_COOKIE_NAME, baseOptions); + + const cookies = res.req?.cookies || {}; + + if (slug) { + const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; + res.clearCookie(cookieName, baseOptions); + } else { + Object.keys(cookies).forEach((name) => { + if (name.startsWith(GALLERY_COOKIE_PREFIX)) { + res.clearCookie(name, baseOptions); + } + }); + } +} + +function getAdminTokenFromRequest(req) { + const header = req.headers?.authorization; + if (header && header.startsWith('Bearer ')) { + return header.substring(7); + } + return req.cookies?.[ADMIN_COOKIE_NAME] || null; +} + +function getGalleryTokenFromRequest(req, slug) { + const header = req.headers?.authorization; + if (header && header.startsWith('Bearer ')) { + return header.substring(7); + } + + if (!req.cookies) { + return null; + } + + if (slug) { + const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; + if (req.cookies[cookieName]) { + return req.cookies[cookieName]; + } + } + + if (req.cookies[GALLERY_COOKIE_NAME]) { + return req.cookies[GALLERY_COOKIE_NAME]; + } + + const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GALLERY_COOKIE_PREFIX)); + if (prefixed) { + return req.cookies[prefixed]; + } + + return null; +} + +module.exports = { + ADMIN_COOKIE_NAME, + GALLERY_COOKIE_NAME, + GALLERY_COOKIE_PREFIX, + sanitizeSlugForCookie, + setAdminAuthCookie, + clearAdminAuthCookie, + setGalleryAuthCookies, + clearGalleryAuthCookies, + getAdminTokenFromRequest, + getGalleryTokenFromRequest, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2d0cc6..0bfcc29 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,14 +20,14 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", - "axios": "^1.3.2", + "axios": "^1.12.2", "clsx": "^2.0.0", "date-fns": "4.1.0", "dompurify": "^3.2.6", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", - "js-cookie": "^3.0.5", + "linkifyjs": "^4.3.2", "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "0.525.0", @@ -44,7 +44,6 @@ }, "devDependencies": { "@eslint/js": "^9.29.0", - "@types/js-cookie": "^3.0.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.5.2", @@ -57,7 +56,7 @@ "tailwindcss": "^3.3.0", "typescript": "~5.8.3", "typescript-eslint": "^8.34.1", - "vite": "^7.0.0" + "vite": "^7.1.6" } }, "node_modules/@alloc/quick-lru": { @@ -2014,13 +2013,6 @@ "@types/unist": "^2" } }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2549,13 +2541,13 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -3903,15 +3895,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4032,9 +4015,9 @@ } }, "node_modules/linkifyjs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", - "integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, "node_modules/locate-path": { @@ -5524,14 +5507,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -5541,11 +5524,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -5731,18 +5717,18 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", - "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", + "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -5806,11 +5792,14 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, diff --git a/frontend/package.json b/frontend/package.json index 488196d..1de7657 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,14 +23,14 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", - "axios": "^1.3.2", + "axios": "^1.12.2", "clsx": "^2.0.0", "date-fns": "4.1.0", "dompurify": "^3.2.6", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", - "js-cookie": "^3.0.5", + "linkifyjs": "^4.3.2", "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "0.525.0", @@ -47,7 +47,6 @@ }, "devDependencies": { "@eslint/js": "^9.29.0", - "@types/js-cookie": "^3.0.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.5.2", @@ -60,6 +59,6 @@ "tailwindcss": "^3.3.0", "typescript": "~5.8.3", "typescript-eslint": "^8.34.1", - "vite": "^7.0.0" + "vite": "^7.1.6" } } diff --git a/frontend/src/components/MaintenanceWrapper.tsx b/frontend/src/components/MaintenanceWrapper.tsx index c6b34f0..e1708b0 100644 --- a/frontend/src/components/MaintenanceWrapper.tsx +++ b/frontend/src/components/MaintenanceWrapper.tsx @@ -1,9 +1,9 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { MaintenanceMode } from './MaintenanceMode'; import { useMaintenanceMode } from '../contexts/MaintenanceContext'; -import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api'; +import { setMaintenanceModeCallback, api } from '../config/api'; interface MaintenanceWrapperProps { children: React.ReactNode; @@ -12,10 +12,38 @@ interface MaintenanceWrapperProps { export const MaintenanceWrapper: React.FC = ({ children }) => { const location = useLocation(); const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); + const [hasAdminSession, setHasAdminSession] = useState(false); // Check if current route is admin route const isAdminRoute = location.pathname.startsWith('/admin'); - const hasAdminAuth = !!getAuthToken(true); + + useEffect(() => { + let isMounted = true; + + const checkAdminSession = async () => { + if (!isAdminRoute) { + setHasAdminSession(false); + return; + } + + try { + const response = await api.get<{ valid: boolean; type: string }>('/auth/session'); + if (isMounted) { + setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin')); + } + } catch (error) { + if (isMounted) { + setHasAdminSession(false); + } + } + }; + + checkAdminSession(); + + return () => { + isMounted = false; + }; + }, [isAdminRoute]); // Register the maintenance mode callback useEffect(() => { @@ -37,7 +65,7 @@ export const MaintenanceWrapper: React.FC = ({ children } catch (error: any) { if (error.response?.status === 503) { // Only set maintenance mode for non-admin routes or unauthenticated admin routes - if (!isAdminRoute || !hasAdminAuth) { + if (!isAdminRoute || !hasAdminSession) { setMaintenanceMode(true); return { maintenance: true }; } @@ -47,13 +75,13 @@ export const MaintenanceWrapper: React.FC = ({ children }, staleTime: 30000, // Check every 30 seconds retry: false, // Don't retry on failure - enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance + enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance }); // Show maintenance page if in maintenance mode and not on admin route with auth - if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) { + if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) { return ; } return <>{children}; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index 111e87a..4a4a205 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import { getAuthToken } from '../../config/api'; import { buildResourceUrl } from '../../utils/url'; interface AuthenticatedImageProps extends React.ImgHTMLAttributes { @@ -25,33 +24,12 @@ export const AuthenticatedImage: React.FC = ({ let objectUrl: string | null = null; // Determine which token to use based on context - let token: string | undefined; - - if (isGallery) { - // For gallery images, get the gallery-specific token - const pathParts = window.location.pathname.split('/'); - if (pathParts[1] === 'gallery' && pathParts[2]) { - const gallerySlug = pathParts[2]; - token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined; - } - } else { - // For admin images, use the admin token - token = getAuthToken(true); - } - if (!src) { setImageSrc(fallbackSrc || ''); setIsLoading(false); return; } - if (!token) { - // No auth token - use fallback - setImageSrc(fallbackSrc || ''); - setIsLoading(false); - return; - } - setIsLoading(true); setError(false); @@ -71,9 +49,7 @@ export const AuthenticatedImage: React.FC = ({ // Fetch authenticated image const response = await fetch(fullImageUrl, { - headers: { - 'Authorization': `Bearer ${token}` - } + credentials: 'include' }); if (!response.ok) { @@ -119,4 +95,4 @@ export const AuthenticatedImage: React.FC = ({ } return {alt}; -}; \ No newline at end of file +}; diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 65ed3dc..2396a35 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,9 +1,4 @@ -import axios, { AxiosHeaders } from 'axios'; -import Cookies from 'js-cookie'; - -// Cookie keys -export const ADMIN_TOKEN_KEY = 'admin_token'; -export const GALLERY_TOKEN_KEY = 'gallery_token'; +import axios from 'axios'; // Maintenance mode callback let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; @@ -18,80 +13,12 @@ export const api = axios.create({ headers: { 'Content-Type': 'application/json', }, - withCredentials: false, // Ensure we're not relying on cookies + withCredentials: true, }); -// Request interceptor to add auth token +// Request interceptor: drop Content-Type for FormData payloads so the browser can set boundaries api.interceptors.request.use( (config) => { - // Don't process if headers are already set by the component - const existingAuth = config.headers?.['Authorization'] || config.headers?.get?.('Authorization'); - - // If authorization is already set by the component, don't override it - if (existingAuth) { - return config; - } - - // Check if it's an admin route or gallery route - const isAdminRoute = config.url?.includes('/admin'); - - if (isAdminRoute) { - const token = Cookies.get(ADMIN_TOKEN_KEY); - if (token) { - if (!config.headers) { - config.headers = {}; - } - config.headers['Authorization'] = `Bearer ${token}`; - } - } else { - // For gallery routes, try to extract slug from the request URL first - const galleryMatch = config.url?.match(/gallery\/([^\/]+)/); - - if (galleryMatch && galleryMatch[1]) { - const galleryIdOrSlug = galleryMatch[1]; - // Remove any query parameters from the slug - const cleanIdOrSlug = galleryIdOrSlug.split('?')[0]; - - // Check if it's a numeric ID (for upload endpoints) - let token = null; - if (/^\d+$/.test(cleanIdOrSlug)) { - // It's an event ID - try to find the token from current page slug - const pathParts = window.location.pathname.split('/'); - if (pathParts[1] === 'gallery' && pathParts[2]) { - const gallerySlug = pathParts[2]; - const cleanSlug = gallerySlug.split('?')[0]; - token = localStorage.getItem(`gallery_token_${cleanSlug}`); - } - } else { - // It's a slug - use it directly - token = localStorage.getItem(`gallery_token_${cleanIdOrSlug}`); - } - - if (token) { - if (!config.headers) { - config.headers = {}; - } - config.headers['Authorization'] = `Bearer ${token}`; - } - } else { - // Fallback to getting slug from the current page URL - const pathParts = window.location.pathname.split('/'); - if (pathParts[1] === 'gallery' && pathParts[2]) { - const gallerySlug = pathParts[2]; - // Remove any query parameters from the slug - const cleanSlug = gallerySlug.split('?')[0]; - const token = localStorage.getItem(`gallery_token_${cleanSlug}`); - if (token) { - if (!config.headers) { - config.headers = {}; - } - config.headers['Authorization'] = `Bearer ${token}`; - } - } - } - } - - // Don't set Content-Type for FormData - let browser set it with boundary if (config.data instanceof FormData) { delete config.headers?.['Content-Type']; } @@ -110,10 +37,9 @@ api.interceptors.response.use( // Handle maintenance mode (503) if (error.response?.status === 503) { const isAdminRoute = error.config?.url?.includes('/admin'); - const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer '); // Only trigger maintenance mode for non-admin routes or unauthenticated admin routes - if (!isAdminRoute || !hasAdminAuth) { + if (!isAdminRoute) { if (maintenanceModeCallback) { maintenanceModeCallback(true); } @@ -126,8 +52,6 @@ api.interceptors.response.use( const currentPath = window.location.pathname; if (isAdminRoute) { - // Clear admin token on unauthorized - Cookies.remove(ADMIN_TOKEN_KEY); // Only redirect if we're not already on the admin login page if (!currentPath.includes('/admin/login')) { window.location.href = '/admin/login'; @@ -144,8 +68,7 @@ api.interceptors.response.use( // Don't clear tokens for image requests - they might just need a retry if (!isImageRequest && galleryMatch && galleryMatch[1]) { const gallerySlug = galleryMatch[1]; - localStorage.removeItem(`gallery_token_${gallerySlug}`); - localStorage.removeItem(`gallery_event_${gallerySlug}`); + sessionStorage.removeItem(`gallery_event_${gallerySlug}`); } // Don't redirect - let the component handle the auth state } else if (galleryMatch) { @@ -159,21 +82,3 @@ api.interceptors.response.use( return Promise.reject(error); } ); - -// Helper to set auth tokens -export const setAuthToken = (token: string, isAdmin: boolean = false) => { - const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; - Cookies.set(key, token, { expires: 1 }); // 1 day expiry -}; - -// Helper to clear auth tokens -export const clearAuthToken = (isAdmin: boolean = false) => { - const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; - Cookies.remove(key); -}; - -// Helper to get auth tokens -export const getAuthToken = (isAdmin: boolean = false) => { - const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; - return Cookies.get(key); -}; \ No newline at end of file diff --git a/frontend/src/contexts/AdminAuthContext.tsx b/frontend/src/contexts/AdminAuthContext.tsx index df46a5b..dce42ea 100644 --- a/frontend/src/contexts/AdminAuthContext.tsx +++ b/frontend/src/contexts/AdminAuthContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import type { ReactNode } from 'react'; -import { getAuthToken } from '../config/api'; +import { api } from '../config/api'; import { authService } from '../services'; import type { AdminUser } from '../types'; @@ -40,15 +40,31 @@ export const AdminAuthProvider: React.FC = ({ children } // Check if user has a valid token on mount const checkAuth = async () => { try { - const token = getAuthToken(true); - if (token) { - // For now, just assume the token is valid - // TODO: Validate token with backend and get user info + const storedUser = sessionStorage.getItem('admin_user'); + if (storedUser) { + try { + setUser(JSON.parse(storedUser)); + } catch (err) { + sessionStorage.removeItem('admin_user'); + } + } + + const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string }>( + '/auth/session' + ); + + if (response.data?.valid && response.data.type === 'admin') { setIsAuthenticated(true); + } else { + sessionStorage.removeItem('admin_user'); + setIsAuthenticated(false); + setUser(null); } } catch (error) { // Auth check failed - user needs to login - setError('Failed to check authentication'); + sessionStorage.removeItem('admin_user'); + setIsAuthenticated(false); + setUser(null); } finally { setIsLoading(false); } @@ -63,9 +79,11 @@ export const AdminAuthProvider: React.FC = ({ children } setError(null); setIsAuthenticated(true); setMustChangePassword(user.mustChangePassword || false); + sessionStorage.setItem('admin_user', JSON.stringify(user)); }; const logout = () => { + sessionStorage.removeItem('admin_user'); authService.adminLogout(); setIsAuthenticated(false); setUser(null); @@ -79,6 +97,10 @@ export const AdminAuthProvider: React.FC = ({ children } ...user, mustChangePassword: false }); + sessionStorage.setItem('admin_user', JSON.stringify({ + ...user, + mustChangePassword: false + })); } }; @@ -98,4 +120,4 @@ export const AdminAuthProvider: React.FC = ({ children } {children} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 83d3689..df10d9f 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import type { ReactNode } from 'react'; +import { api } from '../config/api'; import { authService, galleryService } from '../services'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; @@ -52,65 +53,81 @@ export const GalleryAuthProvider: React.FC = ({ childr }; useEffect(() => { - // Clean up old authentication data on mount cleanupOldGalleryAuth(); - - // Check if user has a valid token on mount - const currentSlug = getCurrentGallerySlug(); - if (currentSlug) { - // Try to restore event data from localStorage with slug-specific key - const storedEvent = localStorage.getItem(`gallery_event_${currentSlug}`); - const storedToken = localStorage.getItem(`gallery_token_${currentSlug}`); - - if (storedEvent && storedToken) { + + const initialise = async () => { + const currentSlug = getCurrentGallerySlug(); + + if (!currentSlug) { + setIsLoading(false); + return; + } + + const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); + if (storedEvent) { try { - const eventData = JSON.parse(storedEvent); - // Verify the stored event matches the current gallery slug - if (eventData && eventData.id) { - setEvent(eventData); - setIsAuthenticated(true); - } else { - // Clear invalid data - localStorage.removeItem(`gallery_event_${currentSlug}`); - localStorage.removeItem(`gallery_token_${currentSlug}`); + const parsed = JSON.parse(storedEvent); + if (parsed && parsed.id) { + setEvent(parsed); } - } catch (error) { - // Invalid stored data - clear it - localStorage.removeItem(`gallery_event_${currentSlug}`); - localStorage.removeItem(`gallery_token_${currentSlug}`); - } - } else { - // No stored auth; check for token in URL and auto-authenticate - const parts = window.location.pathname.split('/'); - const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined); - if (urlToken) { - (async () => { - try { - setIsLoading(true); - // Verify token against backend - const verify = await galleryService.verifyToken(currentSlug, urlToken); - if (verify?.valid) { - // Store token and fetch event via photos endpoint to get full event object - localStorage.setItem(`gallery_token_${currentSlug}`, urlToken); - const data = await galleryService.getGalleryPhotos(currentSlug); - if (data?.event) { - setEvent(data.event); - setIsAuthenticated(true); - localStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(data.event)); - } - } - } catch (e) { - // Invalid token; ensure any residual storage is cleared - localStorage.removeItem(`gallery_token_${currentSlug}`); - localStorage.removeItem(`gallery_event_${currentSlug}`); - } finally { - setIsLoading(false); - } - })(); + } catch (err) { + sessionStorage.removeItem(`gallery_event_${currentSlug}`); } } - } - setIsLoading(false); + + try { + setIsLoading(true); + const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>( + '/auth/session', + { params: { slug: currentSlug } } + ); + + if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) { + setIsAuthenticated(true); + + if (!storedEvent) { + // 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)); + } + } + + return; + } + + // If no active session, check for share token in URL + const parts = window.location.pathname.split('/'); + const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined); + + if (urlToken) { + const verify = await galleryService.verifyToken(currentSlug, urlToken); + if (verify?.valid) { + const response = await authService.shareLinkLogin(currentSlug, urlToken); + if (response?.event) { + setEvent(response.event); + setIsAuthenticated(true); + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); + return; + } + } + } + + // No valid session found + setIsAuthenticated(false); + sessionStorage.removeItem(`gallery_event_${currentSlug}`); + setEvent(null); + } catch (error) { + setIsAuthenticated(false); + sessionStorage.removeItem(`gallery_event_${currentSlug}`); + setEvent(null); + } finally { + setIsLoading(false); + } + }; + + initialise(); }, []); const login = async (slug: string, password: string, recaptchaToken?: string | null) => { @@ -121,9 +138,8 @@ export const GalleryAuthProvider: React.FC = ({ childr setEvent(response.event); setIsAuthenticated(true); - // Store event data and token in localStorage with slug-specific key - localStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); - localStorage.setItem(`gallery_token_${slug}`, response.token); + // Store event data for quick reloads (non-sensitive) + sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); } catch (err: any) { setError(err.response?.data?.error || 'Invalid password'); throw err; @@ -135,13 +151,13 @@ export const GalleryAuthProvider: React.FC = ({ childr const logout = () => { const currentSlug = getCurrentGallerySlug(); if (currentSlug) { - localStorage.removeItem(`gallery_event_${currentSlug}`); - localStorage.removeItem(`gallery_token_${currentSlug}`); + sessionStorage.removeItem(`gallery_event_${currentSlug}`); } - authService.galleryLogout(); + authService.galleryLogout(currentSlug || undefined); setIsAuthenticated(false); setEvent(null); - }; + } +; return ( { const { t } = useTranslation(); @@ -84,17 +84,20 @@ export const AdminLoginPage: React.FC = () => { login(response.token, response.user); toast.success(t('adminLogin.loginSuccess')); setLoginSuccess(true); - } catch (error: any) { - // Login error handled by UI notification - - // Handle network errors gracefully - if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') { + } catch (error: any) { + // Login error handled by UI notification + + // Handle network errors gracefully + if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') { // Check if we actually got logged in despite the error - const token = getAuthToken(true); - if (token) { - // Login was successful, just had a connection issue - setLoginSuccess(true); - return; + try { + const sessionResponse = await api.get<{ valid: boolean; type: string }>('/auth/session'); + if (sessionResponse.data?.valid && sessionResponse.data.type === 'admin') { + setLoginSuccess(true); + return; + } + } catch (sessionError) { + // Ignore secondary failure, we'll surface the original network error } toast.error(t('adminLogin.networkError')); } else if (error.response?.status === 429) { @@ -259,4 +262,4 @@ export const AdminLoginPage: React.FC = () => { ); }; -AdminLoginPage.displayName = 'AdminLoginPage'; \ No newline at end of file +AdminLoginPage.displayName = 'AdminLoginPage'; diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 0ecc759..8d3fdaf 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,4 +1,4 @@ -import { api, setAuthToken, clearAuthToken } from '../config/api'; +import { api } from '../config/api'; import type { LoginResponse, GalleryAuthResponse } from '../types'; export const authService = { @@ -10,14 +10,17 @@ export const authService = { password: credentials.password, recaptchaToken: credentials.recaptchaToken }); - - setAuthToken(response.data.token, true); return response.data; }, - adminLogout() { - clearAuthToken(true); - window.location.href = '/admin/login'; + async adminLogout() { + try { + await api.post('/auth/logout'); + } catch (err) { + // Ignore logout errors; fallback to redirect + } finally { + window.location.href = '/admin/login'; + } }, // Gallery authentication @@ -32,7 +35,19 @@ export const authService = { return response.data; }, - galleryLogout() { - // Logout is now handled by GalleryAuthContext + async shareLinkLogin(slug: string, token: string): Promise { + const response = await api.post('/auth/gallery/share-login', { + slug, + token, + }); + return response.data; }, -}; \ No newline at end of file + + async galleryLogout(slug?: string | null) { + try { + await api.post('/auth/gallery/logout', { slug }); + } catch (err) { + // Ignore; cookie will naturally expire if removal fails + } + }, +}; diff --git a/frontend/src/services/secureToken.service.ts b/frontend/src/services/secureToken.service.ts index ddb50bf..581382e 100644 --- a/frontend/src/services/secureToken.service.ts +++ b/frontend/src/services/secureToken.service.ts @@ -49,21 +49,10 @@ class SecureTokenService { } try { - // Get the gallery token from localStorage - const galleryToken = localStorage.getItem(`gallery_token_${slug}`); - if (!galleryToken) { - throw new Error('No gallery authentication token found'); - } - - // Generate new token from backend with explicit auth header + // Generate new token from backend – authentication handled via cookies const response = await api.post( `/secure-images/${slug}/generate-token`, - { photoId, accessType }, - { - headers: { - 'Authorization': `Bearer ${galleryToken}` - } - } + { photoId, accessType } ); const tokenData: SecureToken = { @@ -190,4 +179,4 @@ if (typeof window !== 'undefined') { setInterval(() => { secureTokenService.clearExpiredTokens(); }, 5 * 60 * 1000); -} \ No newline at end of file +} diff --git a/frontend/src/utils/cleanupGalleryAuth.ts b/frontend/src/utils/cleanupGalleryAuth.ts index ad10d3f..5aca910 100644 --- a/frontend/src/utils/cleanupGalleryAuth.ts +++ b/frontend/src/utils/cleanupGalleryAuth.ts @@ -9,18 +9,12 @@ export const cleanupOldGalleryAuth = () => { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && (key.startsWith('gallery_token') || key.startsWith('gallery_event'))) { - // Check if it's an old format token that might be corrupted - const value = localStorage.getItem(key); - if (value && (value.length < 100 || !value.includes('.'))) { - // Token is too short or doesn't contain dots (not a valid JWT) - keysToRemove.push(key); - } + keysToRemove.push(key); } } keysToRemove.forEach(key => { localStorage.removeItem(key); - // Silently remove corrupted tokens }); // Remove old gallery token from cookies if it exists @@ -29,4 +23,4 @@ export const cleanupOldGalleryAuth = () => { // Also clear session storage sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_token'); -}; \ No newline at end of file +}; diff --git a/tests/e2e/admin-create-event-ui.spec.ts b/tests/e2e/admin-create-event-ui.spec.ts new file mode 100644 index 0000000..c6f95c4 --- /dev/null +++ b/tests/e2e/admin-create-event-ui.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +function randomSuffix() { + return Math.random().toString(36).slice(2, 8); +} + +test('admin can create event via UI', async ({ page }) => { + const eventName = `UI Playwright ${randomSuffix()}`; + const hostEmail = `host+${randomSuffix()}@example.com`; + + // Login + await page.goto('/admin/login'); + await page.getByLabel(/Email/i).fill(ADMIN_EMAIL); + await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /Sign In|Log in/i }).click(); + await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 }); + + // Navigate to create event page + const createButton = page.getByRole('button', { name: /Create Event/i }); + if (await createButton.count()) { + await createButton.first().click(); + } else { + await page.goto('/admin/events/new'); + } + + await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 }); + + await page.getByLabel(/Event Name/i).fill(eventName); + await page.getByLabel(/Host Name/i).fill('Host User'); + await page.getByLabel(/Event Date/i).fill('2025-12-31'); + await page.getByLabel(/Host Email/i).fill(hostEmail); + await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL); + await page.getByLabel(/Gallery Password/i).fill('UiPlay123!'); + await page.getByLabel(/Confirm Password/i).fill('UiPlay123!'); + + await page.getByRole('button', { name: /Create Event/i }).click(); + + await expect(page).toHaveURL(/\/admin\/events\//, { timeout: 20000 }); + await expect(page.getByRole('heading', { name: eventName })).toBeVisible(); +}); diff --git a/tests/e2e/auth-smoke.spec.ts b/tests/e2e/auth-smoke.spec.ts new file mode 100644 index 0000000..d0ad959 --- /dev/null +++ b/tests/e2e/auth-smoke.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function createEventWithPhotos(page: Page) { + const api = page.request; + const loginResponse = await api.post('/api/auth/admin/login', { + data: { + username: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + }, + }); + expect(loginResponse.ok()).toBeTruthy(); + const { token } = await loginResponse.json(); + expect(token).toBeTruthy(); + + const eventName = `Playwright Smoke ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const eventResponse = await api.post('/api/admin/events', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + host_name: 'Playwright Host', + host_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 30, + allow_user_uploads: false, + allow_downloads: true, + disable_right_click: false, + watermark_downloads: false, + }, + }); + expect(eventResponse.ok()).toBeTruthy(); + const event = await eventResponse.json(); + + const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png'); + const buffer = fs.readFileSync(imagePath); + const uploadResponse = await api.post(`/api/admin/events/${event.id}/upload`, { + headers: { + Authorization: `Bearer ${token}`, + }, + multipart: { + photos: { + name: path.basename(imagePath), + mimeType: 'image/png', + buffer, + }, + category_id: 'individual', + }, + }); + expect(uploadResponse.ok()).toBeTruthy(); + + return { + event, + shareLink: event.share_link, + slug: event.slug, + }; +} + +test('admin login and gallery viewing smoke test', async ({ page }) => { + const { shareLink } = await createEventWithPhotos(page); + + // Admin UI login + await page.goto('/admin/login'); + const emailField = page.getByLabel(/Email/i); + if (await emailField.count()) { + await emailField.fill(ADMIN_EMAIL); + await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /Sign In|Log in/i }).click(); + } + await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 }); + + // Visit gallery share link and authenticate + await page.goto(shareLink); + const passwordField = page.getByPlaceholder(/gallery password/i); + await passwordField.fill(GALLERY_PASSWORD); + await page.getByRole('button', { name: /View Gallery/i }).click(); + + // Wait for photos grid to appear + const tiles = page.locator('.relative.group'); + await expect(tiles.first()).toBeVisible({ timeout: 20000 }); + + // Open lightbox to ensure media renders + await tiles.first().hover(); + await tiles.first().getByRole('button', { name: /View full size/i }).click(); + await expect(page.getByRole('button', { name: /Close/i })).toBeVisible(); +}); diff --git a/tests/e2e/gallery-grid-actions.spec.ts b/tests/e2e/gallery-grid-actions.spec.ts new file mode 100644 index 0000000..ffedabd --- /dev/null +++ b/tests/e2e/gallery-grid-actions.spec.ts @@ -0,0 +1,180 @@ +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function ensureGalleryWithPhotos(page) { + const loginResponse = await page.request.post('/api/auth/admin/login', { + data: { + username: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + }, + failOnStatusCode: false, + }); + expect(loginResponse.ok()).toBeTruthy(); + const { token } = await loginResponse.json(); + expect(token).toBeTruthy(); + + const eventName = `Playwright MCP ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const createResponse = await page.request.post('/api/admin/events', { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + host_name: 'Playwright Host', + host_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 90, + allow_user_uploads: false, + allow_downloads: true, + disable_right_click: false, + watermark_downloads: false, + feedback_enabled: true, + allow_ratings: true, + allow_likes: true, + allow_comments: true, + allow_favorites: true, + require_name_email: false, + moderate_comments: false, + show_feedback_to_guests: true, + }, + failOnStatusCode: false, + }); + + expect(createResponse.ok()).toBeTruthy(); + const createdEvent = await createResponse.json(); + expect(createdEvent?.id).toBeTruthy(); + + const imagePaths = ['img1.png', 'img2.png'].map((file) => + path.join(process.cwd(), 'test-assets', file) + ); + + for (const imagePath of imagePaths) { + const buffer = fs.readFileSync(imagePath); + const uploadResponse = await page.request.post( + `/api/admin/events/${createdEvent.id}/upload`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + multipart: { + photos: { + name: path.basename(imagePath), + mimeType: 'image/png', + buffer, + }, + category_id: 'individual', + }, + failOnStatusCode: false, + } + ); + expect(uploadResponse.ok()).toBeTruthy(); + } + + return { + shareLink: createdEvent.share_link, + slug: createdEvent.slug, + }; +} + +test.describe('Gallery grid tile quick actions', () => { + test('Each tile: open, download, comment, like with immediate UI', async ({ page }) => { + const { shareLink } = await ensureGalleryWithPhotos(page); + + await page.goto(shareLink); + const gallery = page; + await gallery.waitForLoadState('domcontentloaded'); + await gallery.waitForURL(/\/gallery\//); + + const passwordField = gallery.getByPlaceholder(/gallery password/i).first(); + if (await passwordField.count()) { + await passwordField.fill(GALLERY_PASSWORD); + await gallery.getByRole('button', { name: /View Gallery/i }).click(); + await gallery.waitForLoadState('networkidle'); + } + + // Ensure grid tiles rendered + const tiles = gallery.locator('.relative.group'); + await expect(tiles.first()).toBeVisible({ timeout: 20000 }); + + const tileCount = await tiles.count(); + expect(tileCount).toBeGreaterThan(0); + + // Limit to a few tiles to keep test time sensible + const N = Math.min(tileCount, 3); + for (let i = 0; i < N; i++) { + const tile = tiles.nth(i); + await tile.scrollIntoViewIfNeeded(); + // On desktop, actions show on hover + await tile.hover({ force: true }); + + // Actions should be present + const openBtn = tile.getByRole('button', { name: /View full size/i }); + await expect(openBtn).toBeVisible(); + + const likeBtn = tile.getByRole('button', { name: /Like photo/i }).first(); + await expect(likeBtn).toBeVisible(); + + const commentBtn = tile.getByRole('button', { name: /Comment on photo|Comment/i }).first(); + await expect(commentBtn).toBeVisible(); + + const downloadBtn = tile.getByRole('button', { name: /Download photo/i }).first(); + await expect(downloadBtn).toBeVisible(); + + // Like should toggle to red and indicator appear immediately + const pressedBefore = await likeBtn.getAttribute('aria-pressed'); + await likeBtn.click(); + await expect.poll(async () => (await likeBtn.getAttribute('aria-pressed')) || '').toContain('true'); + // Feedback indicator (title="Liked") should appear on the tile + await expect(tile.locator('[title="Liked"]')).toBeVisible(); + + // Open lightbox + await openBtn.click(); + const closeLightboxBtn = gallery.getByRole('button', { name: /^Close$/i }).first(); + await expect(closeLightboxBtn).toBeVisible(); + // Close again to continue + await closeLightboxBtn.click(); + + // Comment quick action should open lightbox with feedback panel visible + await tile.hover({ force: true }); + await commentBtn.click(); + await expect(gallery.getByRole('button', { name: /Toggle feedback/ })).toBeVisible(); + + // Ensure feedback panel is visible or open it + const feedbackHeading = gallery.getByRole('heading', { name: /Photo Feedback/i }); + if (!(await feedbackHeading.isVisible())) { + await gallery.getByRole('button', { name: /Toggle feedback/ }).click(); + } + await expect(feedbackHeading).toBeVisible(); + + // Comments quick action should surface the feedback tools + const addCommentBtn = gallery.getByRole('button', { name: /Add Comment|Add comment/i }); + await expect(addCommentBtn).toBeVisible(); + await addCommentBtn.click(); + // Allow UI to react without requiring text entry + await gallery.waitForTimeout(250); + + // Close lightbox to continue (we do not submit to keep test idempotent) + await closeLightboxBtn.click(); + + // Download from tile should trigger a browser download event + await tile.hover({ force: true }); + const downloadPromise = gallery.waitForEvent('download'); + await downloadBtn.click(); + const download = await downloadPromise; + expect((await download.path()) !== null).toBeTruthy(); + } + }); +});