From e4b0f961b75952b6907cc2291fa256215c09c80c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 26 Apr 2026 22:15:00 +0200 Subject: [PATCH 1/2] fix: prevent backend crash on archive when admin_email is null (#318) Archiving an event with no admin_email queued an email_queue row with recipient_email=null, violating the NOT NULL constraint. The error was thrown inside the output.on('close') callback (detached from the caller), becoming an unhandled rejection that crashed Node and dropped admin sessions on bulk archive. - Skip queueEmail when event.admin_email is null/empty (admin_email has been nullable since migration 073). - Wrap the close handler in try/catch so any post-archive failure logs instead of crashing the process. --- backend/src/services/archiveService.js | 59 +++++++++++++++----------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index f4e62ea2..59521393 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -61,32 +61,43 @@ async function archiveEvent(event) { } output.on('close', async () => { - logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); - - // Update database - await db('events').where('id', event.id).update({ - is_archived: true, - archive_path: path.relative(getStoragePath(), archivePath), - archived_at: new Date() - }); - - // Delete original files - await fs.rm(eventPath, { recursive: true }); - - // Delete thumbnails - const photos = await db('photos').where('event_id', event.id); - for (const photo of photos) { - if (photo.thumbnail_path) { - const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); - await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted + try { + logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); + + // Update database + await db('events').where('id', event.id).update({ + is_archived: true, + archive_path: path.relative(getStoragePath(), archivePath), + archived_at: new Date() + }); + + // Delete original files + await fs.rm(eventPath, { recursive: true }); + + // Delete thumbnails + const photos = await db('photos').where('event_id', event.id); + for (const photo of photos) { + if (photo.thumbnail_path) { + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted + } } + + // Queue completion email — admin_email is nullable on events (migration 073); + // skip queueing rather than violating email_queue.recipient_email NOT NULL. + if (event.admin_email) { + await queueEmail(event.id, event.admin_email, 'archive_complete', { + event_name: event.event_name, + archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' + }); + } else { + logger.info(`Skipping archive_complete email for event ${event.slug}: no admin_email set`); + } + } catch (err) { + // Never let the close handler reject — it runs detached from the caller, + // and an unhandled rejection here crashes the backend process. + logger.error(`Post-archive cleanup failed for event ${event.slug}:`, err); } - - // Queue completion email - await queueEmail(event.id, event.admin_email, 'archive_complete', { - event_name: event.event_name, - archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' - }); }); archive.pipe(output); From 6cfff6f6a6dbdc5bc1e9fe4fbce5795cdb1855c6 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 26 Apr 2026 22:48:59 +0200 Subject: [PATCH 2/2] fix: address bugs and feature requests from discussion #317 - Share link: display and copy now use the absolute URL built from the current origin instead of the relative path stored in events.share_link. Added a Copy Link button to the events list (inline + dropdown). - Detect dev tools default: event creation now reads the global enable_devtools_protection app setting instead of always falling back to the column default; admins who disable it globally get new events with it disabled too. - Require password default: added a global "Require password by default" setting (event_default_require_password, default true), exposed via Settings -> Events. Create-event form initialises from it. - Filter bar: added gallery_show_filter_bar setting and hide the search/ sort row in the public gallery when off, or when the gallery has zero photos (fixes the empty-state UX from the screenshot). - Theme picker unclickable on Create Event: memoised availableEventTypes so its identity is stable. The "auto-apply event-type recommended preset" effect was firing on every render due to the unstable array reference and silently overwriting the user's preset selection ~1ms after each click. - Branding logo disappearing on theme change: handlePresetChange and handleThemeChange no longer wipe the existing logoUrl when a preset config (which carries no logoUrl) is applied; handleSave falls back to brandingSettings.logo_url. themeMutation now invalidates the admin-settings and public-settings caches so saved theme changes appear immediately. --- backend/src/routes/adminEvents.js | 50 ++++++++++++- backend/src/routes/publicSettings.js | 10 ++- .../src/components/gallery/GalleryView.tsx | 7 +- .../settings/hooks/useSettingsState.ts | 10 ++- .../src/features/settings/tabs/EventsTab.tsx | 38 ++++++++++ frontend/src/pages/admin/BrandingPage.tsx | 30 +++++--- frontend/src/pages/admin/CreateEventPage.tsx | 39 +++++++--- frontend/src/pages/admin/EventDetailsPage.tsx | 21 +++--- frontend/src/pages/admin/EventsListPage.tsx | 72 ++++++++++++++++--- .../src/services/publicSettings.service.ts | 2 + frontend/src/utils/url.ts | 27 +++++++ 11 files changed, 258 insertions(+), 48 deletions(-) diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index a3c5085c..c12dadd4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -113,6 +113,31 @@ const getEventFieldRequirements = async () => { } }; +// Helper to read app_settings booleans by key, used to inherit per-setting +// defaults onto new events. Returns `undefined` for missing/non-boolean rows +// so callers can fall back to a legacy default. +const readBooleanSetting = async (key) => { + try { + const setting = await db('app_settings').where('setting_key', key).first(); + if (!setting) return undefined; + let value = setting.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return typeof value === 'boolean' ? value : undefined; + } catch (error) { + logger.error('Failed to read app setting', { key, error: error.message }); + return undefined; + } +}; + +// Helper to read the global "enable_devtools_protection" admin setting so +// new events inherit it instead of always falling back to the DB column default +// (#317 — admin disabled it globally but new events still got it ON). +const getDownloadProtectionDefaults = async () => { + return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; +}; + // Helper to get branding defaults for new events (Feature 7: Branding Inheritance) const getBrandingDefaults = async () => { try { @@ -246,6 +271,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), + body('enable_devtools_protection').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), @@ -291,9 +317,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ upload_category_id = null, allow_downloads = true, disable_right_click = false, + enable_devtools_protection: enableDevtoolsProtectionInput, watermark_downloads = false, watermark_text = null, - require_password: requirePasswordInput = true, + require_password: requirePasswordInput, // Feedback settings feedback_enabled = false, allow_ratings = true, @@ -349,7 +376,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [ return res.status(400).json({ errors: validationErrors }); } - const requirePassword = parseBooleanInput(requirePasswordInput, true); + // Default require_password from global "event_default_require_password" + // setting when the body omits it (#317 — admins want to flip the default). + let requirePasswordFallback = true; + if (requirePasswordInput === undefined) { + const setting = await readBooleanSetting('event_default_require_password'); + if (setting !== undefined) requirePasswordFallback = setting; + } + const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); // Debug logging logger.debug('Download control values', { @@ -457,6 +491,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; + // Inherit "Detect dev tools" from the global Image Security setting unless + // the request explicitly overrides it (#317 — admin disabled it globally + // but new events still got it ON because the column default is true). + const protectionDefaults = await getDownloadProtectionDefaults(); + const effectiveEnableDevtoolsProtection = + enableDevtoolsProtectionInput !== undefined + ? enableDevtoolsProtectionInput + : protectionDefaults.enable_devtools_protection !== undefined + ? protectionDefaults.enable_devtools_protection + : true; + // Insert into database const insertResult = await db('events').insert({ slug, @@ -479,6 +524,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ upload_category_id, allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), + enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, require_password: formatBoolean(requirePassword), diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 23600ce4..ff7441e1 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -13,7 +13,11 @@ router.get('/', async (req, res) => { this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean']) .orWhere('setting_key', 'like', 'analytics_%') .orWhere('setting_key', 'like', 'event_require_%') - .orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']); + .orWhereIn('setting_key', [ + 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', + 'event_default_require_password', + 'gallery_show_filter_bar' + ]); }) .select('setting_key', 'setting_value'); }); @@ -78,6 +82,10 @@ router.get('/', async (req, res) => { event_require_admin_email: settingsObject.event_require_admin_email !== false, event_require_event_date: settingsObject.event_require_event_date !== false, event_require_expiration: settingsObject.event_require_expiration !== false, + // Default value for "Require password" toggle in event creation form + event_default_require_password: settingsObject.event_default_require_password !== false, + // Whether to show the search/sort filter bar in public galleries (default: true) + gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, // Upload settings (safe to expose - needed for client-side validation) allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp', // SEO meta tag flags (safe to expose - these are intended for crawlers) diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 34c41552..1f55dc30 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -847,8 +847,11 @@ export const GalleryView: React.FC = ({ slug, event }) => { )} - {/* Search and Filters - Only for grid layout */} - {!showSidebar ? ( + {/* Search and Filters - Only for grid layout, when admin enables the + filter bar globally, and when the gallery actually has photos + (avoids the empty "Search photos by filename" row in the screenshot + from discussion #317). */} + {!showSidebar && settingsData?.gallery_show_filter_bar !== false && (data?.photos?.length ?? 0) > 0 ? (
= ({
+ +
+ +
+ +
+ +
diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index d0619d25..1fb2e86a 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -71,6 +71,11 @@ export const BrandingPage: React.FC = () => { mutationFn: settingsService.updateTheme, onSuccess: () => { toast.success(t('toast.themeUpdated')); + // Refresh both the admin settings cache (which the page reads from) and + // the public-settings cache (which the gallery reads from) so the saved + // theme is reflected without a manual reload (#317). + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['public-settings'] }); }, onError: () => { toast.error(t('toast.saveError')); @@ -117,13 +122,19 @@ export const BrandingPage: React.FC = () => { }; const handleThemeChange = (newTheme: ThemeConfig) => { - setCurrentTheme(newTheme); - // Also update logo URL in branding settings if it changed - if (newTheme.logoUrl !== currentTheme.logoUrl) { + // Preset configs don't carry a logoUrl, so a preset change inside the + // customizer arrives here with newTheme.logoUrl=undefined. Keep the + // existing logo instead of wiping branding_logo_url on save (#317). + const mergedTheme: ThemeConfig = { + ...newTheme, + logoUrl: newTheme.logoUrl ?? currentTheme.logoUrl + }; + setCurrentTheme(mergedTheme); + if (newTheme.logoUrl !== undefined && newTheme.logoUrl !== currentTheme.logoUrl) { setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' })); } if (isPreviewMode) { - setTheme(newTheme); + setTheme(mergedTheme); } }; @@ -132,9 +143,10 @@ export const BrandingPage: React.FC = () => { // Get the preset theme config const preset = GALLERY_THEME_PRESETS[presetName]; if (preset) { - setCurrentTheme(preset.config); + // Preserve the existing logo when switching presets (#317). + setCurrentTheme(prev => ({ ...preset.config, logoUrl: prev.logoUrl })); if (isPreviewMode) { - setTheme(preset.config); + setTheme({ ...preset.config, logoUrl: currentTheme.logoUrl }); } } }; @@ -201,10 +213,12 @@ export const BrandingPage: React.FC = () => { const handleSave = async () => { try { - // Sync logo URL from theme to branding settings + // Sync logo URL from theme to branding settings, but never let an + // undefined/empty theme.logoUrl wipe a logo that is still configured in + // branding settings (#317 — preset selection does not imply logo removal). const updatedBrandingSettings = { ...brandingSettings, - logo_url: currentTheme.logoUrl || '' + logo_url: currentTheme.logoUrl || brandingSettings.logo_url || '' }; // Save branding settings to database diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 0a9624de..ce30d945 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Calendar, @@ -146,15 +146,21 @@ export const CreateEventPage: React.FC = () => { queryFn: () => eventTypesService.getActiveEventTypes() }); - // Compute event types to use (API data or fallback) - const availableEventTypes = eventTypes?.length - ? eventTypes.map(et => ({ - value: et.slug_prefix, - name: et.name, - emoji: et.emoji, - theme_preset: et.theme_preset - })) - : FALLBACK_EVENT_TYPES; + // Compute event types to use (API data or fallback). Memoised so its + // identity is stable across renders — otherwise the "Update theme when + // event type changes" effect below re-runs on every render and silently + // overwrites the user's Theme Preset selection (#317). + const availableEventTypes = useMemo( + () => (eventTypes?.length + ? eventTypes.map(et => ({ + value: et.slug_prefix, + name: et.name, + emoji: et.emoji, + theme_preset: et.theme_preset + })) + : FALLBACK_EVENT_TYPES), + [eventTypes] + ); // Fetch default settings const { data: settings } = useQuery({ @@ -185,6 +191,19 @@ export const CreateEventPage: React.FC = () => { } }, [settings]); + // Honour the global "Require password by default" admin setting (#317). + // Apply once when public settings first load, before the user has interacted. + const requirePasswordDefaultApplied = useRef(false); + useEffect(() => { + if (requirePasswordDefaultApplied.current) return; + if (publicSettings?.event_default_require_password === undefined) return; + requirePasswordDefaultApplied.current = true; + setFormData(prev => ({ + ...prev, + require_password: publicSettings.event_default_require_password !== false + })); + }, [publicSettings]); + // Update theme when event type changes useEffect(() => { // Find the selected event type's theme preset diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 5fb14cfa..99132d66 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -58,7 +58,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { publicSettingsService } from '../../services/publicSettings.service'; import { api } from '../../config/api'; -import { buildResourceUrl } from '../../utils/url'; +import { buildResourceUrl, buildShareLinkUrl } from '../../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { archiveService } from '../../services/archive.service'; import { externalMediaService } from '../../services/externalMedia.service'; @@ -67,13 +67,6 @@ import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../.. import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; -const resolveShareLink = (link: string): string => { - if (!link) return '#'; - if (link.startsWith('http')) return link; - if (link.startsWith('/')) return link; - return `/gallery/${link}`; -}; - const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => { const { t } = useTranslation(); const [entries, setEntries] = useState<{ path: string; entries: any[]; canNavigateUp: boolean } | null>(null); @@ -657,13 +650,15 @@ export const EventDetailsPage: React.FC = () => { return; } + const shareUrl = buildShareLinkUrl(event.share_link); + // Try modern clipboard API first if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(event.share_link); + await navigator.clipboard.writeText(shareUrl); } else { // Fallback for non-HTTPS contexts or older browsers const textArea = document.createElement('textarea'); - textArea.value = event.share_link; + textArea.value = shareUrl; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; @@ -793,8 +788,8 @@ export const EventDetailsPage: React.FC = () => { {event.share_link && !isEditing && ( {
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index a3ca6e1a..f09cf903 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -12,7 +12,9 @@ import { Trash2, Calendar, Image, - Activity + Activity, + Copy, + CheckCircle } from 'lucide-react'; import { parseISO, differenceInDays } from 'date-fns'; import { toast } from 'react-toastify'; @@ -23,16 +25,10 @@ import { BulkArchiveModal } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { isGalleryPublic } from '../../utils/accessControl'; +import { buildShareLinkUrl } from '../../utils/url'; import type { Event } from '../../types'; import { useTranslation } from 'react-i18next'; -const resolveShareLink = (link: string): string => { - if (!link) return '#'; - if (link.startsWith('http')) return link; - if (link.startsWith('/')) return link; - return `/gallery/${link}`; -}; - export const EventsListPage: React.FC = () => { const { t } = useTranslation(); const { format } = useLocalizedDate(); @@ -46,6 +42,35 @@ export const EventsListPage: React.FC = () => { const [activeDropdown, setActiveDropdown] = useState(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); + const [copiedEventId, setCopiedEventId] = useState(null); + + const copyShareLink = async (event: Event) => { + const url = buildShareLinkUrl(event.share_link); + if (!url || url === '#') { + toast.error(t('errors.noShareLink', 'No share link available')); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(url); + } else { + const textArea = document.createElement('textarea'); + textArea.value = url; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textArea); + if (!ok) throw new Error('Copy failed'); + } + setCopiedEventId(event.id); + toast.success(t('events.linkCopied', 'Gallery link copied')); + setTimeout(() => setCopiedEventId((current) => (current === event.id ? null : current)), 2000); + } catch { + toast.error(t('errors.copyFailed', 'Failed to copy link')); + } + }; // Get filter from URL const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null; @@ -489,12 +514,26 @@ export const EventsListPage: React.FC = () => { )} + {event.share_link && ( + + )}
{/* Context menu for additional actions */} @@ -539,7 +578,7 @@ export const EventsListPage: React.FC = () => { {event.share_link ? (
{ {t('events.viewGallery')} ) : null} + {event.share_link ? ( + + ) : null} {!event.is_archived ? (