diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js index f18f4286..db94b4ae 100644 --- a/backend/src/routes/adminEventTypes.js +++ b/backend/src/routes/adminEventTypes.js @@ -89,6 +89,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ body('emoji').optional().trim(), body('theme_preset').optional().trim(), body('theme_config').optional(), + body('slideshow_preset').optional(), body('display_order').optional().isInt({ min: 0 }) ], async (req, res) => { try { @@ -103,6 +104,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ emoji, theme_preset, theme_config, + slideshow_preset, display_order } = req.body; @@ -112,6 +114,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ emoji, theme_preset, theme_config, + slideshow_preset, display_order }); @@ -150,6 +153,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [ body('emoji').optional().trim(), body('theme_preset').optional().trim(), body('theme_config').optional(), + body('slideshow_preset').optional(), body('display_order').optional().isInt({ min: 0 }), body('is_active').optional().isBoolean() ], async (req, res) => { diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index fc880c6b..f7e513ae 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -672,10 +672,52 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); // Insert into database + // Seed the new event's Live Slideshow settings from the event TYPE's preset + // (migration 138). The admin configures "weddings fade slowly with our + // white logo, sepia" once on the type; every new wedding inherits it. The + // share token is NOT seeded — the link is still minted on demand. Guarded + // so un-migrated installs (mid-branch) don't reference missing columns. + let slideshowSeed = {}; + if (await hasColumnCached('events', 'show_interval_ms')) { + try { + const type = await eventTypeService.getEventTypeBySlugPrefix(event_type); + const preset = type?.slideshow_preset + ? (typeof type.slideshow_preset === 'string' ? JSON.parse(type.slideshow_preset) : type.slideshow_preset) + : null; + if (preset && typeof preset === 'object') { + const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); + const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); + // Tri-state watermark: 'on'/'off' seed an explicit override; 'inherit' + // (or unset) leaves the column NULL so the event follows the global. + let watermarkSeed; + if (preset.watermark === 'on' || preset.watermark === true) watermarkSeed = formatBoolean(true); + else if (preset.watermark === 'off' || preset.watermark === false) watermarkSeed = formatBoolean(false); + const seed = { + show_interval_ms: intP(preset.interval_ms, 1000, 120000), + show_transition: oneOf(preset.transition, SLIDESHOW_TRANSITIONS), + show_transition_ms: intP(preset.transition_ms, 100, 5000), + show_watermark: watermarkSeed, + show_watermark_source: oneOf(preset.watermark_source, SLIDESHOW_WATERMARK_SOURCES), + show_watermark_position: oneOf(preset.watermark_position, SLIDESHOW_WATERMARK_POSITIONS), + show_watermark_opacity: intP(preset.watermark_opacity, 0, 100), + show_watermark_style: oneOf(preset.watermark_style, SLIDESHOW_WATERMARK_STYLES), + show_colorfilter: oneOf(preset.colorfilter, SLIDESHOW_COLORFILTERS), + }; + // Only carry through fields the preset actually set. + for (const [k, v] of Object.entries(seed)) { + if (v !== undefined) slideshowSeed[k] = v; + } + } + } catch (e) { + logger.warn('Failed to seed slideshow settings from event type preset', { error: e.message }); + } + } + const insertResult = await db('events').insert({ slug, event_type, event_name, + ...slideshowSeed, event_date: event_date || null, ...(calendarColumnsExist ? { event_time_start: calendarTriple.event_time_start, @@ -1854,6 +1896,162 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r } }); +// --------------------------------------------------------------------------- +// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live +// events that auto-picks-up new uploads (migration 137). Mirrors the +// client-access second-token pattern: the link is minted on demand, rotatable +// and disable-able, independent of the gallery password / share link. +// --------------------------------------------------------------------------- + +// Allowed slide transition styles (kept in sync with the SlideshowPage). +// dipwhite/dipblack = fade through highlights / lowlights between images. +const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; +// Allowed per-slide color filters. +const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; +// Allowed watermark logo sources + corners. +const SLIDESHOW_WATERMARK_SOURCES = ['logo', 'logo_dark', 'favicon', 'event']; +const SLIDESHOW_WATERMARK_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; +// 'white' recolors the logo white; 'original' keeps its own colors (for boxed +// / colored logos that would otherwise whiten into a solid blob). +const SLIDESHOW_WATERMARK_STYLES = ['white', 'original']; + +// Build the public slideshow URL for a freshly-minted/existing token. +async function buildSlideshowUrl(slug, token) { + if (!token) return null; + const base = await getFrontendBaseUrl(); + return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`; +} + +// Fetch the event respecting the editor-role ownership scope (requireEventOwnership +// already gates the route; this re-applies the created_by filter for editors so the +// 404 is identical to the rest of this file). +async function loadOwnedEvent(req) { + let q = db('events').where('id', req.params.id); + if (req.admin.roleName === 'editor') { + q = q.where('created_by', req.admin.id); + } + return q.first(); +} + +// Generate (or rotate) the slideshow share token. Idempotent in intent: each +// call mints a fresh token, which both "Generate" (first time) and "Regenerate" +// (rotate, kills the old link) use. +router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + await db('events').where('id', req.params.id).update({ + show_share_token: token, + updated_at: new Date() + }); + + await logActivity('slideshow_link_generated', + { eventName: event.event_name, rotated: Boolean(event.show_share_token) }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + show_share_token: token, + slideshow_url: await buildSlideshowUrl(event.slug, token) + }); + } catch (error) { + logger.error('Error generating slideshow link', { error: error.message }); + res.status(500).json({ error: 'Failed to generate slideshow link' }); + } +}); + +// Disable the slideshow link (null the token). The public /show/ route dies on +// its next poll, killing any projector currently pointed at the old link. +router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + await db('events').where('id', req.params.id).update({ + show_share_token: null, + updated_at: new Date() + }); + + await logActivity('slideshow_link_disabled', + { eventName: event.event_name }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ show_share_token: null }); + } catch (error) { + logger.error('Error disabling slideshow link', { error: error.message }); + res.status(500).json({ error: 'Failed to disable slideshow link' }); + } +}); + +// Update the LIVE slideshow settings (display time / transition style / speed). +// A running projector picks these up via the show-page settings poll within a +// few seconds — no need to regenerate the link. +router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ + body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }), + body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), + body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), + body('show_watermark').optional({ nullable: true }), + body('show_watermark_source').optional().isIn(SLIDESHOW_WATERMARK_SOURCES), + body('show_watermark_position').optional().isIn(SLIDESHOW_WATERMARK_POSITIONS), + body('show_watermark_opacity').optional().isInt({ min: 0, max: 100 }), + body('show_watermark_style').optional().isIn(SLIDESHOW_WATERMARK_STYLES), + body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); + } + + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const updates = { updated_at: new Date() }; + if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10); + if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition; + if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10); + // Tri-state: explicit null = inherit the global default. + if (req.body.show_watermark !== undefined) { + updates.show_watermark = req.body.show_watermark === null + ? null + : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); + } + if (req.body.show_watermark_source !== undefined) updates.show_watermark_source = req.body.show_watermark_source; + if (req.body.show_watermark_position !== undefined) updates.show_watermark_position = req.body.show_watermark_position; + if (req.body.show_watermark_opacity !== undefined) updates.show_watermark_opacity = parseInt(req.body.show_watermark_opacity, 10); + if (req.body.show_watermark_style !== undefined) updates.show_watermark_style = req.body.show_watermark_style; + if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; + + await db('events').where('id', req.params.id).update(updates); + + res.json({ + show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, + show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', + show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, + show_watermark: updates.show_watermark ?? event.show_watermark ?? false, + show_watermark_source: updates.show_watermark_source ?? event.show_watermark_source ?? 'logo', + show_watermark_position: updates.show_watermark_position ?? event.show_watermark_position ?? 'bottom-right', + show_watermark_opacity: updates.show_watermark_opacity ?? event.show_watermark_opacity ?? 60, + show_watermark_style: updates.show_watermark_style ?? event.show_watermark_style ?? 'white', + show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' + }); + } catch (error) { + logger.error('Error updating slideshow settings', { error: error.message }); + res.status(500).json({ error: 'Failed to update slideshow settings' }); + } +}); + // Reset event password router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { try { diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 8402fcdd..a762e2de 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -290,6 +290,44 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async ( } }); +// Global Live Slideshow defaults (migration 138). The per-event watermark is +// tri-state (events.show_watermark NULL = inherit these). Read via the generic +// GET /:type ('slideshow'); this is the typed write. +router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const updates = []; + const push = (key, value) => updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'slideshow' }); + const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k); + + if (has('slideshow_watermark_enabled')) push('slideshow_watermark_enabled', !!req.body.slideshow_watermark_enabled); + if (has('slideshow_watermark_source')) { + const v = ['logo', 'logo_dark', 'favicon', 'event'].includes(req.body.slideshow_watermark_source) ? req.body.slideshow_watermark_source : 'logo'; + push('slideshow_watermark_source', v); + } + if (has('slideshow_watermark_position')) { + const allowed = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + const v = allowed.includes(req.body.slideshow_watermark_position) ? req.body.slideshow_watermark_position : 'bottom-right'; + push('slideshow_watermark_position', v); + } + if (has('slideshow_watermark_opacity')) { + const n = Math.min(100, Math.max(0, Math.round(Number(req.body.slideshow_watermark_opacity) || 0))); + push('slideshow_watermark_opacity', n); + } + if (has('slideshow_watermark_style')) { + const v = ['white', 'original'].includes(req.body.slideshow_watermark_style) ? req.body.slideshow_watermark_style : 'white'; + push('slideshow_watermark_style', v); + } + + for (const u of updates) { + await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); + } + res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) }); + } catch (error) { + console.error('Slideshow settings save error:', error); + res.status(500).json({ error: 'Failed to save slideshow settings' }); + } +}); + // Get settings by type router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { try { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 0ea362e0..5bc70798 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1,4 +1,5 @@ const express = require('express'); +const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const archiver = require('archiver'); @@ -24,6 +25,8 @@ const { } = require('../services/downloadFilenameService'); const { buildContentDisposition } = require('../utils/filenameSanitizer'); const { getStorage } = require('../services/storage'); +const { setGalleryAuthCookies } = require('../utils/tokenUtils'); +const { getSetting } = require('../services/settingsService'); const fs = require('fs'); // Get storage path from environment or default @@ -212,6 +215,162 @@ router.get('/:slug/info', async (req, res) => { } }); +// --------------------------------------------------------------------------- +// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface +// (migration 137). The token in the URL IS the secret (no gallery password), +// so these routes are unauthenticated except for the token match itself. The +// slideshow shows ALL public/visible, finished photos — exactly the guest +// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT, +// the page reuses the normal /photos + image endpoints unchanged. +// --------------------------------------------------------------------------- + +// Photos a slideshow may display: published, finished, non-hidden. Mirrors the +// guest filter in GET /:slug/photos so the live count matches the rendered set. +function slideshowPhotosQuery(eventId) { + return db('photos') + .where('photos.event_id', eventId) + .where(function() { + this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); + }) + .where(function() { + this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); + }); +} + +// Resolve an active slideshow by slug + token. Returns the event row, or null +// when the link is missing/rotated/disabled or the gallery isn't live (archived +// / draft / inactive / expired) — every one of those collapses to a 404 so a +// dead link reveals nothing and stops any projector on its next poll. +async function resolveSlideshow(slug, token) { + if (!token) return null; + const event = await db('events') + .where({ + slug, + show_share_token: token, + is_active: formatBoolean(true), + is_archived: formatBoolean(false), + is_draft: formatBoolean(false) + }) + .first(); + if (!event) return null; + if (event.expires_at && new Date(event.expires_at) < new Date()) return null; + return event; +} + +// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style +// watermark (a white, semi-transparent corner logo). The logo URL is resolved +// from the chosen source so the kiosk renders it without knowing about +// branding/event internals; null url = nothing to overlay. +async function slideshowSettings(event) { + // Watermark cascade: per-event `show_watermark` overrides the global default, + // NULL inherits it. When inheriting, the source/position/opacity also come + // from the global settings; when overriding, from the event's own columns. + const wm = event.show_watermark; + const inherit = (wm === null || wm === undefined); + const enabled = inherit + ? (await getSetting('slideshow_watermark_enabled', false)) === true + : (wm === true || wm === 1 || wm === '1'); + let watermark = null; + if (enabled) { + const source = inherit + ? (await getSetting('slideshow_watermark_source', 'logo')) + : (event.show_watermark_source || 'logo'); + const position = inherit + ? (await getSetting('slideshow_watermark_position', 'bottom-right')) + : (event.show_watermark_position || 'bottom-right'); + const opacity = inherit + ? (await getSetting('slideshow_watermark_opacity', 60)) + : (event.show_watermark_opacity ?? 60); + const style = inherit + ? (await getSetting('slideshow_watermark_style', 'white')) + : (event.show_watermark_style || 'white'); + // Resolve the chosen logo to a URL. Branding assets come from settings; + // the event source uses the event's own hero logo. + let url; + if (source === 'event') { + url = event.hero_logo_url || null; + } else if (source === 'logo_dark') { + url = await getSetting('branding_logo_url_dark', null); + } else if (source === 'favicon') { + url = await getSetting('branding_favicon_url', null); + } else { + url = await getSetting('branding_logo_url', null); + } + if (url) { + watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white' }; + } + } + return { + interval_ms: event.show_interval_ms || 5000, + transition: event.show_transition || 'crossfade', + transition_ms: event.show_transition_ms || 800, + colorfilter: event.show_colorfilter || 'none', + watermark, + }; +} + +// Open a slideshow session: validate the token and mint a short-lived gallery +// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo / +// image endpoints → visible photos only, no client-only/hidden). The page +// stores this token and the existing axios interceptor injects it. +router.get('/:slug/show/:token/session', handleAsync(async (req, res) => { + const { slug, token } = req.params; + const event = await resolveSlideshow(slug, token); + if (!event) { + throw new NotFoundError('Slideshow'); + } + + const sessionToken = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + accessLevel: 'slideshow', + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '12h', + issuer: 'picpeak-auth' + }); + + // tags can't carry an Authorization header, so the photo/thumbnail/ + // preview endpoints authenticate via the per-slug gallery cookie. Set it + // here so the kiosk's image requests are authorized with zero extra wiring. + setGalleryAuthCookies(res, sessionToken, event.slug); + + const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count'); + + res.json({ + token: sessionToken, + event: { + event_name: event.event_name, + event_type: event.event_type, + color_theme: event.color_theme + }, + settings: await slideshowSettings(event), + photo_count: parseInt(count, 10) || 0, + expires_at: event.expires_at || null + }); +})); + +// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show): +// current settings + the visible photo count. The page diffs photo_count to +// decide when to refetch the full list, and re-reads settings so admin changes +// take effect live. A dead/disabled link 404s here → the projector stops. +router.get('/:slug/show/:token/state', handleAsync(async (req, res) => { + const { slug, token } = req.params; + const event = await resolveSlideshow(slug, token); + if (!event) { + throw new NotFoundError('Slideshow'); + } + + const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count'); + + res.json({ + ...(await slideshowSettings(event)), + photo_count: parseInt(count, 10) || 0, + expires_at: event.expires_at || null + }); +})); + // Get all photos router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => { try { @@ -418,13 +577,18 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) categoryMap[cat.id] = cat; }); - // Log view - await db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'view' - }); + // Log view — but NOT for the Live Slideshow kiosk. A running projector + // refetches this list on every new-upload poll, which would massively + // inflate total_views / unique_visitors. The slideshow is explicitly + // excluded from real visitor analytics (migration 137 design). + if (req.accessLevel !== 'slideshow') { + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'view' + }); + } // Include protection settings in response const protectionSettings = { diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index 1dbd8308..0ba052b4 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -100,7 +100,8 @@ const createEventType = async (eventTypeData) => { emoji, theme_preset, theme_config, - display_order + display_order, + slideshow_preset } = eventTypeData; // Normalize slug_prefix @@ -127,6 +128,7 @@ const createEventType = async (eventTypeData) => { emoji: emoji || '📷', theme_preset: theme_preset || 'default', theme_config: theme_config ? JSON.stringify(theme_config) : null, + slideshow_preset: slideshow_preset ? JSON.stringify(slideshow_preset) : null, display_order: finalDisplayOrder, is_system: false, is_active: true, @@ -191,6 +193,10 @@ const updateEventType = async (id, updates) => { updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null; } + if (updates.slideshow_preset !== undefined) { + updateData.slideshow_preset = updates.slideshow_preset ? JSON.stringify(updates.slideshow_preset) : null; + } + if (updates.display_order !== undefined) { updateData.display_order = updates.display_order; }