Merge pull request #319 from the-luap/feat/prezip-and-photo-replace

fix: discussion #317 issues and #318 archive crash
This commit is contained in:
Paul Nothaft
2026-04-26 22:51:46 +02:00
committed by GitHub
12 changed files with 293 additions and 72 deletions
+48 -2
View File
@@ -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),
+9 -1
View File
@@ -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)
+35 -24
View File
@@ -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);
@@ -847,8 +847,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</div>
)}
{/* 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 ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
@@ -50,6 +50,8 @@ export interface EventSettings {
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
event_default_require_password: boolean;
gallery_show_filter_bar: boolean;
}
export interface SeoSettings {
@@ -123,7 +125,9 @@ export function useSettingsState() {
event_require_customer_email: true,
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
event_require_expiration: true,
event_default_require_password: true,
gallery_show_filter_bar: true
});
// SEO settings state
@@ -206,7 +210,9 @@ export function useSettingsState() {
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true),
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true)
});
setSeoSettings({
@@ -149,6 +149,44 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_default_require_password}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_require_password: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.defaultRequirePassword', 'Require password by default')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.defaultRequirePasswordHelp', 'Pre-check "Require password" when creating new events. Disable for quicker creation of public galleries.')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.gallery_show_filter_bar}
onChange={(e) => setEventSettings(prev => ({ ...prev, gallery_show_filter_bar: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.showGalleryFilterBar', 'Show filter bar in galleries')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.showGalleryFilterBarHelp', 'Display the search-by-filename and sort controls above grid-layout galleries. Disable for a cleaner layout.')}
</p>
</div>
</label>
</div>
</div>
<div className="mt-6">
+22 -8
View File
@@ -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
+29 -10
View File
@@ -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
+8 -13
View File
@@ -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 && (
<a
href={event.is_draft
? `${resolveShareLink(event.share_link)}${resolveShareLink(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: resolveShareLink(event.share_link)
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: buildShareLinkUrl(event.share_link)
}
target="_blank"
rel="noopener noreferrer"
@@ -1604,7 +1599,7 @@ export const EventDetailsPage: React.FC = () => {
<div className="flex items-center gap-2">
<input
type="text"
value={event.share_link}
value={buildShareLinkUrl(event.share_link)}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
/>
+62 -10
View File
@@ -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<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
const [copiedEventId, setCopiedEventId] = useState<number | null>(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 = () => {
<Button
variant="ghost"
size="sm"
onClick={() => window.open(resolveShareLink(event.share_link), '_blank')}
onClick={() => window.open(buildShareLinkUrl(event.share_link), '_blank')}
title={t('events.viewGallery')}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
{event.share_link && (
<Button
variant="ghost"
size="sm"
onClick={() => copyShareLink(event)}
title={t('events.copyLink', 'Copy Link')}
>
{copiedEventId === event.id ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
)}
</div>
{/* Context menu for additional actions */}
@@ -539,7 +578,7 @@ export const EventsListPage: React.FC = () => {
</button>
{event.share_link ? (
<a
href={resolveShareLink(event.share_link)}
href={buildShareLinkUrl(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
@@ -552,6 +591,19 @@ export const EventsListPage: React.FC = () => {
{t('events.viewGallery')}
</a>
) : null}
{event.share_link ? (
<button
onClick={() => {
copyShareLink(event);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<Copy className="w-4 h-4" />
{t('events.copyLink', 'Copy Link')}
</button>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
@@ -31,6 +31,8 @@ export interface PublicSettings {
event_require_admin_email?: boolean;
event_require_event_date?: boolean;
event_require_expiration?: boolean;
event_default_require_password?: boolean;
gallery_show_filter_bar?: boolean;
}
export const publicSettingsService = {
+27
View File
@@ -132,3 +132,30 @@ export const isProductionMode = (): boolean => {
const apiBase = getApiBaseUrl();
return !ABSOLUTE_URL_REGEX.test(apiBase);
};
/**
* Build a fully-qualified gallery share URL from the value stored in
* `events.share_link`. The DB stores the relative path (e.g.
* `/gallery/<slug>/<token>`); for display and clipboard copy we need an
* absolute URL the recipient can paste into a browser. Absolute inputs are
* passed through (with the same localhost-fallback rule used elsewhere).
*
* @param link - The relative or absolute share link
* @returns A fully-qualified URL, or `'#'` if input is empty
*/
export const buildShareLinkUrl = (link: string | null | undefined): string => {
if (!link) return '#';
if (ABSOLUTE_URL_REGEX.test(link)) {
if (!shouldFallbackToRelative(link)) return link;
try {
const parsed = new URL(link);
return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`);
} catch {
return link;
}
}
const path = link.startsWith('/') ? link : `/gallery/${link}`;
return buildFromOrigin(path);
};