From 934d6ddc5847f65db6371a4043b764f6d4cd6c8b Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 21 Aug 2025 20:06:49 +0200 Subject: [PATCH] fix: resolve GitHub issues #4, #8, #9, and #10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix missing database columns for password reset (#8) - Add must_change_password column to admin_users table - Add password_changed_at column for tracking password changes - Fix feedback functionality (#9) - Add require_moderation column to event_feedback_settings table - Add missing host_name column to events table - Add download control features (#10) - Add allow_downloads, disable_right_click, watermark_downloads columns to events - Implement download restrictions in gallery endpoints - Update event creation and update endpoints to support new fields - Prevent downloads when disabled for an event - Login functionality (#4) verified working with proper credentials All database migrations included and tested with Docker environment. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../core/036_fix_missing_columns.js | 87 +++++++++++++++++++ .../core/037_add_download_controls.js | 69 +++++++++++++++ backend/src/routes/adminEvents.js | 39 +++++++-- backend/src/routes/gallery.js | 25 +++++- 4 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/core/036_fix_missing_columns.js create mode 100644 backend/migrations/core/037_add_download_controls.js diff --git a/backend/migrations/core/036_fix_missing_columns.js b/backend/migrations/core/036_fix_missing_columns.js new file mode 100644 index 0000000..14ec0bc --- /dev/null +++ b/backend/migrations/core/036_fix_missing_columns.js @@ -0,0 +1,87 @@ +// Fix missing columns identified in GitHub issues + +exports.up = async function(knex) { + console.log('Adding missing columns to database tables...'); + + // Add must_change_password column to admin_users table + const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password'); + if (!hasMustChangePassword) { + console.log('Adding must_change_password column to admin_users table...'); + await knex.schema.table('admin_users', (table) => { + table.boolean('must_change_password').defaultTo(false); + }); + } + + // Add password_changed_at column to admin_users table + const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at'); + if (!hasPasswordChangedAt) { + console.log('Adding password_changed_at column to admin_users table...'); + await knex.schema.table('admin_users', (table) => { + table.datetime('password_changed_at'); + }); + } + + // Add require_moderation column to event_feedback_settings table + const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings'); + if (hasEventFeedbackSettings) { + const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation'); + if (!hasRequireModeration) { + console.log('Adding require_moderation column to event_feedback_settings table...'); + await knex.schema.table('event_feedback_settings', (table) => { + table.boolean('require_moderation').defaultTo(true); + }); + } + } + + // Add host_name column to events table if missing + const hasHostName = await knex.schema.hasColumn('events', 'host_name'); + if (!hasHostName) { + console.log('Adding host_name column to events table...'); + await knex.schema.table('events', (table) => { + table.string('host_name'); + }); + } + + console.log('Missing columns have been added successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing added columns...'); + + // Remove must_change_password column from admin_users table + const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password'); + if (hasMustChangePassword) { + await knex.schema.table('admin_users', (table) => { + table.dropColumn('must_change_password'); + }); + } + + // Remove password_changed_at column from admin_users table + const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at'); + if (hasPasswordChangedAt) { + await knex.schema.table('admin_users', (table) => { + table.dropColumn('password_changed_at'); + }); + } + + // Remove require_moderation column from event_feedback_settings table + const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings'); + if (hasEventFeedbackSettings) { + const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation'); + if (hasRequireModeration) { + await knex.schema.table('event_feedback_settings', (table) => { + table.dropColumn('require_moderation'); + }); + } + } + + // Remove host_name column from events table + const hasHostName = await knex.schema.hasColumn('events', 'host_name'); + if (hasHostName) { + await knex.schema.table('events', (table) => { + table.dropColumn('host_name'); + }); + } + + console.log('Columns removed'); +}; \ No newline at end of file diff --git a/backend/migrations/core/037_add_download_controls.js b/backend/migrations/core/037_add_download_controls.js new file mode 100644 index 0000000..5e16f21 --- /dev/null +++ b/backend/migrations/core/037_add_download_controls.js @@ -0,0 +1,69 @@ +// Add download control features to events table + +exports.up = async function(knex) { + console.log('Adding download control columns to events table...'); + + // Add download control columns to events table + const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads'); + if (!hasAllowDownloads) { + await knex.schema.table('events', (table) => { + table.boolean('allow_downloads').defaultTo(true); + table.boolean('disable_right_click').defaultTo(false); + table.boolean('watermark_downloads').defaultTo(false); + table.text('watermark_text'); + }); + } + + // Add download control settings to app_settings + const downloadSettingExists = await knex('app_settings') + .where('setting_key', 'default_allow_downloads') + .first(); + + if (!downloadSettingExists) { + await knex('app_settings').insert([ + { + setting_key: 'default_allow_downloads', + setting_value: JSON.stringify(true), + setting_type: 'gallery' + }, + { + setting_key: 'default_disable_right_click', + setting_value: JSON.stringify(false), + setting_type: 'gallery' + }, + { + setting_key: 'default_watermark_downloads', + setting_value: JSON.stringify(false), + setting_type: 'gallery' + } + ]); + } + + console.log('Download control features added successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing download control columns...'); + + // Remove app settings + await knex('app_settings') + .whereIn('setting_key', [ + 'default_allow_downloads', + 'default_disable_right_click', + 'default_watermark_downloads' + ]) + .delete(); + + // Remove columns from events table + const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads'); + if (hasAllowDownloads) { + await knex.schema.table('events', (table) => { + table.dropColumn('allow_downloads'); + table.dropColumn('disable_right_click'); + table.dropColumn('watermark_downloads'); + table.dropColumn('watermark_text'); + }); + } + + console.log('Download control columns removed'); +}; \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 17175df..9308cc1 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1,6 +1,7 @@ const express = require('express'); const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth-enhanced-v2'); const router = express.Router(); const bcrypt = require('bcrypt'); @@ -12,7 +13,6 @@ const { queueEmail } = require('../services/emailProcessor'); const { escapeLikePattern } = require('../utils/sqlSecurity'); // formatDate import removed - dates are formatted by email processor const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); -const { formatBoolean } = require('../utils/dbCompat'); // Create new event router.post('/', adminAuth, [ @@ -27,7 +27,11 @@ router.post('/', adminAuth, [ body('color_theme').optional().trim(), body('allow_user_uploads').optional().isBoolean().toBoolean(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('host_name').notEmpty().trim() + body('host_name').notEmpty().trim(), + body('allow_downloads').optional().isBoolean(), + body('disable_right_click').optional().isBoolean(), + body('watermark_downloads').optional().isBoolean(), + body('watermark_text').optional().trim() ], async (req, res) => { try { console.log('Create event request body:', req.body); @@ -49,9 +53,26 @@ router.post('/', adminAuth, [ color_theme = null, expiration_days = 30, allow_user_uploads = false, - upload_category_id = null + upload_category_id = null, + allow_downloads = true, + disable_right_click = false, + watermark_downloads = false, + watermark_text = null } = req.body; + // Debug logging + console.log('Download control values:', { + allow_downloads, + disable_right_click, + watermark_downloads, + watermark_text, + types: { + allow_downloads: typeof allow_downloads, + disable_right_click: typeof disable_right_click, + watermark_downloads: typeof watermark_downloads + } + }); + // Validate password strength const passwordValidation = await validatePasswordInContext(password, 'gallery', { eventName: event_name @@ -121,7 +142,11 @@ router.post('/', adminAuth, [ expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), allow_user_uploads, - upload_category_id + upload_category_id, + allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), + disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), + watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), + watermark_text }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) @@ -341,7 +366,11 @@ router.put('/:id', adminAuth, [ // Check if it's a number or can be converted to a valid integer const num = Number(value); return !isNaN(num) && Number.isInteger(num); - }).withMessage('hero_photo_id must be an integer or null') + }).withMessage('hero_photo_id must be an integer or null'), + body('allow_downloads').optional().isBoolean(), + body('disable_right_click').optional().isBoolean(), + body('watermark_downloads').optional().isBoolean(), + body('watermark_text').optional().trim() ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 025f6d0..a2e7327 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -46,7 +46,8 @@ 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', 'is_archived', 'share_link') + .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', + 'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text') .first(); if (!event) { @@ -78,7 +79,11 @@ router.get('/:slug/info', async (req, res) => { is_active: event.is_active, is_expired: !event.is_active || new Date(event.expires_at) < new Date(), requires_password: true, - color_theme: event.color_theme + color_theme: event.color_theme, + allow_downloads: event.allow_downloads !== false, + disable_right_click: event.disable_right_click === true, + watermark_downloads: event.watermark_downloads === true, + watermark_text: event.watermark_text }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -125,7 +130,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { welcome_message: req.event.welcome_message, color_theme: req.event.color_theme, expires_at: req.event.expires_at, - hero_photo_id: req.event.hero_photo_id + hero_photo_id: req.event.hero_photo_id, + allow_downloads: req.event.allow_downloads !== false, + disable_right_click: req.event.disable_right_click === true, + watermark_downloads: req.event.watermark_downloads === true, + watermark_text: req.event.watermark_text }, categories: categories.map(cat => ({ id: cat.id, @@ -157,6 +166,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => try { const { photoId } = req.params; + // Check if downloads are allowed for this event + if (req.event.allow_downloads === false) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + const photo = await db('photos') .where({ id: photoId, event_id: req.event.id }) .first(); @@ -205,6 +219,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => // Download all photos as ZIP router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { try { + // Check if downloads are allowed for this event + if (req.event.allow_downloads === false) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + // Fetch photos with category information const photos = await db('photos') .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')