diff --git a/backend/data/photo-sharing.db b/backend/data/photo-sharing.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index f5bd38e..92bd487 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/migrations/007_add_read_at_to_activity_logs.js b/backend/migrations/007_add_read_at_to_activity_logs.js new file mode 100644 index 0000000..d4333bd --- /dev/null +++ b/backend/migrations/007_add_read_at_to_activity_logs.js @@ -0,0 +1,15 @@ +exports.up = async function(knex) { + // Add read_at column to activity_logs table + const hasReadAt = await knex.schema.hasColumn('activity_logs', 'read_at'); + if (!hasReadAt) { + await knex.schema.table('activity_logs', (table) => { + table.datetime('read_at').nullable(); + }); + } +}; + +exports.down = async function(knex) { + await knex.schema.table('activity_logs', (table) => { + table.dropColumn('read_at'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js index a7e0075..b9f965c 100644 --- a/backend/migrations/run-migrations.js +++ b/backend/migrations/run-migrations.js @@ -28,7 +28,7 @@ async function runMigration(filename) { if (migration.up) { console.log(`Running migration: ${filename}`); - await migration.up(); + await migration.up(db); await db('migrations').insert({ filename }); console.log(`Migration ${filename} completed`); } @@ -39,13 +39,12 @@ async function runMigrations() { try { console.log('Starting database migrations...'); - // First run the init.js if it exists - const initPath = path.join(__dirname, 'init.js'); - if (require.resolve(initPath)) { - console.log('Running initial setup...'); - require(initPath); - // Wait a bit for init to complete - await new Promise(resolve => setTimeout(resolve, 2000)); + // First run the init.js if it exists but only if migrations table doesn't exist + const tableExists = await db.schema.hasTable('migrations'); + if (!tableExists) { + const { initializeDatabase } = require('../src/database/db'); + console.log('Running initial database setup...'); + await initializeDatabase(); } // Create migrations table diff --git a/backend/package-lock.json b/backend/package-lock.json index 71f38cb..32bc900 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,7 +8,9 @@ "name": "photo-sharing-backend", "version": "1.0.0", "dependencies": { + "adm-zip": "^0.5.16", "archiver": "^5.3.1", + "axios": "^1.10.0", "bcrypt": "^5.1.0", "chokidar": "^3.5.3", "cors": "^2.8.5", @@ -16,6 +18,7 @@ "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", + "form-data": "^4.0.3", "helmet": "^7.0.0", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", @@ -1525,6 +1528,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1773,9 +1785,19 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "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==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -2575,7 +2597,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2892,7 +2913,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3151,7 +3171,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3701,11 +3720,30 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4011,7 +4049,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6724,6 +6761,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/backend/package.json b/backend/package.json index db5514e..4339757 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,9 @@ "lint": "eslint src/" }, "dependencies": { + "adm-zip": "^0.5.16", "archiver": "^5.3.1", + "axios": "^1.10.0", "bcrypt": "^5.1.0", "chokidar": "^3.5.3", "cors": "^2.8.5", @@ -19,6 +21,7 @@ "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", + "form-data": "^4.0.3", "helmet": "^7.0.0", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", diff --git a/backend/scripts/check-db-schema.js b/backend/scripts/check-db-schema.js new file mode 100755 index 0000000..f508832 --- /dev/null +++ b/backend/scripts/check-db-schema.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +const sqlite3 = require('sqlite3').verbose(); + +// Connect to the database +const dbPath = '/app/data/photo_sharing.db'; +console.log(`Connecting to database at: ${dbPath}`); + +const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + console.error('Error opening database:', err.message); + process.exit(1); + } + console.log('Connected to the SQLite database.\n'); +}); + +// Get schema for events table +console.log('=== EVENTS TABLE SCHEMA ==='); +db.all("PRAGMA table_info(events)", [], (err, rows) => { + if (err) { + console.error('Error getting events schema:', err.message); + } else { + rows.forEach(row => { + console.log(`${row.name} (${row.type})`); + }); + } + + console.log('\n=== PHOTOS TABLE SCHEMA ==='); + // Get schema for photos table + db.all("PRAGMA table_info(photos)", [], (err, rows) => { + if (err) { + console.error('Error getting photos schema:', err.message); + } else { + rows.forEach(row => { + console.log(`${row.name} (${row.type})`); + }); + } + + // Close the database + db.close(); + }); +}); \ No newline at end of file diff --git a/backend/scripts/debug-event-photos.js b/backend/scripts/debug-event-photos.js new file mode 100644 index 0000000..243dc78 --- /dev/null +++ b/backend/scripts/debug-event-photos.js @@ -0,0 +1,56 @@ +const knex = require('knex')({ + client: 'sqlite3', + connection: { filename: '/app/data/photo_sharing.db' }, + useNullAsDefault: true +}); + +async function debugEventPhotos() { + try { + // Get all photos for event 12 + const photos = await knex('photos') + .where('event_id', 12) + .select('id', 'filename', 'path', 'thumbnail_path') + .orderBy('id'); + + console.log('Total photos for event 12:', photos.length); + console.log('\nSample photos:'); + + // Show first few and specific IDs that were failing + const sampleIds = [1686, 1687, 1688, 1689, 1715, 1717, 1718, 1719]; + const samples = photos.filter(p => sampleIds.includes(p.id)); + + samples.forEach(p => { + console.log(`\nID ${p.id}: ${p.filename}`); + console.log(` Path: ${p.path}`); + console.log(` Thumbnail: ${p.thumbnail_path}`); + }); + + // Check for any photos without thumbnails + const noThumbs = photos.filter(p => !p.thumbnail_path); + if (noThumbs.length > 0) { + console.log(`\nPhotos without thumbnails: ${noThumbs.length}`); + noThumbs.forEach(p => console.log(` ID ${p.id}: ${p.filename}`)); + } + + // Check file existence for failing photos + const fs = require('fs').promises; + console.log('\nChecking file existence for samples:'); + + for (const photo of samples) { + const thumbPath = `/app/storage/${photo.thumbnail_path}`; + try { + await fs.access(thumbPath); + console.log(`✓ ID ${photo.id}: Thumbnail exists at ${thumbPath}`); + } catch (err) { + console.log(`✗ ID ${photo.id}: Thumbnail NOT FOUND at ${thumbPath}`); + } + } + + } catch (error) { + console.error('Error:', error); + } finally { + knex.destroy(); + } +} + +debugEventPhotos(); \ No newline at end of file diff --git a/backend/scripts/debug-photos-enhanced.js b/backend/scripts/debug-photos-enhanced.js new file mode 100755 index 0000000..89cfa73 --- /dev/null +++ b/backend/scripts/debug-photos-enhanced.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +const sqlite3 = require('sqlite3').verbose(); +const fs = require('fs'); +const path = require('path'); + +// Connect to the database +const dbPath = '/app/data/photo_sharing.db'; +console.log(`Connecting to database at: ${dbPath}`); + +const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + console.error('Error opening database:', err.message); + process.exit(1); + } + console.log('Connected to the SQLite database.\n'); +}); + +// Query for photos with IDs 1688 and 1689 where event_id = 12 +const query = ` + SELECT p.id, p.filename, p.path, p.thumbnail_path, p.event_id, + e.slug as event_slug, e.is_active, e.is_archived + FROM photos p + JOIN events e ON p.event_id = e.id + WHERE p.id IN (1688, 1689) AND p.event_id = 12 +`; + +console.log('Executing query to get photo details with event information...\n'); + +db.all(query, [], (err, rows) => { + if (err) { + console.error('Error executing query:', err.message); + db.close(); + process.exit(1); + } + + console.log(`Found ${rows.length} photo(s):\n`); + + if (rows.length === 0) { + console.log('No photos found matching the criteria.'); + } else { + rows.forEach((row) => { + console.log('=== Photo ID:', row.id, '==='); + console.log('Filename:', row.filename); + console.log('DB Path:', row.path); + console.log('DB Thumbnail Path:', row.thumbnail_path); + console.log('Event ID:', row.event_id); + console.log('Event Slug:', row.event_slug); + console.log('Event is_active:', row.is_active); + console.log('Event is_archived:', row.is_archived); + + // Check file existence + const storageBase = '/app/storage'; + const eventStatusDir = row.is_active ? 'active' : 'archived'; + + // Check full image path + const fullImagePath1 = path.join(storageBase, row.path); + const fullImagePath2 = path.join(storageBase, 'events', eventStatusDir, row.path); + + console.log('\nChecking full image paths:'); + console.log(` Path 1: ${fullImagePath1} - ${fs.existsSync(fullImagePath1) ? 'EXISTS' : 'NOT FOUND'}`); + console.log(` Path 2: ${fullImagePath2} - ${fs.existsSync(fullImagePath2) ? 'EXISTS' : 'NOT FOUND'}`); + + // Check thumbnail path + const thumbnailPath = path.join(storageBase, row.thumbnail_path); + console.log('\nChecking thumbnail path:'); + console.log(` ${thumbnailPath} - ${fs.existsSync(thumbnailPath) ? 'EXISTS' : 'NOT FOUND'}`); + + console.log('\n---\n'); + }); + } + + // Close the database connection + db.close((err) => { + if (err) { + console.error('Error closing database:', err.message); + } else { + console.log('Database connection closed.'); + } + }); +}); \ No newline at end of file diff --git a/backend/scripts/debug-photos.js b/backend/scripts/debug-photos.js new file mode 100755 index 0000000..dbd5ff0 --- /dev/null +++ b/backend/scripts/debug-photos.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +// Connect to the database +const dbPath = '/app/data/photo_sharing.db'; +console.log(`Connecting to database at: ${dbPath}`); + +const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + console.error('Error opening database:', err.message); + process.exit(1); + } + console.log('Connected to the SQLite database.'); +}); + +// Query for photos with IDs 1688 and 1689 where event_id = 12 +const query = ` + SELECT id, filename, path, thumbnail_path + FROM photos + WHERE id IN (1688, 1689) AND event_id = 12 +`; + +console.log('\nExecuting query:', query); + +db.all(query, [], (err, rows) => { + if (err) { + console.error('Error executing query:', err.message); + db.close(); + process.exit(1); + } + + console.log(`\nFound ${rows.length} photo(s):\n`); + + if (rows.length === 0) { + console.log('No photos found matching the criteria.'); + } else { + rows.forEach((row) => { + console.log('Photo ID:', row.id); + console.log('Filename:', row.filename); + console.log('Path:', row.path); + console.log('Thumbnail Path:', row.thumbnail_path); + console.log('---'); + }); + } + + // Close the database connection + db.close((err) => { + if (err) { + console.error('Error closing database:', err.message); + } else { + console.log('\nDatabase connection closed.'); + } + }); +}); \ No newline at end of file diff --git a/backend/scripts/test-admin-photo.js b/backend/scripts/test-admin-photo.js new file mode 100644 index 0000000..7196912 --- /dev/null +++ b/backend/scripts/test-admin-photo.js @@ -0,0 +1,55 @@ +const axios = require('axios'); + +async function testAdminPhotoEndpoint() { + try { + // First login + console.log('1. Logging in as admin...'); + const loginResponse = await axios.post('http://localhost:3000/api/admin/auth/login', { + username: 'admin', + password: 'admin123' + }); + + const token = loginResponse.data.token; + console.log('✓ Login successful, got token'); + + // Test thumbnail endpoint + console.log('\n2. Testing thumbnail endpoint for photo 1688...'); + try { + const thumbResponse = await axios.get('http://localhost:3000/api/admin/events/12/thumbnail/1688', { + headers: { + Authorization: `Bearer ${token}` + }, + responseType: 'arraybuffer' + }); + + console.log('✓ Thumbnail request successful'); + console.log(' Response headers:', thumbResponse.headers); + console.log(' Data size:', thumbResponse.data.length, 'bytes'); + } catch (error) { + console.error('✗ Thumbnail request failed:', error.response?.status, error.response?.data?.toString()); + } + + // Test from frontend proxy port + console.log('\n3. Testing through nginx proxy (port 3001)...'); + try { + const proxyResponse = await axios.get('http://localhost:3001/api/admin/events/12/thumbnail/1688', { + headers: { + Authorization: `Bearer ${token}`, + Origin: 'http://localhost:3005' + }, + responseType: 'arraybuffer' + }); + + console.log('✓ Proxy request successful'); + console.log(' Response headers:', proxyResponse.headers); + console.log(' Data size:', proxyResponse.data.length, 'bytes'); + } catch (error) { + console.error('✗ Proxy request failed:', error.response?.status, error.response?.data?.toString()); + } + + } catch (error) { + console.error('Error:', error.message); + } +} + +testAdminPhotoEndpoint(); \ No newline at end of file diff --git a/backend/scripts/test-upload.js b/backend/scripts/test-upload.js new file mode 100644 index 0000000..2e5011a --- /dev/null +++ b/backend/scripts/test-upload.js @@ -0,0 +1,59 @@ +const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); +const path = require('path'); + +async function testUpload() { + try { + // First login + console.log('1. Logging in as admin...'); + const loginResponse = await axios.post('http://localhost:3000/api/admin/auth/login', { + username: 'admin', + password: 'admin123' + }); + + const token = loginResponse.data.token; + console.log('✓ Login successful'); + + // Create a test image file + const testImagePath = path.join(__dirname, 'test-image.png'); + const imageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); + fs.writeFileSync(testImagePath, imageBuffer); + + // Test upload + console.log('\n2. Testing upload with category_id=7...'); + const form = new FormData(); + form.append('photos', fs.createReadStream(testImagePath), 'test-image.png'); + form.append('category_id', '7'); + + console.log('Form data headers:', form.getHeaders()); + + try { + const uploadResponse = await axios.post( + 'http://localhost:3000/api/admin/events/12/upload', + form, + { + headers: { + ...form.getHeaders(), + 'Authorization': `Bearer ${token}` + } + } + ); + + console.log('✓ Upload successful:', uploadResponse.data); + } catch (error) { + console.error('✗ Upload failed:', error.response?.status, error.response?.data); + if (error.response?.data) { + console.error('Error details:', JSON.stringify(error.response.data, null, 2)); + } + } + + // Clean up + fs.unlinkSync(testImagePath); + + } catch (error) { + console.error('Error:', error.message); + } +} + +testUpload(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 127cdba..8d9cf67 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,8 @@ const path = require('path'); const { initializeDatabase } = require('./src/database/db'); const { startFileWatcher } = require('./src/services/fileWatcher'); const { startExpirationChecker } = require('./src/services/expirationChecker'); +const { maintenanceMiddleware } = require('./src/middleware/maintenance'); +const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); const logger = require('./src/utils/logger'); // Import routes @@ -50,7 +52,7 @@ app.use(cors(corsOptions)); // Rate limiting with admin bypass const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs + max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development skip: (req) => { // Skip rate limiting for authenticated admin users if (req.path.startsWith('/api/admin/') && req.headers.authorization) { @@ -62,6 +64,10 @@ const limiter = rateLimit({ return false; } } + // Also skip rate limiting for public settings endpoint in development + if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') { + return true; + } return false; } }); @@ -79,6 +85,12 @@ app.use('/api/auth', authLimiter); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +// Maintenance mode middleware - add after body parsing but before routes +app.use(maintenanceMiddleware); + +// Session timeout middleware for admin routes +app.use('/api/admin', sessionTimeoutMiddleware); + // Middleware to set CORS headers for static files const setCorsHeaders = (req, res, next) => { res.header('Access-Control-Allow-Origin', req.headers.origin || '*'); diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 750362d..00f4b38 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -161,7 +161,16 @@ async function initializeDatabase() { table.json('metadata'); // Additional data about the activity table.integer('event_id').references('id').inTable('events'); table.datetime('created_at').defaultTo(db.fn.now()); + table.datetime('read_at').nullable(); }); + } else { + // Check if read_at column exists + const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at'); + if (!hasReadAt) { + await db.schema.table('activity_logs', (table) => { + table.datetime('read_at').nullable(); + }); + } } } diff --git a/backend/src/middleware/maintenance.js b/backend/src/middleware/maintenance.js new file mode 100644 index 0000000..c0112ec --- /dev/null +++ b/backend/src/middleware/maintenance.js @@ -0,0 +1,73 @@ +const { db } = require('../database/db'); + +// Cache maintenance mode status to avoid DB queries on every request +let maintenanceMode = false; +let lastCheck = 0; +const CACHE_DURATION = 60000; // 1 minute + +async function checkMaintenanceMode() { + const now = Date.now(); + + // Use cached value if recent + if (now - lastCheck < CACHE_DURATION) { + return maintenanceMode; + } + + try { + const setting = await db('app_settings') + .where('setting_key', 'general_maintenance_mode') + .where('setting_type', 'general') + .first(); + + maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false; + lastCheck = now; + + return maintenanceMode; + } catch (error) { + console.error('Error checking maintenance mode:', error); + return false; + } +} + +// Middleware to enforce maintenance mode +async function maintenanceMiddleware(req, res, next) { + // Skip maintenance check for certain paths + const skipPaths = [ + '/api/admin/login', + '/api/admin/auth/login', + '/api/public/settings', + '/health' + ]; + + // Allow static assets (uploads, favicons, logos) + const isStaticAsset = req.path.startsWith('/uploads/') || + req.path.startsWith('/favicons/') || + req.path.startsWith('/logos/'); + + // Allow admin routes if admin is authenticated + const isAdminRoute = req.path.startsWith('/api/admin'); + const hasAdminAuth = req.headers.authorization?.startsWith('Bearer '); + + if (skipPaths.includes(req.path) || isStaticAsset || (isAdminRoute && hasAdminAuth)) { + return next(); + } + + const inMaintenance = await checkMaintenanceMode(); + + if (inMaintenance && !isAdminRoute) { + return res.status(503).json({ + error: 'Service Unavailable', + message: 'The system is currently undergoing maintenance. Please try again later.', + maintenance: true + }); + } + + next(); +} + +// Function to clear cache when settings change +function clearMaintenanceCache() { + lastCheck = 0; +} + +module.exports = { maintenanceMiddleware, clearMaintenanceCache }; \ No newline at end of file diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 17058c8..b729432 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -4,7 +4,17 @@ const { db } = require('../database/db'); async function photoAuth(req, res, next) { try { - const eventSlug = req.path.split('/')[1]; + // Extract event slug from the path + let eventSlug; + + // 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 + eventSlug = null; + } else { + // For regular photos, the slug is the first part of the path + eventSlug = req.path.split('/')[1]; + } // First check for JWT token (from gallery access) const authHeader = req.headers.authorization; @@ -13,17 +23,32 @@ async function photoAuth(req, res, next) { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); - // Check if it's a gallery token for this event - if (decoded.type === 'gallery' && decoded.eventSlug === eventSlug) { - const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); - if (event) { - req.event = event; - return next(); + // Check if it's a gallery token + if (decoded.type === 'gallery') { + // For thumbnails, we accept any valid gallery token + if (!eventSlug) { + const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first(); + if (event) { + req.event = event; + return next(); + } + } + // For regular photos, check if token matches the event + else if (decoded.eventSlug === eventSlug) { + const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); + if (event) { + req.event = event; + return next(); + } } } // Check if it's an admin token (admins can view all photos) if (decoded.type === 'admin') { + if (!eventSlug) { + // For thumbnails with admin token, allow access + return next(); + } const event = await db('events').where({ slug: eventSlug }).first(); if (event) { req.event = event; @@ -42,6 +67,11 @@ async function photoAuth(req, res, next) { return res.status(401).json({ error: 'Authentication required' }); } + // If no eventSlug (thumbnails), we require JWT token + if (!eventSlug) { + return res.status(401).json({ error: 'Authentication required for thumbnails' }); + } + const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js new file mode 100644 index 0000000..362726c --- /dev/null +++ b/backend/src/middleware/sessionTimeout.js @@ -0,0 +1,122 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); + +// In-memory session tracking (in production, use Redis) +const sessions = new Map(); + +// Default session timeout (60 minutes) +const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000; + +// Clean up expired sessions every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [token, lastActivity] of sessions.entries()) { + if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) { + sessions.delete(token); + } + } +}, 5 * 60 * 1000); + +async function getSessionTimeout() { + try { + const setting = await db('app_settings') + .where('setting_key', 'security_session_timeout_minutes') + .first(); + + if (setting && setting.setting_value) { + const minutes = parseInt(JSON.parse(setting.setting_value)); + return minutes * 60 * 1000; // Convert to milliseconds + } + } catch (error) { + console.error('Error getting session timeout:', error); + } + + return DEFAULT_SESSION_TIMEOUT; +} + +async function sessionTimeoutMiddleware(req, res, next) { + // Skip for non-authenticated routes + if (!req.headers.authorization) { + return next(); + } + + const token = req.headers.authorization.split(' ')[1]; + if (!token) { + return next(); + } + + try { + // Verify token is valid + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if this is an admin token + if (!decoded.id) { + return next(); + } + + const now = Date.now(); + const lastActivity = sessions.get(token); + const timeout = await getSessionTimeout(); + + // If session exists, check if it's expired + if (lastActivity) { + if (now - lastActivity > timeout) { + sessions.delete(token); + return res.status(401).json({ + error: 'Session expired', + code: 'SESSION_TIMEOUT' + }); + } + } + + // Update last activity + sessions.set(token, now); + + // Clean up old token if user has a new one + // This prevents memory leaks from token renewals + const userId = decoded.id; + for (const [oldToken, _] of sessions.entries()) { + if (oldToken !== token) { + try { + const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET); + if (oldDecoded.id === userId) { + sessions.delete(oldToken); + } + } catch (e) { + // Token is invalid, remove it + sessions.delete(oldToken); + } + } + } + + next(); + } catch (error) { + // Token is invalid + next(); + } +} + +// Function to end a session +function endSession(token) { + sessions.delete(token); +} + +// Function to get active sessions count +function getActiveSessions() { + const now = Date.now(); + let active = 0; + + for (const [_, lastActivity] of sessions.entries()) { + if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) { + active++; + } + } + + return active; +} + +module.exports = { + sessionTimeoutMiddleware, + endSession, + getActiveSessions +}; \ No newline at end of file diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c00a940..7bfa3cb 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -10,6 +10,7 @@ const eventsRoutes = require('./adminEvents'); const photosRoutes = require('./adminPhotos'); const categoriesRoutes = require('./adminCategories'); const cmsRoutes = require('./adminCMS'); +const notificationsRoutes = require('./adminNotifications'); // Mount sub-routers router.use('/dashboard', dashboardRoutes); @@ -20,5 +21,6 @@ router.use('/events', eventsRoutes); router.use('/events', photosRoutes); router.use('/categories', categoriesRoutes); router.use('/cms', cmsRoutes); +router.use('/notifications', notificationsRoutes); module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index a5f4f51..53e1664 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -4,6 +4,7 @@ const fs = require('fs').promises; const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const archiver = require('archiver'); +const AdmZip = require('adm-zip'); const router = express.Router(); // Get all archived events @@ -34,11 +35,13 @@ router.get('/', adminAuth, async (req, res) => { .offset(offset); // Check if archive files exist and get their sizes + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => { let archiveFileSize = 0; if (archive.archive_path) { try { - const stats = await fs.stat(archive.archive_path); + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); archiveFileSize = stats.size; } catch (error) { console.error(`Archive file not found: ${archive.archive_path}`); @@ -52,8 +55,8 @@ router.get('/', adminAuth, async (req, res) => { eventDate: archive.event_date, eventType: archive.event_type, hostEmail: archive.host_email, - archivedAt: archive.archived_at, - expiresAt: archive.expires_at, + archivedAt: archive.archived_at ? new Date(archive.archived_at).toISOString() : null, + expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null, photoCount: archive.photo_count || 0, originalSize: archive.total_size || 0, archiveSize: archiveFileSize, @@ -97,7 +100,9 @@ router.get('/:id', adminAuth, async (req, res) => { let archiveFileInfo = null; if (archive.archive_path) { try { - const stats = await fs.stat(archive.archive_path); + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); archiveFileInfo = { size: stats.size, createdAt: stats.birthtime, @@ -142,12 +147,123 @@ router.post('/:id/restore', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Archive not found' }); } - // Check if archive directory exists - const archiveDir = path.dirname(archive.archive_path); - const extractedDir = archive.archive_path.replace('.zip', ''); - - // TODO: Implement actual extraction logic - // For now, just update the database + // Check if archive file exists + if (!archive.archive_path) { + return res.status(400).json({ error: 'No archive file found' }); + } + + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + + try { + await fs.access(fullArchivePath); + } catch (error) { + return res.status(404).json({ error: 'Archive file not found on disk' }); + } + + // Extract the archive + try { + const zip = new AdmZip(fullArchivePath); + const eventsDir = path.join(storagePath, 'events/active'); + const eventDir = path.join(eventsDir, archive.slug); + + // Create event directory if it doesn't exist + await fs.mkdir(eventDir, { recursive: true }); + + // Log ZIP contents for debugging + console.log(`Extracting archive to: ${eventDir}`); + const entries = zip.getEntries(); + console.log(`Archive contains ${entries.length} entries`); + + // Extract files to the event directory + zip.extractAllTo(eventDir, true); + + // Get list of extracted files to update database + const extractedPhotos = []; + + // First, collect all category information from the ZIP structure + const categoriesMap = new Map(); + + for (const entry of entries) { + if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { + const filename = path.basename(entry.entryName); + const dirPath = path.dirname(entry.entryName); + const actualFilePath = path.join(eventDir, entry.entryName); + + try { + // Check if file was extracted successfully + const stats = await fs.stat(actualFilePath); + + // Determine category from directory structure + let categoryId = null; + if (dirPath && dirPath !== '.') { + // Get the first level directory as category + const categoryName = dirPath.split(path.sep)[0]; + + if (!categoriesMap.has(categoryName)) { + // Check if this category exists in the database + const existingCategory = await db('photo_categories') + .where('event_id', archive.id) + .where('name', categoryName) + .first(); + + if (existingCategory) { + categoriesMap.set(categoryName, existingCategory.id); + } else { + // Create the category if it doesn't exist + const [newCategoryId] = await db('photo_categories').insert({ + event_id: archive.id, + name: categoryName, + slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'), + created_at: new Date() + }); + categoriesMap.set(categoryName, newCategoryId); + } + } + + categoryId = categoriesMap.get(categoryName); + } + + // Check if photo already exists in database + const existingPhoto = await db('photos') + .where('event_id', archive.id) + .where('filename', filename) + .first(); + + if (!existingPhoto) { + // Store relative path from storage root + const relativePath = path.relative(storagePath, actualFilePath); + extractedPhotos.push({ + event_id: archive.id, + filename: filename, + original_filename: filename, + path: relativePath, + thumbnail_path: null, // Will be regenerated by thumbnail service + type: path.extname(filename).substring(1).toLowerCase(), + size_bytes: stats.size, + category_id: categoryId, + uploaded_at: new Date() + }); + } + } catch (statError) { + console.error(`Failed to stat file: ${actualFilePath}`); + console.error(`Entry name was: ${entry.entryName}`); + console.error(`Error:`, statError.message); + // Skip this file if we can't stat it + continue; + } + } + } + + // Insert new photos if any + if (extractedPhotos.length > 0) { + await db('photos').insert(extractedPhotos); + } + + } catch (extractError) { + console.error('Archive extraction error:', extractError); + return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message }); + } // Update event status await db('events') @@ -164,8 +280,8 @@ router.post('/:id/restore', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'archive_restored', actor_type: 'admin', - actor_id: req.user.id, - actor_name: req.user.username, + actor_id: req.admin.id, + actor_name: req.admin.username, event_id: archive.id, metadata: JSON.stringify({ event_name: archive.event_name }) }); @@ -194,8 +310,11 @@ router.get('/:id/download', adminAuth, async (req, res) => { } // Check if file exists + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + try { - await fs.access(archive.archive_path); + await fs.access(fullArchivePath); } catch (error) { return res.status(404).json({ error: 'Archive file not found on disk' }); } @@ -205,15 +324,15 @@ router.get('/:id/download', adminAuth, async (req, res) => { res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`); // Stream the file - const fileStream = require('fs').createReadStream(archive.archive_path); + const fileStream = require('fs').createReadStream(fullArchivePath); fileStream.pipe(res); // Log download await db('activity_logs').insert({ activity_type: 'archive_downloaded', actor_type: 'admin', - actor_id: req.user.id, - actor_name: req.user.username, + actor_id: req.admin.id, + actor_name: req.admin.username, event_id: archive.id, metadata: JSON.stringify({ event_name: archive.event_name }) }); @@ -251,8 +370,8 @@ router.delete('/:id', adminAuth, async (req, res) => { await db('activity_logs').insert({ activity_type: 'archive_deleted', actor_type: 'admin', - actor_id: req.user.id, - actor_name: req.user.username, + actor_id: req.admin.id, + actor_name: req.admin.username, metadata: JSON.stringify({ event_name: archive.event_name, archived_date: archive.archived_at diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index a48dbeb..3c65556 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -1,8 +1,9 @@ const express = require('express'); const bcrypt = require('bcrypt'); const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); +const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { endSession } = require('../middleware/sessionTimeout'); const router = express.Router(); // Change password @@ -18,7 +19,7 @@ router.post('/change-password', [ } const { currentPassword, newPassword } = req.body; - const userId = req.user.id; + const userId = req.admin.id; // Changed from req.user.id to req.admin.id // Get user from database const user = await db('admin_users') @@ -46,6 +47,13 @@ router.post('/change-password', [ updated_at: new Date() }); + // Log activity + await logActivity('password_changed', + { admin_id: userId }, + null, + { type: 'admin', id: userId, name: user.username } + ); + res.json({ message: 'Password changed successfully' }); } catch (error) { console.error('Password change error:', error); @@ -53,4 +61,28 @@ router.post('/change-password', [ } }); +// Logout +router.post('/logout', adminAuth, async (req, res) => { + try { + // Get token from header + const token = req.headers.authorization?.split(' ')[1]; + if (token) { + // End the session + endSession(token); + } + + // Log activity + await logActivity('admin_logout', + { admin_id: req.admin.id }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ error: 'Failed to logout' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 37b4a48..be09cf4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -7,6 +7,7 @@ const bcrypt = require('bcrypt'); const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); +const { archiveEvent } = require('../services/archiveService'); // Create new event router.post('/', adminAuth, [ @@ -360,6 +361,67 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => { } }); +// Reset event password +router.post('/:id/reset-password', adminAuth, async (req, res) => { + try { + const { id } = req.params; + const { sendEmail = true } = req.body; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Cannot reset password for archived event' }); + } + + // Generate new password + const { generatePassword } = require('../utils/passwordGenerator'); + const newPassword = generatePassword(); + const passwordHash = await bcrypt.hash(newPassword, 10); + + // Update event with new password + await db('events') + .where('id', id) + .update({ + password_hash: passwordHash, + updated_at: new Date() + }); + + // Log activity + await logActivity('password_reset', + { eventName: event.event_name, emailSent: sendEmail }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Queue email notification if requested + if (sendEmail) { + await db('email_queue').insert({ + event_id: id, + recipient_email: event.host_email, + email_type: 'password_reset', + email_data: JSON.stringify({ + event_name: event.event_name, + share_link: event.share_link, + new_password: newPassword, + reset_by: req.admin.username + }) + }); + } + + res.json({ + message: 'Password reset successfully', + newPassword: newPassword, + emailSent: sendEmail + }); + } catch (error) { + console.error('Error resetting password:', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + // Archive event router.post('/:id/archive', adminAuth, async (req, res) => { try { @@ -374,14 +436,8 @@ router.post('/:id/archive', adminAuth, async (req, res) => { return res.status(400).json({ error: 'Event is already archived' }); } - await db('events') - .where('id', id) - .update({ - is_archived: true, - is_active: false, - archived_at: new Date(), - updated_at: new Date() - }); + // Use the archive service to create ZIP archive + await archiveEvent(event); // Log activity await logActivity('event_archived', @@ -397,4 +453,83 @@ router.post('/:id/archive', adminAuth, async (req, res) => { } }); +// Bulk archive events +router.post('/bulk-archive', adminAuth, [ + body('eventIds').isArray().withMessage('eventIds must be an array'), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { eventIds } = req.body; + + if (eventIds.length === 0) { + return res.status(400).json({ error: 'No events selected for archiving' }); + } + + // Get all events to archive + const events = await db('events') + .whereIn('id', eventIds) + .where('is_archived', false); + + if (events.length === 0) { + return res.status(400).json({ error: 'No valid events found to archive' }); + } + + const results = { + successful: [], + failed: [] + }; + + // Process each event + for (const event of events) { + try { + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name, bulkOperation: true }, + event.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + results.successful.push({ + id: event.id, + name: event.event_name + }); + } catch (error) { + console.error(`Failed to archive event ${event.id}:`, error); + results.failed.push({ + id: event.id, + name: event.event_name, + error: error.message + }); + } + } + + // Log bulk archive activity + await logActivity('bulk_archive_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + console.error('Error in bulk archive:', error); + res.status(500).json({ error: 'Failed to perform bulk archive' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js new file mode 100644 index 0000000..d0276f8 --- /dev/null +++ b/backend/src/routes/adminNotifications.js @@ -0,0 +1,109 @@ +const express = require('express'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const router = express.Router(); + +// Get notifications (unread activity logs) +router.get('/', adminAuth, async (req, res) => { + try { + const { limit = 20, includeRead = false } = req.query; + + let query = db('activity_logs') + .select( + 'activity_logs.*', + 'events.event_name' + ) + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .orderBy('activity_logs.created_at', 'desc') + .limit(parseInt(limit)); + + // By default, only show unread notifications + if (includeRead !== 'true') { + query = query.whereNull('activity_logs.read_at'); + } + + const notifications = await query; + + // Format notifications + const formattedNotifications = notifications.map(notification => ({ + id: notification.id, + type: notification.activity_type, + actorType: notification.actor_type, + actorName: notification.actor_name, + eventName: notification.event_name, + eventId: notification.event_id, + metadata: notification.metadata ? JSON.parse(notification.metadata) : {}, + createdAt: notification.created_at, + readAt: notification.read_at, + isRead: !!notification.read_at + })); + + // Get unread count + const unreadCount = await db('activity_logs') + .whereNull('read_at') + .count('id as count') + .first(); + + res.json({ + notifications: formattedNotifications, + unreadCount: unreadCount.count || 0 + }); + } catch (error) { + console.error('Notifications fetch error:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// Mark notification as read +router.put('/:id/read', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + await db('activity_logs') + .where('id', id) + .update({ + read_at: new Date() + }); + + res.json({ message: 'Notification marked as read' }); + } catch (error) { + console.error('Mark notification read error:', error); + res.status(500).json({ error: 'Failed to mark notification as read' }); + } +}); + +// Mark all notifications as read +router.put('/read-all', adminAuth, async (req, res) => { + try { + await db('activity_logs') + .whereNull('read_at') + .update({ + read_at: new Date() + }); + + res.json({ message: 'All notifications marked as read' }); + } catch (error) { + console.error('Mark all notifications read error:', error); + res.status(500).json({ error: 'Failed to mark all notifications as read' }); + } +}); + +// Delete old notifications (older than 30 days and read) +router.delete('/clear-old', adminAuth, async (req, res) => { + try { + const deletedCount = await db('activity_logs') + .whereNotNull('read_at') + .where('created_at', '<', db.raw("datetime('now', '-30 days')")) + .delete(); + + res.json({ + message: 'Old notifications cleared', + deletedCount + }); + } catch (error) { + console.error('Clear old notifications error:', error); + res.status(500).json({ error: 'Failed to clear old notifications' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 937a0fd..4236851 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -14,12 +14,14 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '. // Configure multer for file uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { + console.log('Multer destination called for file:', file.originalname); const { eventId } = req.params; try { // Get event details const event = await db('events').where({ id: eventId }).first(); if (!event) { + console.error('Event not found in multer destination:', eventId); return cb(new Error('Event not found')); } @@ -28,21 +30,26 @@ const storage = multer.diskStorage({ // Create destination path - now just event folder, no type subfolder const destPath = path.join(getStoragePath(), 'events/active', event.slug); + console.log('Destination path:', destPath); // Ensure directory exists await fs.mkdir(destPath, { recursive: true }); cb(null, destPath); } catch (error) { + console.error('Error in multer destination:', error); cb(error); } }, filename: async (req, file, cb) => { + console.log('Multer filename called for file:', file.originalname); try { // Use temporary filename for now, will rename after getting category info const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`; + console.log('Temp filename:', tempName); cb(null, tempName); } catch (error) { + console.error('Error in multer filename:', error); cb(error); } } @@ -68,31 +75,51 @@ const upload = multer({ }); // Upload photos for an event -router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => { +router.post('/:eventId/upload', adminAuth, (req, res, next) => { + upload.array('photos', 20)(req, res, (err) => { + if (err) { + console.error('Multer error:', err); + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); + } + return res.status(400).json({ error: `Upload error: ${err.message}` }); + } + return res.status(400).json({ error: err.message || 'Upload failed' }); + } + next(); + }); +}, async (req, res) => { try { const { eventId } = req.params; const { category_id } = req.body; - console.log('Upload request received:'); + console.log('Upload request received for event:', eventId); console.log('Body:', req.body); console.log('Files:', req.files ? req.files.length : 'none'); - console.log('Headers:', req.headers); + console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype }))); + console.log('Category ID received:', category_id); // Verify event exists and admin has access const event = await db('events').where({ id: eventId }).first(); if (!event) { + console.error('Event not found:', eventId); return res.status(404).json({ error: 'Event not found' }); } if (!req.files || req.files.length === 0) { - console.log('No files in request. req.files:', req.files); + console.error('No files in request. req.files:', req.files); + console.error('Request body keys:', Object.keys(req.body)); return res.status(400).json({ error: 'No files uploaded' }); } + // Parse category_id to number if provided + const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; + // Get category details if provided let category = null; - if (category_id) { - category = await db('photo_categories').where({ id: category_id }).first(); + if (parsedCategoryId) { + category = await db('photo_categories').where({ id: parsedCategoryId }).first(); if (!category) { return res.status(400).json({ error: 'Invalid category' }); } @@ -112,7 +139,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re if (category) { // Lock the category row and get current counter const categoryData = await trx('photo_categories') - .where({ id: category_id }) + .where({ id: parsedCategoryId }) .forUpdate() .first(); @@ -120,7 +147,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re // Update counter await trx('photo_categories') - .where({ id: category_id }) + .where({ id: parsedCategoryId }) .update({ photo_counter: counter }); } else { // For uncategorized photos, count existing uncategorized photos @@ -165,7 +192,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re filename: file.filename, path: relativePath, thumbnail_path: relativeThumbPath, - category_id: category_id || null, + category_id: parsedCategoryId || null, type: 'individual', // Keep for backwards compatibility size_bytes: file.size }); @@ -177,7 +204,7 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re id: photoId, filename: file.filename, size: file.size, - category_id: category_id || null + category_id: parsedCategoryId || null }); } catch (error) { console.error(`Error processing file ${file.filename}:`, error); @@ -255,11 +282,171 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { } }); +// Update a photo (e.g., change category) +router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + const { category_id } = req.body; + + // Verify photo belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Update photo + await db('photos') + .where({ id: photoId }) + .update({ category_id: category_id || null }); + + res.json({ message: 'Photo updated successfully' }); + } catch (error) { + console.error('Error updating photo:', error); + res.status(500).json({ error: 'Failed to update photo' }); + } +}); + +// Bulk delete photos +router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { photoIds } = req.body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return res.status(400).json({ error: 'Invalid photo IDs' }); + } + + // Get all photos to delete + const photos = await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId); + + if (photos.length === 0) { + return res.status(404).json({ error: 'No photos found' }); + } + + // Delete physical files + const storagePath = getStoragePath(); + const event = await db('events').where({ id: eventId }).first(); + + for (const photo of photos) { + // Delete photo file + const photoPath = path.join(storagePath, 'events/active', photo.path); + try { + await fs.unlink(photoPath); + } catch (error) { + console.error('Error deleting photo file:', error); + } + + // Delete thumbnail + if (photo.thumbnail_path) { + const thumbPath = path.join(storagePath, photo.thumbnail_path); + try { + await fs.unlink(thumbPath); + } catch (error) { + console.error('Error deleting thumbnail:', error); + } + } + } + + // Delete from database + await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .delete(); + + // Log activity + await logActivity('photos_bulk_deleted', + { count: photos.length, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: `${photos.length} photos deleted successfully` }); + } catch (error) { + console.error('Error bulk deleting photos:', error); + res.status(500).json({ error: 'Failed to delete photos' }); + } +}); + +// Bulk update photos +router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { photoIds, updates } = req.body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return res.status(400).json({ error: 'Invalid photo IDs' }); + } + + // Verify all photos belong to the event + const photoCount = await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .count('id as count') + .first(); + + if (photoCount.count !== photoIds.length) { + return res.status(400).json({ error: 'Some photos do not belong to this event' }); + } + + // Update photos + const updateData = {}; + if (updates.category_id !== undefined) { + updateData.category_id = updates.category_id || null; + } + + await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .update(updateData); + + res.json({ message: `${photoIds.length} photos updated successfully` }); + } catch (error) { + console.error('Error bulk updating photos:', error); + res.status(500).json({ error: 'Failed to update photos' }); + } +}); + +// Download a photo +router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, 'events/active', photo.path); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + return res.status(404).json({ error: 'Photo file not found' }); + } + + // Send file + res.download(filePath, photo.filename); + } catch (error) { + console.error('Error downloading photo:', error); + res.status(500).json({ error: 'Failed to download photo' }); + } +}); + // Get all photos for an event router.get('/:eventId/photos', adminAuth, async (req, res) => { try { const { eventId } = req.params; - const { category_id, type } = req.query; + const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; let query = db('photos') .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') @@ -270,8 +457,13 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { 'photo_categories.slug as category_slug' ); - if (category_id) { - query = query.where({ 'photos.category_id': category_id }); + // Filter by category (including uncategorized) + if (category_id !== undefined) { + if (category_id === '' || category_id === '0') { + query = query.whereNull('photos.category_id'); + } else { + query = query.where({ 'photos.category_id': category_id }); + } } // Keep type filter for backwards compatibility @@ -279,14 +471,27 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { query = query.where({ 'photos.type': type }); } - const photos = await query.orderBy('photos.uploaded_at', 'desc'); + // Search by filename + if (search) { + query = query.where('photos.filename', 'like', `%${search}%`); + } + + // Sorting + let orderByColumn = 'photos.uploaded_at'; + if (sort === 'name') { + orderByColumn = 'photos.filename'; + } else if (sort === 'size') { + orderByColumn = 'photos.size_bytes'; + } + + const photos = await query.orderBy(orderByColumn, order); res.json({ photos: photos.map(photo => ({ id: photo.id, filename: photo.filename, - url: `/photos/${photo.path}`, - thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null, + url: `/api/admin/events/${eventId}/photo/${photo.id}`, + thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null, type: photo.type, category_id: photo.category_id, category_name: photo.category_name, @@ -301,4 +506,82 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { } }); +// Serve photo with admin authentication +router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, 'events/active', photo.path); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + return res.status(404).json({ error: 'Photo file not found' }); + } + + // Set appropriate headers + res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Send file (sendFile requires absolute path) + res.sendFile(path.resolve(filePath)); + } catch (error) { + console.error('Error serving photo:', error); + res.status(500).json({ error: 'Failed to serve photo' }); + } +}); + +// Serve thumbnail with admin authentication +router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo || !photo.thumbnail_path) { + console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`); + return res.status(404).json({ error: 'Thumbnail not found' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, photo.thumbnail_path); + + console.log(`Attempting to serve thumbnail: ${filePath}`); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + console.error(`Thumbnail file not found: ${filePath}`, error); + return res.status(404).json({ error: 'Thumbnail file not found' }); + } + + // Set appropriate headers + res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Send file (sendFile requires absolute path) + res.sendFile(path.resolve(filePath)); + } catch (error) { + console.error('Error serving thumbnail:', error); + console.error('Photo ID:', req.params.photoId); + console.error('Event ID:', req.params.eventId); + res.status(500).json({ error: 'Failed to serve thumbnail' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 7670fec..4c056bc 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -5,6 +5,7 @@ const fs = require('fs').promises; const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { clearMaintenanceCache } = require('../middleware/maintenance'); const router = express.Router(); // Configure multer for logo uploads @@ -357,6 +358,11 @@ router.put('/general', adminAuth, async (req, res) => { updated_at: new Date() }); } + + // Clear maintenance mode cache if it was updated + if ('general_maintenance_mode' in settings) { + clearMaintenanceCache(); + } // Log activity await db('activity_logs').insert({ diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index c68b127..90165dd 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { body, validationResult } = require('express-validator'); const { db } = require('../database/db'); +const { verifyRecaptcha } = require('../services/recaptcha'); const router = express.Router(); // Admin login @@ -16,7 +17,13 @@ router.post('/admin/login', [ return res.status(400).json({ errors: errors.array() }); } - const { username, password } = req.body; + const { username, password, recaptchaToken } = req.body; + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } const admin = await db('admin_users') .where({ username }) @@ -60,9 +67,15 @@ router.post('/gallery/verify', [ return res.status(400).json({ errors: errors.array() }); } - const { slug, password } = req.body; + const { slug, password, recaptchaToken } = req.body; - const event = await db('events').where({ slug, is_active: true }).first(); + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + const event = await db('events').where({ slug, is_active: true, is_archived: false }).first(); if (!event) { return res.status(404).json({ error: 'Gallery not found or expired' }); } diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index b559ad2..7d9226c 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -18,7 +18,9 @@ async function verifyGalleryAccess(req, res, next) { } const decoded = jwt.verify(token, process.env.JWT_SECRET); - const event = await db('events').where({ id: decoded.eventId, is_active: true }).first(); + const event = await db('events') + .where({ id: decoded.eventId, is_active: true, is_archived: false }) + .first(); if (!event) { return res.status(404).json({ error: 'Gallery not found or expired' }); @@ -38,7 +40,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => { const { slug, token } = req.params; const event = await db('events') - .where({ slug, is_active: true }) + .where({ slug, is_active: true, is_archived: false }) .select('id', 'share_link') .first(); @@ -67,13 +69,18 @@ router.get('/:slug/info', async (req, res) => { const event = await db('events') .where({ slug }) - .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'share_link') + .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link') .first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } + // Check if event is archived + if (event.is_archived) { + return res.status(404).json({ error: 'Gallery has been archived and is no longer available' }); + } + // If token provided, verify it matches the share link if (token) { const expectedToken = event.share_link.split('/').pop(); @@ -147,7 +154,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { id: photo.id, filename: photo.filename, url: `/photos/${photo.path}`, - thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null, + thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null, type: photo.type, category_id: photo.category_id, category_name: photo.category_name, @@ -187,7 +194,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => photo_id: photoId }); - const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); + const filePath = path.join(getStoragePath(), 'events/active', photo.path); // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); @@ -236,7 +243,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { // Add photos to archive for (const photo of photos) { - const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); + const filePath = path.join(getStoragePath(), 'events/active', photo.path); if (watermarkSettings && watermarkSettings.enabled) { // Apply watermark diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 8df6be8..d278b74 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -5,9 +5,9 @@ const router = express.Router(); // Get public settings (branding and theme) router.get('/', async (req, res) => { try { - // Fetch branding, theme, and general settings + // Fetch branding, theme, general, and select security settings const settings = await db('app_settings') - .whereIn('setting_type', ['branding', 'theme', 'general']) + .whereIn('setting_type', ['branding', 'theme', 'general', 'security']) .select('setting_key', 'setting_value'); // Convert to object format @@ -37,7 +37,11 @@ router.get('/', async (req, res) => { branding_favicon_url: settingsObject.branding_favicon_url || '', branding_logo_url: settingsObject.branding_logo_url || '', theme_config: settingsObject.theme_config || null, - default_language: settingsObject.general_default_language || 'en' + default_language: settingsObject.general_default_language || 'en', + enable_analytics: settingsObject.general_enable_analytics !== false, + enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true', + recaptcha_site_key: settingsObject.security_recaptcha_site_key || null, + maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true' }; res.json(publicSettings); diff --git a/backend/src/services/recaptcha.js b/backend/src/services/recaptcha.js new file mode 100644 index 0000000..05d49f5 --- /dev/null +++ b/backend/src/services/recaptcha.js @@ -0,0 +1,58 @@ +const axios = require('axios'); +const { db } = require('../database/db'); + +async function verifyRecaptcha(token) { + // Check if reCAPTCHA is enabled + const settings = await db('app_settings') + .whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key']) + .select('setting_key', 'setting_value'); + + const settingsMap = {}; + settings.forEach(setting => { + try { + settingsMap[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + settingsMap[setting.setting_key] = setting.setting_value; + } + }); + + const isEnabled = settingsMap.security_enable_recaptcha === true || + settingsMap.security_enable_recaptcha === 'true'; + const secretKey = settingsMap.security_recaptcha_secret_key; + + // If reCAPTCHA is not enabled, always return true + if (!isEnabled) { + return true; + } + + // If enabled but no token provided, fail + if (!token) { + return false; + } + + // If no secret key configured, log warning but pass + if (!secretKey) { + console.warn('reCAPTCHA enabled but no secret key configured'); + return true; + } + + try { + const response = await axios.post( + 'https://www.google.com/recaptcha/api/siteverify', + null, + { + params: { + secret: secretKey, + response: token + } + } + ); + + return response.data.success === true; + } catch (error) { + console.error('reCAPTCHA verification error:', error); + return false; + } +} + +module.exports = { verifyRecaptcha }; \ No newline at end of file diff --git a/backend/src/utils/passwordGenerator.js b/backend/src/utils/passwordGenerator.js new file mode 100644 index 0000000..68d2214 --- /dev/null +++ b/backend/src/utils/passwordGenerator.js @@ -0,0 +1,39 @@ +/** + * Generate a secure random password + * @param {number} length - Password length (default 12) + * @returns {string} Generated password + */ +function generatePassword(length = 12) { + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const numbers = '0123456789'; + const symbols = '!@#$%&*'; + + // Ensure at least one character from each set + const requiredChars = [ + lowercase[Math.floor(Math.random() * lowercase.length)], + uppercase[Math.floor(Math.random() * uppercase.length)], + numbers[Math.floor(Math.random() * numbers.length)], + symbols[Math.floor(Math.random() * symbols.length)] + ]; + + // Fill the rest with random characters from all sets + const allChars = lowercase + uppercase + numbers + symbols; + const remainingLength = length - requiredChars.length; + + let password = ''; + for (let i = 0; i < remainingLength; i++) { + password += allChars[Math.floor(Math.random() * allChars.length)]; + } + + // Combine and shuffle + const passwordArray = [...requiredChars, ...password]; + for (let i = passwordArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [passwordArray[i], passwordArray[j]] = [passwordArray[j], passwordArray[i]]; + } + + return passwordArray.join(''); +} + +module.exports = { generatePassword }; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0451ba8..e40f611 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,15 +34,14 @@ services: build: context: ./frontend dockerfile: Dockerfile - target: builder ports: - - "3005:5173" + - "3005:80" environment: - REACT_APP_API_URL=http://localhost:3001 volumes: - - ./frontend:/app - - /app/node_modules - command: npm run dev + - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend mailhog: image: mailhog/mailhog:latest diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 13a61b3..ca73e13 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -31,10 +31,8 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Copy built application from builder stage COPY --from=builder /app/dist /usr/share/nginx/html -# Create non-root user -RUN addgroup -g 101 -S nginx && \ - adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx && \ - chown -R nginx:nginx /usr/share/nginx/html && \ +# Set permissions (nginx user already exists in nginx:alpine) +RUN chown -R nginx:nginx /usr/share/nginx/html && \ chown -R nginx:nginx /var/cache/nginx && \ chown -R nginx:nginx /var/log/nginx && \ touch /var/run/nginx.pid && \ diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6f90df8..9833ab3 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -65,6 +65,20 @@ server { proxy_cache_valid 404 1m; } + # Thumbnail serving proxy + location /thumbnails { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache thumbnails + proxy_cache_valid 200 302 7d; + proxy_cache_valid 404 1m; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef3187a..89fbca8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tiptap/extension-link": "^2.25.0", "@tiptap/react": "^2.25.0", "@tiptap/starter-kit": "^2.25.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", "date-fns": "^2.29.3", @@ -23,6 +24,7 @@ "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-i18next": "^15.6.0", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", @@ -1962,14 +1964,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1986,6 +1986,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2739,7 +2748,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/date-fns": { @@ -3542,6 +3550,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4732,6 +4749,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-countdown": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.6.tgz", @@ -4758,6 +4788,19 @@ "react": "^18.3.1" } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-i18next": { "version": "15.6.0", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", @@ -5425,7 +5468,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 4f5b21d..5460101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tiptap/extension-link": "^2.25.0", "@tiptap/react": "^2.25.0", "@tiptap/starter-kit": "^2.25.0", + "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", "date-fns": "^2.29.3", @@ -25,6 +26,7 @@ "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", "react-i18next": "^15.6.0", "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6478a1e..810f500 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { analyticsService } from './services/analytics.service'; -import { GalleryAuthProvider } from './contexts'; +import { GalleryAuthProvider, MaintenanceProvider } from './contexts'; import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; import { PreviewPage } from './pages/gallery/PreviewPage'; @@ -25,6 +25,8 @@ import { } from './pages/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; +import { MaintenanceWrapper } from './components/MaintenanceWrapper'; +import { GlobalThemeProvider } from './components/GlobalThemeProvider'; // Create a client const queryClient = new QueryClient({ @@ -37,82 +39,110 @@ const queryClient = new QueryClient({ }); function App() { - // Initialize Umami Analytics + // Initialize Umami Analytics based on settings useEffect(() => { - const umamiUrl = import.meta.env.VITE_UMAMI_URL; - const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; + const initializeAnalytics = async () => { + const umamiUrl = import.meta.env.VITE_UMAMI_URL; + const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; + + if (umamiUrl && umamiWebsiteId) { + try { + // Fetch public settings to check if analytics is enabled + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + const settings = await response.json(); + + // Only initialize if analytics is enabled in settings + if (settings.enable_analytics !== false) { + analyticsService.initialize({ + websiteId: umamiWebsiteId, + hostUrl: umamiUrl, + autoTrack: true, + doNotTrack: true + }); + } + } catch (error) { + console.error('Failed to fetch settings for analytics:', error); + // Initialize analytics anyway if settings fetch fails + analyticsService.initialize({ + websiteId: umamiWebsiteId, + hostUrl: umamiUrl, + autoTrack: true, + doNotTrack: true + }); + } + } + }; - if (umamiUrl && umamiWebsiteId) { - analyticsService.initialize({ - websiteId: umamiWebsiteId, - hostUrl: umamiUrl, - autoTrack: true, - doNotTrack: true - }); - } + initializeAnalytics(); }, []); return ( - - - - - - {/* Public gallery routes */} - } /> - - - - } /> + + + + + + + + + {/* Public gallery routes */} + } /> + + + + } /> - {/* Admin routes - wrap with AdminAuthProvider */} - }> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + {/* Admin routes - wrap with AdminAuthProvider */} + }> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - {/* Public legal pages */} - } /> - } /> - } /> + {/* Public legal pages */} + } /> + } /> + } /> - {/* Default redirect */} - } /> - - + {/* Default redirect */} + } /> + + + - {/* Offline indicator */} - + {/* Offline indicator */} + - {/* Toast notifications */} - - + {/* Toast notifications */} + + + + ); diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx new file mode 100644 index 0000000..6aa8070 --- /dev/null +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTheme } from '../contexts/ThemeContext'; +import { api } from '../config/api'; + +interface GlobalThemeProviderProps { + children: React.ReactNode; +} + +export const GlobalThemeProvider: React.FC = ({ children }) => { + const { setTheme } = useTheme(); + const themeAppliedRef = useRef(false); + + // Fetch public settings including theme config + const { data: settingsData } = useQuery({ + queryKey: ['global-theme-settings'], + queryFn: async () => { + const response = await api.get('/api/public/settings'); + return response.data; + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + // Apply global theme when settings are loaded + useEffect(() => { + if (!themeAppliedRef.current && settingsData?.theme_config) { + themeAppliedRef.current = true; + setTheme(settingsData.theme_config); + } + }, [settingsData, setTheme]); + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/components/MaintenanceMode.tsx b/frontend/src/components/MaintenanceMode.tsx new file mode 100644 index 0000000..b91f18c --- /dev/null +++ b/frontend/src/components/MaintenanceMode.tsx @@ -0,0 +1,113 @@ +import React, { useEffect } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { api } from '../config/api'; + +interface BrandingSettings { + branding_company_name?: string; + branding_company_tagline?: string; + branding_support_email?: string; + branding_footer_text?: string; + branding_favicon_url?: string; + branding_logo_url?: string; + default_language?: string; +} + +export const MaintenanceMode: React.FC = () => { + const { t, i18n } = useTranslation(); + + // Fetch branding settings + const { data: settings } = useQuery({ + queryKey: ['public-settings-maintenance'], + queryFn: async () => { + try { + const response = await api.get('/api/public/settings'); + return response.data; + } catch (error) { + // Return empty object if settings can't be fetched + return {}; + } + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + retry: false, // Don't retry on failure + }); + + // Set language based on system settings + useEffect(() => { + if (settings?.default_language && settings.default_language !== i18n.language) { + i18n.changeLanguage(settings.default_language); + } + }, [settings?.default_language, i18n]); + + return ( +
+ {/* Header with branding */} + {(settings?.branding_logo_url || settings?.branding_company_name) && ( +
+
+
+ {settings.branding_logo_url ? ( + {settings.branding_company_name + ) : ( +
+

{settings.branding_company_name}

+ {settings.branding_company_tagline && ( +

{settings.branding_company_tagline}

+ )} +
+ )} +
+
+
+ )} + + {/* Main content */} +
+
+
+ +
+ +

+ {t('maintenance.title')} +

+ +

+ {t('maintenance.message')} +

+ + {settings?.branding_support_email && ( +

+ {t('maintenance.urgentMatters')}{' '} + + {settings.branding_support_email} + +

+ )} +
+
+ + {/* Footer */} + {settings?.branding_footer_text && ( +
+
+

+ {settings.branding_footer_text} +

+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/MaintenanceWrapper.tsx b/frontend/src/components/MaintenanceWrapper.tsx new file mode 100644 index 0000000..d993e7c --- /dev/null +++ b/frontend/src/components/MaintenanceWrapper.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } 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'; + +interface MaintenanceWrapperProps { + children: React.ReactNode; +} + +export const MaintenanceWrapper: React.FC = ({ children }) => { + const location = useLocation(); + const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); + + // Check if current route is admin route + const isAdminRoute = location.pathname.startsWith('/admin'); + const hasAdminAuth = !!getAuthToken(true); + + // Register the maintenance mode callback + useEffect(() => { + setMaintenanceModeCallback((enabled: boolean) => { + setMaintenanceMode(enabled); + }); + }, [setMaintenanceMode]); + + // Check maintenance mode on mount and when location changes + useQuery({ + queryKey: ['maintenance-check', location.pathname], + queryFn: async () => { + try { + // Make a lightweight request to check maintenance status + await api.get('/api/public/settings'); + // If successful, maintenance mode is off + setMaintenanceMode(false); + return { maintenance: false }; + } catch (error: any) { + if (error.response?.status === 503) { + // Only set maintenance mode for non-admin routes or unauthenticated admin routes + if (!isAdminRoute || !hasAdminAuth) { + setMaintenanceMode(true); + return { maintenance: true }; + } + } + return { maintenance: false }; + } + }, + staleTime: 30000, // Check every 30 seconds + retry: false, // Don't retry on failure + enabled: (!isAdminRoute || !hasAdminAuth) && !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)) { + return ; + } + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminAuthenticatedImage.tsx b/frontend/src/components/admin/AdminAuthenticatedImage.tsx new file mode 100644 index 0000000..c0a3b20 --- /dev/null +++ b/frontend/src/components/admin/AdminAuthenticatedImage.tsx @@ -0,0 +1,93 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../../config/api'; + +interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes { + src: string; + fallback?: React.ReactNode; +} + +export const AdminAuthenticatedImage: React.FC = ({ + src, + fallback, + alt, + ...props +}) => { + const [imageSrc, setImageSrc] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + + const loadImage = async () => { + try { + setLoading(true); + setError(false); + + // Make authenticated request to get the image + const response = await api.get(src, { + responseType: 'blob', + }); + + if (!cancelled) { + // Create object URL from blob + const imageUrl = URL.createObjectURL(response.data); + setImageSrc(imageUrl); + setLoading(false); + } + } catch (err: any) { + console.error('Failed to load image:', src, err); + // Log more details about the error + if (err.response) { + console.error('Response status:', err.response.status); + console.error('Response headers:', err.response.headers); + if (err.response.data instanceof Blob) { + // Try to read error message from blob + try { + const text = await err.response.data.text(); + console.error('Response data:', text); + } catch (e) { + console.error('Could not read blob data'); + } + } else { + console.error('Response data:', err.response.data); + } + } + if (!cancelled) { + setError(true); + setLoading(false); + } + } + }; + + if (src) { + loadImage(); + } + + // Cleanup function + return () => { + cancelled = true; + if (imageSrc) { + URL.revokeObjectURL(imageSrc); + } + }; + }, [src]); + + if (loading) { + return ( +
+ ); + } + + if (error) { + return fallback ? ( + <>{fallback} + ) : ( +
+ Failed to load +
+ ); + } + + return {alt}; +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 910076a..8d1e9d1 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -1,13 +1,16 @@ import React, { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Menu, User, LogOut, Settings, Bell, Lock } from 'lucide-react'; -import { format } from 'date-fns'; +import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react'; +import { format, formatDistanceToNow } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAdminAuth } from '../../contexts'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { PasswordChangeModal } from './PasswordChangeModal'; import { LanguageSelector } from '../common'; +import { notificationsService } from '../../services/notifications.service'; +import { toast } from 'react-toastify'; interface AdminHeaderProps { onMenuClick: () => void; @@ -20,6 +23,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const [showUserMenu, setShowUserMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); + const queryClient = useQueryClient(); const userMenuRef = useRef(null); const notificationRef = useRef(null); @@ -32,21 +36,33 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { navigate('/admin/login'); }; - // Mock notifications - const notifications = [ - { - id: 1, - type: 'warning', - message: '3 events expiring in the next 7 days', - time: new Date(), + // Fetch notifications + const { data: notificationsData } = useQuery({ + queryKey: ['notifications', showNotifications], + queryFn: () => notificationsService.getNotifications(showNotifications, 20), + refetchInterval: 60000, // Refetch every minute + }); + + // Mark all as read mutation + const markAllAsReadMutation = useMutation({ + mutationFn: notificationsService.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success('All notifications marked as read'); }, - { - id: 2, - type: 'success', - message: 'Wedding Smith-Jones archived successfully', - time: new Date(Date.now() - 3600000), + }); + + // Clear old notifications mutation + const clearOldMutation = useMutation({ + mutationFn: notificationsService.clearOldNotifications, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success(`Cleared ${data.deletedCount} old notifications`); }, - ]; + }); + + const notifications = notificationsData?.notifications || []; + const unreadCount = notificationsData?.unreadCount || 0; return (
@@ -79,35 +95,80 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors" > - {notifications.length > 0 && ( + {unreadCount > 0 && ( )} {/* Notifications dropdown */} {showNotifications && ( -
-
+
+

{t('admin.notifications')}

+
+ {unreadCount > 0 && ( + + )} + +
- {notifications.map((notification) => ( -
-

{notification.message}

-

- {format(notification.time, 'h:mm a')} -

+ {notifications.length === 0 ? ( +
+ No notifications
- ))} -
-
- + ) : ( + notifications.map((notification) => { + const style = notificationsService.getNotificationStyle(notification.type); + return ( +
+
+
+ +
+
+

+ {notificationsService.formatNotificationMessage(notification)} +

+

+ {formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })} +

+
+
+
+ ); + }) + )}
+ {notifications.length > 0 && ( +
+ +
+ )}
)}
diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 72f1180..76f9090 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -2,12 +2,17 @@ import React, { useState } from 'react'; import { Outlet, Navigate } from 'react-router-dom'; import { useAdminAuth } from '../../contexts'; +import { useSessionTimeout } from '../../hooks/useSessionTimeout'; import { AdminSidebar } from './AdminSidebar'; import { AdminHeader } from './AdminHeader'; +import { MaintenanceBanner } from './MaintenanceBanner'; export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading } = useAdminAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); + + // Handle session timeout + useSessionTimeout(); if (isLoading) { return ( @@ -41,6 +46,9 @@ export const AdminLayout: React.FC = () => {
{/* Header */} setSidebarOpen(true)} /> + + {/* Maintenance mode banner */} + {/* Page content */}
diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx new file mode 100644 index 0000000..e627a3f --- /dev/null +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -0,0 +1,251 @@ +import React, { useState } from 'react'; +import { Check, Download, Trash2, Eye, Package } from 'lucide-react'; +import { toast } from 'react-toastify'; + +import { AdminPhoto } from '../../services/photos.service'; +import { photosService } from '../../services/photos.service'; +import { Button } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; + +interface AdminPhotoGridProps { + photos: AdminPhoto[]; + eventId: number; + onPhotoClick: (photo: AdminPhoto, index: number) => void; + onPhotosDeleted: () => void; +} + +export const AdminPhotoGrid: React.FC = ({ + photos, + eventId, + onPhotoClick, + onPhotosDeleted +}) => { + const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deletingPhotoId, setDeletingPhotoId] = useState(null); + + const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + + const newSelected = new Set(selectedPhotos); + if (newSelected.has(photoId)) { + newSelected.delete(photoId); + } else { + newSelected.add(photoId); + } + setSelectedPhotos(newSelected); + }; + + const handleSelectAll = () => { + if (selectedPhotos.size === photos.length) { + setSelectedPhotos(new Set()); + } else { + setSelectedPhotos(new Set(photos.map(p => p.id))); + } + }; + + const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => { + e.stopPropagation(); + + if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) { + return; + } + + setDeletingPhotoId(photo.id); + try { + await photosService.deletePhoto(eventId, photo.id); + toast.success('Photo deleted successfully'); + onPhotosDeleted(); + } catch (error) { + toast.error('Failed to delete photo'); + } finally { + setDeletingPhotoId(null); + } + }; + + const handleDeleteSelected = async () => { + if (selectedPhotos.size === 0) return; + + const count = selectedPhotos.size; + if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) { + return; + } + + setIsDeleting(true); + try { + await photosService.deletePhotos(eventId, Array.from(selectedPhotos)); + toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`); + setSelectedPhotos(new Set()); + setIsSelectionMode(false); + onPhotosDeleted(); + } catch (error) { + toast.error('Failed to delete photos'); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await photosService.downloadPhoto(eventId, photo.id, photo.filename); + toast.success('Download started'); + } catch (error) { + toast.error('Failed to download photo'); + } + }; + + const toggleSelectionMode = () => { + setIsSelectionMode(!isSelectionMode); + if (isSelectionMode) { + setSelectedPhotos(new Set()); + } + }; + + return ( +
+ {/* Action Bar */} +
+
+ + + {isSelectionMode && ( + <> + + + {selectedPhotos.size > 0 && ( + <> + + {selectedPhotos.size} selected + + + + )} + + )} +
+ +
+ {photos.length} photo{photos.length !== 1 ? 's' : ''} +
+
+ + {/* Photo Grid */} +
+ {photos.map((photo, index) => ( +
isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)} + > + {/* Selection Checkbox */} + {isSelectionMode && ( +
+
+ {selectedPhotos.has(photo.id) && ( + + )} +
+
+ )} + + {/* Thumbnail */} +
+ {photo.thumbnail_url ? ( + + +
+ } + /> + ) : ( +
+ +
+ )} +
+ + {/* Overlay with actions */} +
+
+

+ {photo.filename} +

+

+ {photosService.formatBytes(photo.size)} +

+ + {!isSelectionMode && ( +
+ + +
+ )} +
+
+ + {/* Category Badge */} + {photo.category_name && ( +
+ + {photo.category_name} + +
+ )} +
+ ))} +
+ + {photos.length === 0 && ( +
+

No photos uploaded yet

+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx new file mode 100644 index 0000000..868abf2 --- /dev/null +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -0,0 +1,269 @@ +import React, { useState } from 'react'; +import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'react-toastify'; + +import { AdminPhoto } from '../../services/photos.service'; +import { photosService } from '../../services/photos.service'; +import { Button } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; + +interface AdminPhotoViewerProps { + photos: AdminPhoto[]; + initialIndex: number; + eventId: number; + onClose: () => void; + onPhotoDeleted: () => void; + categories: Array<{ id: number; name: string; slug: string }>; +} + +export const AdminPhotoViewer: React.FC = ({ + photos, + initialIndex, + eventId, + onClose, + onPhotoDeleted, + categories +}) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [isDeleting, setIsDeleting] = useState(false); + const [showCategoryMenu, setShowCategoryMenu] = useState(false); + + const currentPhoto = photos[currentIndex]; + + const goToPrevious = () => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + }; + + const goToNext = () => { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + }; + + const handleDelete = async () => { + if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) { + return; + } + + setIsDeleting(true); + try { + await photosService.deletePhoto(eventId, currentPhoto.id); + toast.success('Photo deleted successfully'); + + // Close viewer if this was the last photo + if (photos.length === 1) { + onClose(); + } else { + // Move to next photo if available, otherwise previous + if (currentIndex === photos.length - 1) { + setCurrentIndex(currentIndex - 1); + } + } + + onPhotoDeleted(); + } catch (error) { + toast.error('Failed to delete photo'); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async () => { + try { + await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename); + toast.success('Download started'); + } catch (error) { + toast.error('Failed to download photo'); + } + }; + + const handleCategoryChange = async (categoryId: number | null) => { + try { + await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId); + toast.success('Category updated'); + setShowCategoryMenu(false); + // Trigger refresh to update the photo data + onPhotoDeleted(); // This will refresh the photos list + } catch (error) { + toast.error('Failed to update category'); + } + }; + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onClose(); + break; + case 'ArrowLeft': + goToPrevious(); + break; + case 'ArrowRight': + goToNext(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentIndex]); + + return ( +
+ {/* Close button */} + + + {/* Navigation */} + + + + + {/* Main content */} +
+ {/* Image */} +
+ +
+ +

Failed to load image

+
+
+ } + /> +
+ + {/* Sidebar */} +
+

{currentPhoto.filename}

+ + {/* Actions */} +
+ + +
+ + {/* Category */} +
+
+ + + Category + + +
+

+ {currentPhoto.category_name || 'Uncategorized'} +

+ + {showCategoryMenu && ( +
+ + {categories.map(cat => ( + + ))} +
+ )} +
+ + {/* Metadata */} +
+
+ + + File Size + +

{photosService.formatBytes(currentPhoto.size)}

+
+ +
+ + + Uploaded + +

+ {format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')} +

+
+ + {currentPhoto.view_count !== undefined && ( +
+ + + Views + +

{currentPhoto.view_count}

+
+ )} + + {currentPhoto.download_count !== undefined && ( +
+ + + Downloads + +

{currentPhoto.download_count}

+
+ )} +
+ + {/* Navigation info */} +
+

+ {currentIndex + 1} of {photos.length} +

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/BulkArchiveModal.tsx b/frontend/src/components/admin/BulkArchiveModal.tsx new file mode 100644 index 0000000..fc039a6 --- /dev/null +++ b/frontend/src/components/admin/BulkArchiveModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Archive, AlertTriangle, X } from 'lucide-react'; +import { Button, Card } from '../common'; +import type { Event } from '../../types'; + +interface BulkArchiveModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + selectedEvents: Event[]; + isLoading?: boolean; +} + +export const BulkArchiveModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + selectedEvents, + isLoading = false, +}) => { + if (!isOpen) return null; + + return ( +
+ +
+
+

Confirm Bulk Archive

+ +
+ +
+
+ +
+

+ You are about to archive {selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}. + This action will: +

+
    +
  • Create a ZIP archive of all photos for each event
  • +
  • Make the galleries inaccessible to guests
  • +
  • Remove the events from active listings
  • +
  • Free up storage space by compressing photos
  • +
+
+
+ +
+
+

Events to be archived:

+
    + {selectedEvents.map((event) => ( +
  • + • {event.event_name} ({event.event_type}) +
  • + ))} +
+
+
+
+ +
+ + +
+
+
+
+ ); +}; + +BulkArchiveModal.displayName = 'BulkArchiveModal'; \ No newline at end of file diff --git a/frontend/src/components/admin/EmailPreviewModal.tsx b/frontend/src/components/admin/EmailPreviewModal.tsx new file mode 100644 index 0000000..b5ffb35 --- /dev/null +++ b/frontend/src/components/admin/EmailPreviewModal.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { X, Mail, FileText } from 'lucide-react'; +import { Button, Card } from '../common'; + +interface EmailPreviewModalProps { + isOpen: boolean; + onClose: () => void; + subject: string; + htmlContent: string; + textContent?: string; +} + +export const EmailPreviewModal: React.FC = ({ + isOpen, + onClose, + subject, + htmlContent, + textContent +}) => { + const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html'); + + if (!isOpen) return null; + + return ( +
+ + {/* Header */} +
+
+ +

Email Preview

+
+ +
+ + {/* Subject */} +
+

Subject:

+

{subject}

+
+ + {/* View mode toggle */} +
+
+ + {textContent && ( + + )} +
+
+ + {/* Content */} +
+ {viewMode === 'html' ? ( +
+