feat(slideshow): backend api for live slideshow

- public GET /gallery/:slug/show/:token/session (validates token, mints a
  slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests
  authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
  the list; skip the view-log for slideshow access so the kiosk does not pollute
  visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
  event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
  (global default -> per-event override) resolving the light/dark/favicon/event
  logo url.
This commit is contained in:
Luca
2026-06-19 22:10:56 +02:00
parent 1029dd05bd
commit dea5e0f8a6
5 changed files with 418 additions and 8 deletions
+4
View File
@@ -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) => {
+198
View File
@@ -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 {
+38
View File
@@ -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 {
+171 -7
View File
@@ -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'
});
// <img> 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 = {
+7 -1
View File
@@ -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;
}