diff --git a/backend/migrations/core/049_add_slug_redirects.js b/backend/migrations/core/049_add_slug_redirects.js new file mode 100644 index 00000000..a83c6866 --- /dev/null +++ b/backend/migrations/core/049_add_slug_redirects.js @@ -0,0 +1,21 @@ +/** + * Migration: Add slug_redirects table for event rename feature + * This table stores old slugs that should redirect to new slugs + */ + +exports.up = function(knex) { + return knex.schema.createTable('slug_redirects', (table) => { + table.increments('id').primary(); + table.string('old_slug', 255).notNullable().unique(); + table.string('new_slug', 255).notNullable(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // Index for fast lookup + table.index('old_slug'); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('slug_redirects'); +}; diff --git a/backend/migrations/core/050_add_optional_event_fields_settings.js b/backend/migrations/core/050_add_optional_event_fields_settings.js new file mode 100644 index 00000000..3d1a1ffc --- /dev/null +++ b/backend/migrations/core/050_add_optional_event_fields_settings.js @@ -0,0 +1,33 @@ +/** + * Migration: Add optional event fields settings + * These settings control whether customer name, customer email, and admin email + * are required when creating new events. + */ + +exports.up = async function(knex) { + const settings = [ + { setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' }, + { setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }, + { setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' } + ]; + + for (const setting of settings) { + const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ + ...setting, + updated_at: knex.fn.now() + }); + } + } +}; + +exports.down = function(knex) { + return knex('app_settings') + .whereIn('setting_key', [ + 'event_require_customer_name', + 'event_require_customer_email', + 'event_require_admin_email' + ]) + .del(); +}; diff --git a/backend/migrations/core/051_add_photo_filter_indexes.js b/backend/migrations/core/051_add_photo_filter_indexes.js new file mode 100644 index 00000000..6459db3f --- /dev/null +++ b/backend/migrations/core/051_add_photo_filter_indexes.js @@ -0,0 +1,100 @@ +/** + * Migration: Add indexes for photo filtering performance + * These indexes optimize queries that filter by feedback metrics + */ + +exports.up = async function(knex) { + // Add comment_count column if it doesn't exist + const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count'); + if (!hasCommentCount) { + await knex.schema.alterTable('photos', (table) => { + table.integer('comment_count').defaultTo(0); + }); + } + + // Add indexes for common filter queries + // Note: PostgreSQL supports partial indexes, SQLite does not + const client = knex.client.config.client; + + if (client === 'pg' || client === 'postgresql') { + // Partial indexes for PostgreSQL + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_rating_filter + ON photos(event_id, average_rating) + WHERE average_rating > 0 + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_likes_filter + ON photos(event_id, like_count) + WHERE like_count > 0 + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter + ON photos(event_id, favorite_count) + WHERE favorite_count > 0 + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_comments_filter + ON photos(event_id, comment_count) + WHERE comment_count > 0 + `); + } else { + // Regular indexes for SQLite + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_rating_filter + ON photos(event_id, average_rating) + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_likes_filter + ON photos(event_id, like_count) + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter + ON photos(event_id, favorite_count) + `); + + await knex.raw(` + CREATE INDEX IF NOT EXISTS idx_photos_comments_filter + ON photos(event_id, comment_count) + `); + } + + // Create export_jobs table for tracking large exports + const hasExportJobs = await knex.schema.hasTable('export_jobs'); + if (!hasExportJobs) { + await knex.schema.createTable('export_jobs', (table) => { + table.increments('id').primary(); + table.string('job_id', 50).unique().notNullable(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL'); + table.string('format', 20).notNullable(); + table.string('status', 20).defaultTo('pending'); + table.integer('progress').defaultTo(0); + table.integer('total_photos'); + table.json('options'); + table.string('file_path', 500); + table.bigInteger('file_size'); + table.text('error_message'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('completed_at'); + }); + } +}; + +exports.down = async function(knex) { + // Drop indexes + await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter'); + await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter'); + await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter'); + await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter'); + + // Drop export_jobs table + await knex.schema.dropTableIfExists('export_jobs'); + + // Note: We don't remove comment_count column as it might have data +}; diff --git a/backend/migrations/core/052_add_css_templates.js b/backend/migrations/core/052_add_css_templates.js new file mode 100644 index 00000000..5006a4bd --- /dev/null +++ b/backend/migrations/core/052_add_css_templates.js @@ -0,0 +1,192 @@ +/** + * Migration: Add CSS Templates feature + * Creates css_templates table and adds css_template_id to events table + */ + +// Default CSS template content +const DEFAULT_CSS_TEMPLATE = `/* + * PicPeak Custom CSS Template: Elegant Dark + * + * Available CSS Custom Properties: + * --gallery-bg: Background color + * --gallery-text: Primary text color + * --gallery-accent: Accent/highlight color + * --gallery-border: Border color + * --gallery-shadow: Box shadow value + * --gallery-radius: Border radius value + * --gallery-spacing: Base spacing unit + */ + +/* ===== Base Theme Variables ===== */ +.gallery-page { + --gallery-bg: #1a1a2e; + --gallery-bg-secondary: #16213e; + --gallery-text: #eaeaea; + --gallery-text-muted: #8b8b9a; + --gallery-accent: #e94560; + --gallery-accent-hover: #ff6b6b; + --gallery-border: #2d2d44; + --gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + --gallery-radius: 12px; + --gallery-spacing: 16px; +} + +/* ===== Page Background ===== */ +.gallery-page { + background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%); + min-height: 100vh; + color: var(--gallery-text); +} + +/* ===== Gallery Header ===== */ +.gallery-header { + background: rgba(22, 33, 62, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--gallery-border); + padding: calc(var(--gallery-spacing) * 2); +} + +.gallery-title { + color: var(--gallery-text); + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +/* ===== Photo Grid ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--gallery-spacing); + padding: calc(var(--gallery-spacing) * 2); +} + +/* ===== Photo Cards ===== */ +.photo-card { + background: var(--gallery-bg-secondary); + border-radius: var(--gallery-radius); + overflow: hidden; + transition: transform 0.3s ease, box-shadow 0.3s ease; + border: 1px solid var(--gallery-border); +} + +.photo-card:hover { + transform: translateY(-4px); + box-shadow: var(--gallery-shadow); +} + +.photo-card img { + width: 100%; + height: 200px; + object-fit: cover; + transition: transform 0.3s ease; +} + +.photo-card:hover img { + transform: scale(1.05); +} + +/* ===== Buttons ===== */ +.gallery-btn { + background: var(--gallery-accent); + color: white; + border: none; + border-radius: calc(var(--gallery-radius) / 2); + padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.gallery-btn:hover { + background: var(--gallery-accent-hover); + transform: translateY(-2px); +} + +/* ===== Lightbox ===== */ +.lightbox-overlay { + background: rgba(10, 10, 20, 0.95); + backdrop-filter: blur(20px); +} + +/* ===== Responsive Adjustments ===== */ +@media (max-width: 768px) { + .gallery-page { + --gallery-spacing: 12px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .gallery-title { + font-size: 1.5rem; + } +}`; + +exports.up = async function(knex) { + // Create css_templates table + const hasTable = await knex.schema.hasTable('css_templates'); + if (!hasTable) { + await knex.schema.createTable('css_templates', (table) => { + table.increments('id').primary(); + table.integer('slot_number').notNullable(); + table.string('name', 50).notNullable().defaultTo('Untitled'); + table.text('css_content').notNullable().defaultTo(''); + table.boolean('is_enabled').notNullable().defaultTo(false); + table.boolean('is_default').notNullable().defaultTo(false); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.unique('slot_number'); + }); + + // Insert default templates + await knex('css_templates').insert([ + { + slot_number: 1, + name: 'Elegant Dark', + css_content: DEFAULT_CSS_TEMPLATE, + is_enabled: true, + is_default: true + }, + { + slot_number: 2, + name: 'Untitled', + css_content: '', + is_enabled: false, + is_default: false + }, + { + slot_number: 3, + name: 'Untitled', + css_content: '', + is_enabled: false, + is_default: false + } + ]); + } + + // Add css_template_id to events table + const hasColumn = await knex.schema.hasColumn('events', 'css_template_id'); + if (!hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL'); + }); + } +}; + +exports.down = async function(knex) { + // Remove css_template_id from events table + const hasColumn = await knex.schema.hasColumn('events', 'css_template_id'); + if (hasColumn) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('css_template_id'); + }); + } + + // Drop css_templates table + await knex.schema.dropTableIfExists('css_templates'); +}; + +// Export default template for use in reset functionality +module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE; diff --git a/backend/server.js b/backend/server.js index aaa89c75..6693963f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -432,6 +432,9 @@ app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); app.use('/api/admin/photos', require('./src/routes/adminPhotos')); +app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); +app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); +app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/images', require('./src/routes/protectedImages')); diff --git a/backend/src/routes/adminCssTemplates.js b/backend/src/routes/adminCssTemplates.js new file mode 100644 index 00000000..38bd2ba4 --- /dev/null +++ b/backend/src/routes/adminCssTemplates.js @@ -0,0 +1,194 @@ +/** + * Admin CSS Templates Routes + * Handles CRUD operations for custom CSS gallery templates + */ + +const express = require('express'); +const router = express.Router(); +const { body, param, validationResult } = require('express-validator'); +const { db, withRetry } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer'); +const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates'); + +/** + * GET /admin/css-templates + * Get all CSS templates + */ +router.get('/', adminAuth, async (req, res) => { + try { + const templates = await withRetry(() => + db('css_templates').orderBy('slot_number') + ); + res.json({ success: true, templates }); + } catch (error) { + console.error('Get CSS templates error:', error); + res.status(500).json({ error: 'Failed to fetch templates' }); + } +}); + +/** + * GET /admin/css-templates/enabled + * Get only enabled templates (for event form dropdown) + */ +router.get('/enabled', adminAuth, async (req, res) => { + try { + const templates = await withRetry(() => + db('css_templates') + .where({ is_enabled: true }) + .select('id', 'name', 'slot_number') + .orderBy('slot_number') + ); + res.json({ success: true, templates }); + } catch (error) { + console.error('Get enabled templates error:', error); + res.status(500).json({ error: 'Failed to fetch templates' }); + } +}); + +/** + * GET /admin/css-templates/:slotNumber + * Get a specific template by slot number + */ +router.get('/:slotNumber', adminAuth, [ + param('slotNumber').isInt({ min: 1, max: 3 }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slotNumber } = req.params; + const template = await withRetry(() => + db('css_templates') + .where({ slot_number: parseInt(slotNumber) }) + .first() + ); + + if (!template) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json({ success: true, template }); + } catch (error) { + console.error('Get template error:', error); + res.status(500).json({ error: 'Failed to fetch template' }); + } +}); + +/** + * PUT /admin/css-templates/:slotNumber + * Update a template + */ +router.put('/:slotNumber', adminAuth, [ + param('slotNumber').isInt({ min: 1, max: 3 }), + body('name').optional().isString().isLength({ max: 50 }), + body('css_content').optional().isString(), + body('is_enabled').optional().isBoolean() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slotNumber } = req.params; + const { name, css_content, is_enabled } = req.body; + + // Validate CSS size + if (css_content && css_content.length > MAX_CSS_SIZE) { + return res.status(400).json({ + error: `CSS content exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB` + }); + } + + // Validate CSS syntax + if (css_content) { + const validation = validateCSS(css_content); + if (!validation.valid) { + return res.status(400).json({ + error: 'Invalid CSS syntax', + details: validation.error + }); + } + } + + // Sanitize CSS + const { sanitized, warnings } = sanitizeCSS(css_content || ''); + + const updates = { + updated_at: db.fn.now() + }; + + if (name !== undefined) { + updates.name = name.substring(0, 50) || 'Untitled'; + } + if (css_content !== undefined) { + updates.css_content = sanitized; + } + if (is_enabled !== undefined) { + updates.is_enabled = Boolean(is_enabled); + } + + await withRetry(() => + db('css_templates') + .where({ slot_number: parseInt(slotNumber) }) + .update(updates) + ); + + const template = await withRetry(() => + db('css_templates') + .where({ slot_number: parseInt(slotNumber) }) + .first() + ); + + res.json({ + success: true, + template, + sanitization_warnings: warnings + }); + } catch (error) { + console.error('Update template error:', error); + res.status(500).json({ error: 'Failed to update template' }); + } +}); + +/** + * POST /admin/css-templates/:slotNumber/reset + * Reset template to default (only for slot 1) + */ +router.post('/:slotNumber/reset', adminAuth, [ + param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + await withRetry(() => + db('css_templates') + .where({ slot_number: 1 }) + .update({ + name: 'Elegant Dark', + css_content: DEFAULT_CSS_TEMPLATE, + is_enabled: true, + updated_at: db.fn.now() + }) + ); + + const template = await withRetry(() => + db('css_templates') + .where({ slot_number: 1 }) + .first() + ); + + res.json({ success: true, template }); + } catch (error) { + console.error('Reset template error:', error); + res.status(500).json({ error: 'Failed to reset template' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminEventRename.js b/backend/src/routes/adminEventRename.js new file mode 100644 index 00000000..efd93926 --- /dev/null +++ b/backend/src/routes/adminEventRename.js @@ -0,0 +1,88 @@ +/** + * Admin Event Rename Routes + * Handles event renaming operations + */ + +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const eventRenameService = require('../services/eventRenameService'); +const router = express.Router(); + +/** + * POST /api/admin/events/:eventId/rename + * Rename an event + */ +router.post('/:eventId/rename', adminAuth, [ + body('newEventName') + .trim() + .isLength({ min: 3, max: 100 }) + .withMessage('Event name must be between 3 and 100 characters'), + body('resendEmail') + .optional() + .isBoolean() + .withMessage('resendEmail must be a boolean') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + const { eventId } = req.params; + const { newEventName, resendEmail = false } = req.body; + + const result = await eventRenameService.renameEvent( + parseInt(eventId, 10), + newEventName, + resendEmail, + req.admin + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json({ + success: true, + message: 'Event renamed successfully', + data: result.data + }); + } catch (error) { + console.error('Error renaming event:', error); + res.status(500).json({ success: false, error: 'Failed to rename event' }); + } +}); + +/** + * POST /api/admin/events/:eventId/validate-rename + * Validate a potential rename without executing it + */ +router.post('/:eventId/validate-rename', adminAuth, [ + body('newEventName') + .trim() + .isLength({ min: 3, max: 100 }) + .withMessage('Event name must be between 3 and 100 characters') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ valid: false, errors: errors.array() }); + } + + const { eventId } = req.params; + const { newEventName } = req.body; + + const validation = await eventRenameService.validateRename( + parseInt(eventId, 10), + newEventName + ); + + res.json(validation); + } catch (error) { + console.error('Error validating rename:', error); + res.status(500).json({ valid: false, error: 'Validation failed' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index b3ce0d42..fe9111df 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -16,6 +16,48 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor const logger = require('../utils/logger'); const { buildShareLinkVariants } = require('../services/shareLinkService'); +// Helper to get event field requirements from settings +const getEventFieldRequirements = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'event_require_customer_name', + 'event_require_customer_email', + 'event_require_admin_email' + ]) + .select('setting_key', 'setting_value'); + + const requirements = { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) { + value = value === 'true'; + } + } + if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; + if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; + if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; + }); + + return requirements; + } catch (error) { + logger.error('Failed to get event field requirements', { error: error.message }); + return { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true + }; + } +}; + const parseBooleanInput = (value, defaultValue = true) => { if (value === undefined || value === null) { return defaultValue; @@ -97,9 +139,9 @@ router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), - body('customer_name').notEmpty().trim(), - body('customer_email').isEmail().normalizeEmail(), - body('admin_email').isEmail().normalizeEmail(), + body('customer_name').optional().trim(), + body('customer_email').optional().isEmail().normalizeEmail(), + body('admin_email').optional().isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { const input = req.body.require_password; @@ -141,7 +183,10 @@ router.post('/', adminAuth, [ console.error('Validation errors:', errors.array()); return res.status(400).json({ errors: errors.array() }); } - + + // Get field requirements from settings + const fieldRequirements = await getEventFieldRequirements(); + const { event_type, event_name, @@ -174,8 +219,20 @@ router.post('/', adminAuth, [ const customerColumnsAvailable = await hasCustomerContactColumns(); - if (!customerName || !customerEmail) { - return res.status(400).json({ error: 'customer_name and customer_email are required' }); + // Conditional validation based on settings + const validationErrors = []; + if (fieldRequirements.require_customer_name && !customerName) { + validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' }); + } + if (fieldRequirements.require_customer_email && !customerEmail) { + validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' }); + } + if (fieldRequirements.require_admin_email && !admin_email) { + validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); + } + + if (validationErrors.length > 0) { + return res.status(400).json({ errors: validationErrors }); } const requirePassword = parseBooleanInput(requirePasswordInput, true); diff --git a/backend/src/routes/adminPhotoExport.js b/backend/src/routes/adminPhotoExport.js new file mode 100644 index 00000000..050684d2 --- /dev/null +++ b/backend/src/routes/adminPhotoExport.js @@ -0,0 +1,223 @@ +/** + * Admin Photo Export Routes + * Handles filtering and exporting photos based on guest feedback + */ + +const express = require('express'); +const router = express.Router(); +const { body, query, validationResult } = require('express-validator'); +const { db, withRetry } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder'); +const { PhotoExportService } = require('../services/photoExportService'); + +const exportService = new PhotoExportService(); + +/** + * GET /admin/photos/:eventId/filtered + * Get filtered photos with pagination + */ +router.get('/:eventId/filtered', adminAuth, [ + query('min_rating').optional().isFloat({ min: 0, max: 5 }), + query('max_rating').optional().isFloat({ min: 0, max: 5 }), + query('has_likes').optional().isBoolean(), + query('min_likes').optional().isInt({ min: 0 }), + query('has_favorites').optional().isBoolean(), + query('min_favorites').optional().isInt({ min: 0 }), + query('has_comments').optional().isBoolean(), + query('category_id').optional().isInt(), + query('logic').optional().isIn(['AND', 'OR']), + query('sort').optional().isIn(['rating', 'likes', 'favorites', 'date', 'filename']), + query('order').optional().isIn(['asc', 'desc']), + query('page').optional().isInt({ min: 1 }), + query('limit').optional().isInt({ min: 1, max: 100 }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const eventId = parseInt(req.params.eventId); + + // Verify event exists + const event = await withRetry(() => + db('events').where('id', eventId).first() + ); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Parse filter params + const filters = { + min_rating: req.query.min_rating ? parseFloat(req.query.min_rating) : undefined, + max_rating: req.query.max_rating ? parseFloat(req.query.max_rating) : undefined, + has_likes: req.query.has_likes, + min_likes: req.query.min_likes ? parseInt(req.query.min_likes) : undefined, + has_favorites: req.query.has_favorites, + min_favorites: req.query.min_favorites ? parseInt(req.query.min_favorites) : undefined, + has_comments: req.query.has_comments, + category_id: req.query.category_id ? parseInt(req.query.category_id) : undefined, + logic: req.query.logic || 'AND' + }; + + const sort = req.query.sort || 'date'; + const order = req.query.order || 'desc'; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + + // Build filtered query + const filterBuilder = new PhotoFilterBuilder( + db('photos') + .leftJoin('categories', 'photos.category_id', 'categories.id') + .select( + 'photos.id', + 'photos.filename', + 'photos.original_filename', + 'photos.file_path', + 'photos.average_rating', + 'photos.feedback_count', + 'photos.like_count', + 'photos.favorite_count', + 'photos.comment_count', + 'photos.width', + 'photos.height', + 'photos.created_at', + 'categories.name as category_name' + ), + eventId + ); + + filterBuilder + .applyFilters(filters) + .applySorting(sort, order) + .applyPagination(page, limit); + + const photos = await withRetry(() => filterBuilder.getQuery()); + + // Get count of filtered photos + const countResult = await withRetry(() => + PhotoFilterBuilder.buildCountQuery(db, eventId, filters) + ); + const filteredCount = parseInt(countResult[0]?.count) || 0; + + // Get summary counts + const summary = await withRetry(() => + PhotoFilterBuilder.getSummary(db, eventId) + ); + + res.json({ + success: true, + data: { + photos, + pagination: { + total: summary.total, + filtered: filteredCount, + page, + limit, + pages: Math.ceil(filteredCount / limit) + }, + summary + } + }); + } catch (error) { + console.error('Filter photos error:', error); + res.status(500).json({ error: 'Failed to filter photos' }); + } +}); + +/** + * GET /admin/photos/:eventId/filter-summary + * Get just the summary counts for filter UI + */ +router.get('/:eventId/filter-summary', adminAuth, async (req, res) => { + try { + const eventId = parseInt(req.params.eventId); + + const summary = await withRetry(() => + PhotoFilterBuilder.getSummary(db, eventId) + ); + + res.json({ + success: true, + data: summary + }); + } catch (error) { + console.error('Filter summary error:', error); + res.status(500).json({ error: 'Failed to get filter summary' }); + } +}); + +/** + * POST /admin/photos/:eventId/export + * Export selected or filtered photos + */ +router.post('/:eventId/export', adminAuth, [ + body('photo_ids').optional().isArray(), + body('photo_ids.*').optional().isInt(), + body('filter').optional().isObject(), + body('format').isIn(['txt', 'csv', 'xmp', 'json']), + body('options').optional().isObject() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const eventId = parseInt(req.params.eventId); + const { photo_ids, filter, format, options = {} } = req.body; + + // Verify event exists + const event = await withRetry(() => + db('events').where('id', eventId).first() + ); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // If filter provided instead of photo_ids, get matching photo IDs + let photoIds = photo_ids; + + if (!photoIds && filter) { + const filterBuilder = new PhotoFilterBuilder( + db('photos').select('id'), + eventId + ); + filterBuilder.applyFilters(filter); + const filteredPhotos = await withRetry(() => filterBuilder.getQuery()); + photoIds = filteredPhotos.map(p => p.id); + } + + // Export photos + const result = await exportService.exportPhotos(eventId, photoIds, format, options); + + if (result.type === 'stream') { + res.setHeader('Content-Type', result.contentType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + result.stream.pipe(res); + } else { + res.setHeader('Content-Type', result.contentType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.content); + } + } catch (error) { + console.error('Export photos error:', error); + res.status(500).json({ error: error.message || 'Failed to export photos' }); + } +}); + +/** + * GET /admin/photos/export-formats + * Get available export format options + */ +router.get('/export-formats', adminAuth, (req, res) => { + res.json({ + success: true, + data: PhotoExportService.getFormatOptions() + }); +}); + +module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 47035e89..eb72bd46 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -14,13 +14,39 @@ const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = r // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +// Check for slug redirect (for renamed events) +async function checkSlugRedirect(slug) { + try { + const hasTable = await db.schema.hasTable('slug_redirects'); + if (!hasTable) return null; + + const redirect = await db('slug_redirects') + .where({ old_slug: slug }) + .first(); + + return redirect ? redirect.new_slug : null; + } catch (error) { + logger.warn('Error checking slug redirect:', { slug, error: error.message }); + return null; + } +} + // Resolve gallery identifier (slug or token) to canonical data router.get('/resolve/:identifier', async (req, res) => { try { const { identifier } = req.params; - const result = await resolveShareIdentifier(identifier); + let result = await resolveShareIdentifier(identifier); + // If not found, check for redirect if (!result) { + const newSlug = await checkSlugRedirect(identifier); + if (newSlug) { + return res.status(301).json({ + redirect: true, + newSlug, + message: 'Gallery has been renamed' + }); + } return res.status(404).json({ error: 'Gallery not found' }); } @@ -75,8 +101,8 @@ router.get('/:slug/info', async (req, res) => { try { const { slug } = req.params; const { token } = req.query; - - const event = await db('events') + + let event = await db('events') .where({ slug }) .select( 'event_name', @@ -95,8 +121,17 @@ router.get('/:slug/info', async (req, res) => { 'color_theme' ) .first(); - + if (!event) { + // Check for redirect + const newSlug = await checkSlugRedirect(slug); + if (newSlug) { + return res.status(301).json({ + redirect: true, + newSlug, + message: 'Gallery has been renamed' + }); + } return res.status(404).json({ error: 'Gallery not found' }); } @@ -920,4 +955,43 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { } }); +/** + * GET /:slug/css-template + * Get custom CSS template for gallery (public endpoint) + */ +router.get('/:slug/css-template', async (req, res) => { + try { + const { slug } = req.params; + + // Find the event by slug + const event = await db('events') + .where({ slug }) + .select('css_template_id') + .first(); + + if (!event || !event.css_template_id) { + // No custom CSS - return 204 No Content + return res.status(204).send(); + } + + // Get the template if it's enabled + const template = await db('css_templates') + .where({ id: event.css_template_id, is_enabled: true }) + .select('css_content') + .first(); + + if (!template || !template.css_content) { + return res.status(204).send(); + } + + // Return CSS with caching headers + res.setHeader('Content-Type', 'text/css'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache + res.send(template.css_content); + } catch (error) { + console.error('Get CSS template error:', error); + res.status(500).send('/* Error loading template */'); + } +}); + module.exports = router; diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 3bcd6a8c..2e41f182 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -5,13 +5,14 @@ const router = express.Router(); // Get public settings (branding and theme) router.get('/', async (req, res) => { try { - // Fetch branding, theme, general, and security settings + // Fetch branding, theme, general, security, analytics, and event settings // Note: We include analytics in the query but it might not exist yet const settings = await withRetry(async () => { return await db('app_settings') .where(function() { - this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics']) - .orWhere('setting_key', 'like', 'analytics_%'); + this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean']) + .orWhere('setting_key', 'like', 'analytics_%') + .orWhere('setting_key', 'like', 'event_require_%'); }) .select('setting_key', 'setting_value'); }); @@ -19,10 +20,19 @@ router.get('/', async (req, res) => { // Convert to object format const settingsObject = {}; settings.forEach(setting => { + // Handle null/undefined values + if (setting.setting_value === null || setting.setting_value === undefined) { + settingsObject[setting.setting_key] = null; + return; + } + // If value is already a primitive (boolean, number), use it directly + if (typeof setting.setting_value === 'boolean' || typeof setting.setting_value === 'number') { + settingsObject[setting.setting_key] = setting.setting_value; + return; + } + // Try to parse string values as JSON try { - settingsObject[setting.setting_key] = setting.setting_value - ? JSON.parse(setting.setting_value) - : null; + settingsObject[setting.setting_key] = JSON.parse(setting.setting_value); } catch (e) { // If parsing fails, use the raw value settingsObject[setting.setting_key] = setting.setting_value; @@ -59,7 +69,11 @@ router.get('/', async (req, res) => { umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true', umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null, umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null, - umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null + umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null, + // Event field requirements + event_require_customer_name: settingsObject.event_require_customer_name !== false, + event_require_customer_email: settingsObject.event_require_customer_email !== false, + event_require_admin_email: settingsObject.event_require_admin_email !== false }; res.json(publicSettings); diff --git a/backend/src/services/eventRenameService.js b/backend/src/services/eventRenameService.js new file mode 100644 index 00000000..4f16580a --- /dev/null +++ b/backend/src/services/eventRenameService.js @@ -0,0 +1,421 @@ +/** + * Event Rename Service + * Handles renaming events including slug updates, file system changes, and database updates + */ + +const { db, logActivity } = require('../database/db'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../utils/logger'); +const { buildShareLinkVariants } = require('./shareLinkService'); +const { queueEmail } = require('./emailProcessor'); + +class EventRenameService { + constructor() { + this.storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + } + + /** + * Format a date to YYYY-MM-DD string + * @param {Date|string} date - Date object or string + * @returns {string} Formatted date string + */ + formatDate(date) { + if (!date) return ''; + const d = new Date(date); + if (isNaN(d.getTime())) return String(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + /** + * Generate a slug from event details + * @param {string} eventType - Type of event (wedding, birthday, etc.) + * @param {string} eventName - Name of the event + * @param {string|Date} eventDate - Date of the event + * @returns {string} Generated slug + */ + generateSlug(eventType, eventName, eventDate) { + const processedEventName = eventName + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + const formattedDate = this.formatDate(eventDate); + return `${eventType}-${processedEventName}-${formattedDate}`; + } + + /** + * Validate if a rename operation is possible + * @param {number} eventId - Event ID + * @param {string} newEventName - New event name + * @returns {Promise<{valid: boolean, error?: string, newSlug?: string}>} + */ + async validateRename(eventId, newEventName) { + try { + // Get current event + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + return { valid: false, error: 'Event not found' }; + } + + if (event.is_archived) { + return { valid: false, error: 'Cannot rename archived events' }; + } + + // Validate new name + if (!newEventName || newEventName.trim().length < 3) { + return { valid: false, error: 'Event name must be at least 3 characters' }; + } + + if (newEventName.trim().length > 100) { + return { valid: false, error: 'Event name must be less than 100 characters' }; + } + + // Generate new slug + const newSlug = this.generateSlug(event.event_type, newEventName.trim(), event.event_date); + + // Check if slug already exists (for different event) + const existingEvent = await db('events') + .where({ slug: newSlug }) + .whereNot({ id: eventId }) + .first(); + + if (existingEvent) { + return { valid: false, error: 'An event with this name already exists for the same date', conflicts: [existingEvent.event_name] }; + } + + // Check if the slug is the same as current + if (newSlug === event.slug) { + return { valid: false, error: 'New name generates the same URL as the current name' }; + } + + return { valid: true, newSlug, currentSlug: event.slug }; + } catch (error) { + logger.error('Validation error:', { error: error.message }); + return { valid: false, error: 'Validation failed' }; + } + } + + /** + * Rename the event folder on the filesystem + * @param {string} oldSlug - Current slug + * @param {string} newSlug - New slug + * @returns {Promise} + */ + async renameEventFolder(oldSlug, newSlug) { + const oldPath = path.join(this.storagePath, 'events/active', oldSlug); + const newPath = path.join(this.storagePath, 'events/active', newSlug); + + try { + // Check if old folder exists + await fs.access(oldPath); + + // Check if new folder already exists + try { + await fs.access(newPath); + throw new Error('Target folder already exists'); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + // Rename folder + await fs.rename(oldPath, newPath); + logger.info('Event folder renamed', { oldSlug, newSlug }); + return true; + } catch (error) { + if (error.code === 'ENOENT') { + logger.warn('Event folder not found, skipping rename', { oldSlug }); + return true; // Not a fatal error if folder doesn't exist + } + throw error; + } + } + + /** + * Rename individual photo files to match new event name + * @param {number} eventId - Event ID + * @param {string} oldEventName - Old event name + * @param {string} newEventName - New event name + * @param {string} newSlug - New slug for path updates + * @returns {Promise} Number of files renamed + */ + async renamePhotoFiles(eventId, oldEventName, newEventName, oldSlug, newSlug) { + const photos = await db('photos').where({ event_id: eventId }); + let renamedCount = 0; + + // Process event name for filenames + const oldNamePrefix = oldEventName.replace(/[^a-zA-Z0-9]/g, '_'); + const newNamePrefix = newEventName.replace(/[^a-zA-Z0-9]/g, '_'); + + for (const photo of photos) { + try { + const oldFilename = photo.filename; + let newFilename = oldFilename; + + // Replace event name prefix if present + if (oldFilename.startsWith(oldNamePrefix)) { + newFilename = oldFilename.replace(oldNamePrefix, newNamePrefix); + } + + // Update path with new slug + const newPath = photo.path.replace(oldSlug, newSlug); + const newThumbnailPath = photo.thumbnail_path ? + photo.thumbnail_path.replace(oldSlug, newSlug) : null; + + // Rename physical file if filename changed + if (newFilename !== oldFilename) { + const oldFilePath = path.join(this.storagePath, 'events/active', newSlug, + photo.type === 'collage' ? 'collages' : 'individual', oldFilename); + const newFilePath = path.join(this.storagePath, 'events/active', newSlug, + photo.type === 'collage' ? 'collages' : 'individual', newFilename); + + try { + await fs.rename(oldFilePath, newFilePath); + } catch (error) { + if (error.code !== 'ENOENT') { + logger.warn('Could not rename photo file', { oldFilename, error: error.message }); + } + } + } + + // Update database record + await db('photos') + .where({ id: photo.id }) + .update({ + filename: newFilename, + path: newPath, + thumbnail_path: newThumbnailPath + }); + + renamedCount++; + } catch (error) { + logger.error('Error renaming photo', { photoId: photo.id, error: error.message }); + } + } + + return renamedCount; + } + + /** + * Update database records for the rename + * @param {object} trx - Knex transaction + * @param {number} eventId - Event ID + * @param {string} oldSlug - Current slug + * @param {string} newSlug - New slug + * @param {string} newEventName - New event name + * @returns {Promise<{newShareLink: string}>} + */ + async updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName) { + const event = await trx('events').where({ id: eventId }).first(); + + // Generate new share link + const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ + slug: newSlug, + shareToken: event.share_token + }); + + // Update event + await trx('events') + .where({ id: eventId }) + .update({ + event_name: newEventName, + slug: newSlug, + share_link: shareLinkToStore + }); + + return { newShareLink: shareUrl }; + } + + /** + * Create a redirect entry for the old slug + * @param {object} trx - Knex transaction + * @param {number} eventId - Event ID + * @param {string} oldSlug - Old slug + * @param {string} newSlug - New slug + */ + async createSlugRedirect(trx, eventId, oldSlug, newSlug) { + // Check if table exists + const hasTable = await trx.schema.hasTable('slug_redirects'); + if (!hasTable) { + logger.warn('slug_redirects table does not exist, skipping redirect creation'); + return; + } + + // Check if redirect already exists + const existingRedirect = await trx('slug_redirects') + .where({ old_slug: oldSlug }) + .first(); + + if (existingRedirect) { + // Update existing redirect + await trx('slug_redirects') + .where({ old_slug: oldSlug }) + .update({ new_slug: newSlug }); + } else { + // Create new redirect + await trx('slug_redirects').insert({ + old_slug: oldSlug, + new_slug: newSlug, + event_id: eventId + }); + } + + // Also update any existing redirects pointing to the old slug + await trx('slug_redirects') + .where({ new_slug: oldSlug }) + .update({ new_slug: newSlug }); + } + + /** + * Send notification email about the rename + * @param {number} eventId - Event ID + * @param {string} newShareLink - New share link + */ + async sendRenamedEventEmail(eventId, newShareLink) { + const event = await db('events').where({ id: eventId }).first(); + if (!event) return; + + const recipientEmail = event.customer_email || event.host_email; + if (!recipientEmail) return; + + const recipientName = event.customer_name || event.host_name || + (recipientEmail ? recipientEmail.split('@')[0] : 'Guest'); + + await queueEmail(eventId, recipientEmail, 'gallery_link_updated', { + customer_name: recipientName, + event_name: event.event_name, + new_gallery_link: newShareLink, + event_date: event.event_date + }); + } + + /** + * Rollback a failed rename operation + * @param {object} backupData - Backup data from the rename attempt + */ + async rollbackRename(backupData) { + try { + if (backupData.folderRenamed && backupData.event) { + const oldPath = path.join(this.storagePath, 'events/active', backupData.newSlug); + const newPath = path.join(this.storagePath, 'events/active', backupData.event.slug); + + try { + await fs.rename(oldPath, newPath); + logger.info('Rolled back folder rename'); + } catch (error) { + logger.error('Failed to rollback folder rename', { error: error.message }); + } + } + } catch (error) { + logger.error('Rollback failed', { error: error.message }); + } + } + + /** + * Main method to rename an event + * @param {number} eventId - Event ID + * @param {string} newEventName - New event name + * @param {boolean} resendEmail - Whether to resend invitation email + * @param {object} adminUser - Admin user performing the action + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async renameEvent(eventId, newEventName, resendEmail = false, adminUser = null) { + const backupData = {}; + + try { + // 1. Validate + const validation = await this.validateRename(eventId, newEventName); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + // 2. Get current event data + const event = await db('events').where({ id: eventId }).first(); + backupData.event = event; + backupData.newSlug = validation.newSlug; + + const oldSlug = event.slug; + const oldName = event.event_name; + const newSlug = validation.newSlug; + + // 3. Start transaction + const trx = await db.transaction(); + + try { + // 4. Rename folder (filesystem) + await this.renameEventFolder(oldSlug, newSlug); + backupData.folderRenamed = true; + + // 5. Rename photo files and update paths + const filesRenamed = await this.renamePhotoFiles(eventId, oldName, newEventName.trim(), oldSlug, newSlug); + + // 6. Update database records + const { newShareLink } = await this.updateDatabaseRecords(trx, eventId, oldSlug, newSlug, newEventName.trim()); + + // 7. Create redirect entry + await this.createSlugRedirect(trx, eventId, oldSlug, newSlug); + + // 8. Log activity + await trx('activity_logs').insert({ + activity_type: 'event_renamed', + actor_type: adminUser ? 'admin' : 'system', + actor_id: adminUser?.id || null, + actor_name: adminUser?.username || 'system', + metadata: JSON.stringify({ + old_name: oldName, + new_name: newEventName.trim(), + old_slug: oldSlug, + new_slug: newSlug, + files_renamed: filesRenamed, + email_sent: resendEmail + }), + event_id: eventId + }); + + // 9. Commit transaction + await trx.commit(); + + // 10. Send email (after commit, non-critical) + let emailSent = false; + if (resendEmail) { + try { + await this.sendRenamedEventEmail(eventId, newShareLink); + emailSent = true; + } catch (error) { + logger.error('Failed to send rename notification email', { error: error.message }); + } + } + + return { + success: true, + data: { + eventId, + oldName, + newName: newEventName.trim(), + oldSlug, + newSlug, + newShareLink, + emailSent, + filesRenamed + } + }; + + } catch (error) { + await trx.rollback(); + throw error; + } + + } catch (error) { + logger.error('Event rename failed', { eventId, error: error.message }); + await this.rollbackRename(backupData); + return { success: false, error: error.message || 'Failed to rename event' }; + } + } +} + +module.exports = new EventRenameService(); diff --git a/backend/src/services/photoExportService.js b/backend/src/services/photoExportService.js new file mode 100644 index 00000000..c662346d --- /dev/null +++ b/backend/src/services/photoExportService.js @@ -0,0 +1,251 @@ +/** + * Photo Export Service + * Handles exporting filtered photos in various formats + */ + +const archiver = require('archiver'); +const { PassThrough } = require('stream'); +const { XmpGenerator } = require('./xmpGenerator'); +const { db } = require('../database/db'); +const path = require('path'); +const fs = require('fs').promises; + +class PhotoExportService { + constructor() { + this.xmpGenerator = new XmpGenerator(); + } + + /** + * Get photos with full feedback data + * @param {number} eventId - Event ID + * @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided) + * @returns {Promise} Photos with feedback + */ + async getPhotosWithFeedback(eventId, photoIds = null) { + let query = db('photos') + .leftJoin('categories', 'photos.category_id', 'categories.id') + .where('photos.event_id', eventId) + .select( + 'photos.id', + 'photos.filename', + 'photos.original_filename', + 'photos.file_path', + 'photos.average_rating', + 'photos.feedback_count', + 'photos.like_count', + 'photos.favorite_count', + 'photos.comment_count', + 'photos.width', + 'photos.height', + 'photos.file_size', + 'photos.created_at', + 'categories.name as category_name' + ) + .orderBy('photos.filename', 'asc'); + + if (photoIds && photoIds.length > 0) { + query = query.whereIn('photos.id', photoIds); + } + + return await query; + } + + /** + * Export photos in the specified format + * @param {number} eventId - Event ID + * @param {number[]} photoIds - Photo IDs to export + * @param {string} format - Export format (txt, csv, xmp, photos, json) + * @param {Object} options - Export options + * @returns {Promise} Export result with stream/content + */ + async exportPhotos(eventId, photoIds, format, options = {}) { + const photos = await this.getPhotosWithFeedback(eventId, photoIds); + + if (photos.length === 0) { + throw new Error('No photos to export'); + } + + switch (format) { + case 'txt': + return this.exportAsTxt(photos, options); + case 'csv': + return this.exportAsCsv(photos, options); + case 'xmp': + return this.exportAsXmpZip(photos, options); + case 'json': + return this.exportAsJson(photos, eventId, options); + default: + throw new Error(`Unknown export format: ${format}`); + } + } + + /** + * Export as plain text filename list + */ + exportAsTxt(photos, options = {}) { + const { filename_format = 'original', separator = 'newline' } = options; + + const filenames = photos.map(photo => + filename_format === 'original' ? photo.original_filename : photo.filename + ); + + let content; + switch (separator) { + case 'comma': + content = filenames.join(', '); + break; + case 'semicolon': + content = filenames.join('; '); + break; + default: + content = filenames.join('\n'); + } + + return { + type: 'text', + content, + filename: `photo_list_${Date.now()}.txt`, + contentType: 'text/plain' + }; + } + + /** + * Export as CSV with metadata + */ + exportAsCsv(photos, options = {}) { + const { filename_format = 'original' } = options; + + const headers = [ + 'filename', + 'original_filename', + 'rating', + 'rating_count', + 'likes', + 'favorites', + 'comments', + 'category', + 'width', + 'height', + 'file_size', + 'created_at' + ]; + + const rows = photos.map(photo => [ + filename_format === 'original' ? photo.original_filename : photo.filename, + photo.original_filename || '', + photo.average_rating ? photo.average_rating.toFixed(2) : '0.00', + photo.feedback_count || 0, + photo.like_count || 0, + photo.favorite_count || 0, + photo.comment_count || 0, + photo.category_name || '', + photo.width || '', + photo.height || '', + photo.file_size || '', + photo.created_at ? new Date(photo.created_at).toISOString() : '' + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + ].join('\n'); + + return { + type: 'text', + content: csvContent, + filename: `photo_export_${Date.now()}.csv`, + contentType: 'text/csv' + }; + } + + /** + * Export as XMP sidecar files in a ZIP archive + */ + async exportAsXmpZip(photos, options = {}) { + const { filename_format = 'original' } = options; + + const archive = archiver('zip', { zlib: { level: 9 } }); + const passthrough = new PassThrough(); + archive.pipe(passthrough); + + for (const photo of photos) { + const baseFilename = filename_format === 'original' + ? photo.original_filename + : photo.filename; + const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename); + const xmpContent = this.xmpGenerator.generateXmp(photo, options); + + archive.append(xmpContent, { name: xmpFilename }); + } + + archive.finalize(); + + return { + type: 'stream', + stream: passthrough, + filename: `xmp_export_${Date.now()}.zip`, + contentType: 'application/zip' + }; + } + + /** + * Export as JSON metadata + */ + async exportAsJson(photos, eventId, options = {}) { + // Get event info + const event = await db('events') + .where('id', eventId) + .select('event_name', 'event_date', 'slug') + .first(); + + const exportData = { + export_info: { + event_name: event?.event_name || 'Unknown Event', + event_date: event?.event_date, + event_slug: event?.slug, + exported_at: new Date().toISOString(), + total_photos: photos.length + }, + photos: photos.map(photo => ({ + id: photo.id, + filename: photo.filename, + original_filename: photo.original_filename, + category: photo.category_name || null, + rating: { + average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0, + count: photo.feedback_count || 0 + }, + likes: photo.like_count || 0, + favorites: photo.favorite_count || 0, + comments: photo.comment_count || 0, + dimensions: { + width: photo.width, + height: photo.height + }, + file_size: photo.file_size, + created_at: photo.created_at + })) + }; + + return { + type: 'text', + content: JSON.stringify(exportData, null, 2), + filename: `photo_metadata_${Date.now()}.json`, + contentType: 'application/json' + }; + } + + /** + * Get export format display names + */ + static getFormatOptions() { + return [ + { value: 'txt', label: 'Filename List (TXT)', description: 'Simple text list of filenames' }, + { value: 'csv', label: 'Filename List (CSV)', description: 'Spreadsheet with metadata' }, + { value: 'xmp', label: 'XMP Sidecar Files (ZIP)', description: 'For Lightroom/Bridge/Capture One' }, + { value: 'json', label: 'Metadata (JSON)', description: 'Structured data for automation' } + ]; + } +} + +module.exports = { PhotoExportService }; diff --git a/backend/src/services/xmpGenerator.js b/backend/src/services/xmpGenerator.js new file mode 100644 index 00000000..9c23f8d6 --- /dev/null +++ b/backend/src/services/xmpGenerator.js @@ -0,0 +1,165 @@ +/** + * XMP Sidecar File Generator + * Generates Adobe XMP metadata files for photos with guest feedback + */ + +class XmpGenerator { + /** + * Generate XMP sidecar content for a photo + * @param {Object} photo - Photo object with feedback data + * @param {Object} options - Generation options + * @returns {string} XMP file content + */ + generateXmp(photo, options = {}) { + const { + include_rating = true, + include_label = true, + include_description = true, + include_keywords = true + } = options; + + const rating = include_rating ? this.mapRating(photo.average_rating) : 0; + const label = include_label ? this.mapLabel(photo.average_rating) : null; + + const descriptionXml = include_description ? this.generateDescription(photo) : ''; + const keywordsXml = include_keywords ? this.generateKeywords(photo) : ''; + + return ` + + + + ${descriptionXml} + ${keywordsXml} + + +`; + } + + /** + * Map PicPeak average rating to XMP 1-5 rating + * @param {number} avgRating - Average rating (0-5, decimal) + * @returns {number} XMP rating (0-5, integer) + */ + mapRating(avgRating) { + if (!avgRating || avgRating === 0) return 0; + if (avgRating >= 4.5) return 5; + if (avgRating >= 3.5) return 4; + if (avgRating >= 2.5) return 3; + if (avgRating >= 1.5) return 2; + return 1; + } + + /** + * Map PicPeak rating to XMP color label + * @param {number} avgRating - Average rating + * @returns {string|null} XMP label color + */ + mapLabel(avgRating) { + if (!avgRating || avgRating === 0) return null; + if (avgRating >= 4.5) return 'Red'; // Top picks + if (avgRating >= 3.5) return 'Yellow'; // Good + if (avgRating >= 2.5) return 'Green'; // Average + if (avgRating >= 1.5) return 'Blue'; // Below average + return 'Purple'; // Low + } + + /** + * Generate XMP description element + * @param {Object} photo - Photo object + * @returns {string} Description XML + */ + generateDescription(photo) { + const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0'; + const likes = photo.like_count || 0; + const favorites = photo.favorite_count || 0; + + const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`; + + return ` + + ${this.escapeXml(desc)} + + `; + } + + /** + * Generate XMP keywords element + * @param {Object} photo - Photo object + * @returns {string} Keywords XML + */ + generateKeywords(photo) { + const keywords = ['picpeak-export']; + + if (photo.average_rating >= 4) { + keywords.push('guest-pick'); + } + + if (photo.average_rating >= 4.5) { + keywords.push('top-rated'); + } + + if (photo.like_count >= 5) { + keywords.push('popular'); + } + + if (photo.favorite_count > 0) { + keywords.push('favorited'); + } + + if (photo.category_name) { + keywords.push(this.sanitizeKeyword(photo.category_name)); + } + + return ` + + ${keywords.map(k => `${this.escapeXml(k)}`).join('\n ')} + + `; + } + + /** + * Escape special XML characters + * @param {string} str - Input string + * @returns {string} Escaped string + */ + escapeXml(str) { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Sanitize keyword for XMP + * @param {string} keyword - Raw keyword + * @returns {string} Sanitized keyword + */ + sanitizeKeyword(keyword) { + return keyword + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + } + + /** + * Get XMP filename from photo filename + * @param {string} photoFilename - Photo filename + * @returns {string} XMP filename + */ + getXmpFilename(photoFilename) { + return photoFilename.replace(/\.[^.]+$/, '.xmp'); + } +} + +module.exports = { XmpGenerator }; diff --git a/backend/src/utils/cssSanitizer.js b/backend/src/utils/cssSanitizer.js index 42ce395a..97abbc2b 100644 --- a/backend/src/utils/cssSanitizer.js +++ b/backend/src/utils/cssSanitizer.js @@ -1,3 +1,43 @@ +/** + * CSS Sanitizer + * Sanitizes user-provided CSS to prevent security vulnerabilities + */ + +// Patterns that should be blocked for security +const FORBIDDEN_PATTERNS = [ + // JavaScript execution + /expression\s*\(/gi, + /javascript:/gi, + /behavior\s*:/gi, + /-moz-binding/gi, + /vbscript:/gi, + + // External resources (potential data exfiltration) + /@import/gi, + + // Dangerous at-rules + /@charset/gi, + /@namespace/gi, + + // IE-specific exploits + /\\0/g, // Null byte + /\\9/g, // IE CSS hack + + // Script injection attempts + /