diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 9dd109a..f338798 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -3,6 +3,7 @@ const path = require('path'); const knex = require('knex'); const knexConfig = require('../../knexfile'); const logger = require('../utils/logger'); +const { extractShareToken } = require('../utils/shareLinkUtils'); // Ensure SQLite directory exists when using file-based DB (native installs) try { @@ -66,11 +67,13 @@ async function initializeDatabase() { table.string('customer_name'); table.string('customer_email'); table.string('host_email').notNullable(); + table.string('host_name'); table.string('admin_email').notNullable(); table.string('password_hash').notNullable(); table.text('welcome_message'); table.text('color_theme'); table.string('share_link').unique().notNullable(); + table.string('share_token').unique(); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('expires_at').notNullable(); table.boolean('is_active').defaultTo(true); @@ -103,12 +106,14 @@ async function initializeDatabase() { event_date DATE NOT NULL, customer_name TEXT, customer_email TEXT, + host_name TEXT, host_email TEXT NOT NULL, admin_email TEXT NOT NULL, password_hash TEXT NOT NULL, welcome_message TEXT, color_theme TEXT, share_link TEXT UNIQUE NOT NULL, + share_token TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, is_active BOOLEAN DEFAULT 1, @@ -161,6 +166,37 @@ async function initializeDatabase() { } } + const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token'); + if (!hasShareTokenColumn) { + await db.schema.table('events', (table) => { + table.string('share_token').unique(); + }); + } + + const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name'); + if (!hasHostNameColumn) { + await db.schema.table('events', (table) => { + table.string('host_name'); + }); + } + + try { + const eventsWithoutToken = await db('events') + .whereNull('share_token') + .select('id', 'share_link'); + + for (const event of eventsWithoutToken) { + const token = extractShareToken(event.share_link); + if (token) { + await db('events') + .where({ id: event.id }) + .update({ share_token: token }); + } + } + } catch (error) { + logger.warn('Share token backfill skipped', { error: error.message }); + } + // Photo metadata table const hasPhotosTable = await db.schema.hasTable('photos'); if (!hasPhotosTable) { diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index 919b97e..f6ad324 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -2,6 +2,7 @@ // Only the relevant parts are shown - merge with existing adminEvents.js const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); +const { buildShareLinkVariants } = require('../services/shareLinkService'); // Enhanced event creation with password validation router.post('/', adminAuth, [ @@ -65,9 +66,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link + // Generate share link based on configured style const shareToken = crypto.randomBytes(16).toString('hex'); - const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password with configurable rounds const password_hash = await bcrypt.hash(password, getBcryptRounds()); @@ -96,7 +97,8 @@ router.post('/', adminAuth, [ password_hash, welcome_message, color_theme, - share_link: shareLink, + share_link: shareLinkToStore, + share_token: shareToken, expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), allow_user_uploads, diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index acb6834..b3ce0d4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity'); // formatDate import removed - dates are formatted by email processor const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const logger = require('../utils/logger'); +const { buildShareLinkVariants } = require('../services/shareLinkService'); const parseBooleanInput = (value, defaultValue = true) => { if (value === undefined || value === null) { @@ -225,11 +226,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link + // Generate share link respecting configured format const shareToken = crypto.randomBytes(16).toString('hex'); - const sharePath = `/gallery/${slug}/${shareToken}`; - const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); - const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; + const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password with configurable rounds (random placeholder when not required) const password_hash = requirePassword @@ -266,7 +265,8 @@ router.post('/', adminAuth, [ password_hash, welcome_message, color_theme, - share_link: shareLink, + share_link: shareLinkToStore, + share_token: shareToken, expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), allow_user_uploads, @@ -318,7 +318,7 @@ router.post('/', adminAuth, [ host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), event_name, event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareLink, + gallery_link: shareUrl, gallery_password: requirePassword ? password : 'No password required', expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor welcome_message: welcome_message || '' @@ -336,7 +336,7 @@ router.post('/', adminAuth, [ customer_name: customerName, customer_email: customerEmail, require_password: requirePassword, - share_link: shareLink, + share_link: shareUrl, expires_at: expires_at.toISOString(), created_at: new Date().toISOString() }); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 8905bc8..56726d3 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -18,6 +18,7 @@ const { getRawPublicSiteSettings, } = require('../services/publicSiteService'); const { sanitizeCss } = require('../utils/cssSanitizer'); +const { clearShareLinkSettingsCache } = require('../services/shareLinkService'); const router = express.Router(); const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings'); @@ -548,6 +549,9 @@ router.put('/general', adminAuth, async (req, res) => { if (uploadLimitTouched) { clearMaxFilesPerUploadCache(); } + if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { + clearShareLinkSettingsCache(); + } // Log activity await db('activity_logs').insert({ diff --git a/backend/src/routes/auth-enhanced.js b/backend/src/routes/auth-enhanced.js index f5bbc03..408b6c4 100644 --- a/backend/src/routes/auth-enhanced.js +++ b/backend/src/routes/auth-enhanced.js @@ -22,6 +22,7 @@ const { getAdminTokenFromRequest, getGalleryTokenFromRequest, } = require('../utils/tokenUtils'); +const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService'); const router = express.Router(); // Admin login with enhanced security @@ -284,18 +285,22 @@ router.post('/gallery/share-login', [ const ipAddress = req.ip || req.connection.remoteAddress; const userAgent = req.headers['user-agent'] || ''; - const event = await db('events') + let event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .first(); + if (!event) { + const resolved = await resolveShareIdentifier(slug); + if (resolved?.event) { + event = resolved.event; + } + } + if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } - let expectedToken = event.share_link; - if (expectedToken && expectedToken.includes('/')) { - expectedToken = expectedToken.split('/').pop(); - } + const expectedToken = getEventShareToken(event); if (!expectedToken || token !== expectedToken) { return res.status(401).json({ error: 'Invalid or expired share link' }); @@ -312,7 +317,7 @@ router.post('/gallery/share-login', [ issuer: 'picpeak-auth' }); - await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); + await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent); setGalleryAuthCookies(res, jwtToken, event.slug); const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index a7d6c91..a431373 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2'); const fs = require('fs').promises; const path = require('path'); const router = express.Router(); +const { buildShareLinkVariants } = require('../services/shareLinkService'); const parseBooleanInput = (value, defaultValue = true) => { if (value === undefined || value === null) { @@ -160,12 +161,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link (just slug/token, not full URL) + // Generate share link variants (auto-detects short URL preference) const shareToken = crypto.randomBytes(16).toString('hex'); - const sharePath = `/gallery/${slug}/${shareToken}`; - const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); - const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath; - const shareLinkSlug = `${slug}/${shareToken}`; + const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); // Hash password (or placeholder when not required) const password_hash = requirePassword @@ -195,7 +193,8 @@ router.post('/', adminAuth, [ password_hash, welcome_message, color_theme, - share_link: shareLinkSlug, + share_link: shareLinkToStore, + share_token: shareToken, expires_at, require_password: formatBoolean(requirePassword) }).returning('id'); @@ -211,7 +210,7 @@ router.post('/', adminAuth, [ host_name: customerName, event_name, event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: fullShareLink, + gallery_link: shareUrl, gallery_password: requirePassword ? password : 'No password required', expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor welcome_message: welcome_message || '' @@ -220,7 +219,7 @@ router.post('/', adminAuth, [ res.json({ id: eventId, slug, - share_link: fullShareLink, + share_link: shareUrl, expires_at, require_password: requirePassword, customer_name: customerName, diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index da25787..31391c6 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); +const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +// Resolve gallery identifier (slug or token) to canonical data +router.get('/resolve/:identifier', async (req, res) => { + try { + const { identifier } = req.params; + const result = await resolveShareIdentifier(identifier); + + if (!result) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + const { event, matchType, shareToken } = result; + const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + res.json({ + slug: event.slug, + token: shareToken, + matchType, + share_link: event.share_link, + share_path: linkVariants.sharePath, + share_url: linkVariants.shareUrl, + short_enabled: linkVariants.shortEnabled, + requires_password: requiresPassword + }); + } catch (error) { + logger.error('Error resolving gallery identifier:', error); + res.status(500).json({ error: 'Failed to resolve gallery link' }); + } +}); + // Verify share token router.get('/:slug/verify-token/:token', async (req, res) => { try { @@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => { const event = await db('events') .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .select('id', 'share_link') + .select('id', 'share_link', 'share_token') .first(); if (!event) { return res.status(404).json({ error: 'Gallery not found' }); } - // Extract token from share link and verify - const expectedToken = event.share_link.split('/').pop(); + const expectedToken = getEventShareToken(event); if (token !== expectedToken) { return res.status(404).json({ error: 'Invalid gallery link' }); } @@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => { 'is_active', 'is_archived', 'share_link', + 'share_token', 'allow_downloads', 'disable_right_click', 'watermark_downloads', @@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => { // If token provided, verify it matches the share link if (token) { - let expectedToken = event.share_link; - // Handle both formats: full URL or just token - if (event.share_link && event.share_link.includes('/')) { - expectedToken = event.share_link.split('/').pop(); - } - if (token !== expectedToken) { + const expectedToken = getEventShareToken(event); + if (!expectedToken || token !== expectedToken) { return res.status(404).json({ error: 'Invalid gallery link' }); } } diff --git a/backend/src/services/shareLinkService.js b/backend/src/services/shareLinkService.js new file mode 100644 index 0000000..e8e1fb7 --- /dev/null +++ b/backend/src/services/shareLinkService.js @@ -0,0 +1,181 @@ +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils'); + +const SETTING_KEY = 'general_short_gallery_urls'; +const CACHE_TTL_MS = 60_000; + +let cachedSetting = null; +let cacheExpiresAt = 0; + +const parseSettingValue = (rawValue) => { + if (rawValue === undefined || rawValue === null) { + return null; + } + + if (typeof rawValue === 'boolean') { + return rawValue; + } + + if (typeof rawValue === 'number') { + return rawValue !== 0; + } + + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (!trimmed) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + return parseSettingValue(parsed); + } catch { + const normalized = trimmed.toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false; + } + return null; + } + } + + if (typeof rawValue === 'object') { + try { + return parseSettingValue(JSON.parse(JSON.stringify(rawValue))); + } catch { + return null; + } + } + + return null; +}; + +const getRawSettingValue = async () => { + try { + const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first(); + return setting?.setting_value ?? null; + } catch (error) { + console.error('Failed to read gallery URL setting:', error.message); + return null; + } +}; + +const isShortGalleryUrlsEnabled = async () => { + if (cachedSetting !== null && Date.now() < cacheExpiresAt) { + return cachedSetting; + } + + const rawValue = await getRawSettingValue(); + const parsed = parseSettingValue(rawValue); + cachedSetting = parsed === null ? false : Boolean(parsed); + cacheExpiresAt = Date.now() + CACHE_TTL_MS; + return cachedSetting; +}; + +const clearShareLinkSettingsCache = () => { + cachedSetting = null; + cacheExpiresAt = 0; +}; + +const buildShareLinkVariants = async ({ slug, shareToken }) => { + if (!shareToken) { + throw new Error('shareToken is required to build share link variants'); + } + + const shortEnabled = await isShortGalleryUrlsEnabled(); + const sharePath = buildSharePath(slug, shareToken, shortEnabled); + const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, ''); + const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath; + + return { + shortEnabled, + sharePath, + shareUrl, + shareLinkToStore: sharePath + }; +}; + +const getEventShareToken = (event) => { + if (!event) { + return null; + } + + if (event.share_token) { + return event.share_token; + } + + return extractShareToken(event.share_link); +}; + +const ACTIVE_EVENT_FILTER = { + is_active: formatBoolean(true), + is_archived: formatBoolean(false) +}; + +const resolveShareIdentifier = async (identifier) => { + if (!identifier) { + return null; + } + + const trimmed = String(identifier).trim(); + if (!trimmed) { + return null; + } + + const baseQuery = db('events') + .select( + 'id', + 'slug', + 'share_link', + 'share_token', + 'require_password', + 'event_name', + 'event_type', + 'event_date', + 'expires_at', + 'is_active', + 'is_archived' + ) + .where(ACTIVE_EVENT_FILTER); + + let event = await baseQuery.clone().where({ slug: trimmed }).first(); + if (event) { + return { event, matchType: 'slug', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where({ share_token: trimmed }).first(); + if (event) { + return { event, matchType: 'token', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where({ share_link: trimmed }).first(); + if (event) { + return { event, matchType: 'link', shareToken: getEventShareToken(event) }; + } + + event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first(); + if (event) { + return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) }; + } + + // As a final fallback, if identifier looks like a token but we did not match via share_token + if (isPotentialShareToken(trimmed)) { + event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first(); + if (event) { + return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) }; + } + } + + return null; +}; + +module.exports = { + isShortGalleryUrlsEnabled, + clearShareLinkSettingsCache, + buildShareLinkVariants, + getEventShareToken, + resolveShareIdentifier +}; diff --git a/backend/src/utils/shareLinkUtils.js b/backend/src/utils/shareLinkUtils.js new file mode 100644 index 0000000..3afbffc --- /dev/null +++ b/backend/src/utils/shareLinkUtils.js @@ -0,0 +1,63 @@ +const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/; + +/** + * Extracts the share token portion from a stored share link. + * Supports full URLs, absolute paths, and legacy slug/token formats. + * @param {string|null|undefined} shareLink + * @returns {string|null} + */ +function extractShareToken(shareLink) { + if (!shareLink) { + return null; + } + + const trimmed = String(shareLink).trim(); + if (!trimmed) { + return null; + } + + // Remove protocol + host when a full URL is stored + const path = trimmed.replace(/^https?:\/\/[^/]+/i, ''); + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) { + return null; + } + + const candidate = segments[segments.length - 1]; + return candidate || null; +} + +/** + * Returns true if the provided identifier looks like a generated share token. + * @param {string|null|undefined} identifier + * @returns {boolean} + */ +function isPotentialShareToken(identifier) { + if (!identifier) { + return false; + } + return SHARE_TOKEN_REGEX.test(String(identifier).trim()); +} + +/** + * Builds the gallery share path depending on whether short URLs are enabled. + * @param {string} slug + * @param {string} shareToken + * @param {boolean} useShort + * @returns {string} + */ +function buildSharePath(slug, shareToken, useShort) { + if (!shareToken) { + throw new Error('shareToken is required to build share path'); + } + if (useShort || !slug) { + return `/gallery/${shareToken}`; + } + return `/gallery/${slug}/${shareToken}`; +} + +module.exports = { + extractShareToken, + isPotentialShareToken, + buildSharePath +}; diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index 238570e..c1960b4 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; import type { ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; import { api } from '../config/api'; import { authService, galleryService } from '../services'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; @@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC = ({ childr const [event, setEvent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - - // Get current gallery slug from URL - const getCurrentGallerySlug = () => { - const pathParts = window.location.pathname.split('/'); - if (pathParts[1] === 'gallery' && pathParts[2]) { - return pathParts[2]; - } - return null; - }; + const [routeError, setRouteError] = useState(null); + const location = useLocation(); + const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({ + slug: null, + token: undefined, + identifier: null, + ready: false, + }); + const lastResolvedIdentifier = useRef(null); useEffect(() => { cleanupOldGalleryAuth(); + }, []); - const slugAtMount = getCurrentGallerySlug(); - if (slugAtMount) { - setActiveGallerySlug(slugAtMount); - } else { - clearActiveGallerySlug(); - } + useEffect(() => { + let cancelled = false; - const initialise = async () => { - const currentSlug = getCurrentGallerySlug(); + const parseRoute = async () => { + const segments = location.pathname.split('/').filter(Boolean); - if (!currentSlug) { - setIsLoading(false); + if (segments[0] !== 'gallery') { + if (!cancelled) { + setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true }); + setRouteError(null); + } return; } - setActiveGallerySlug(currentSlug); + const identifier = segments[1] || null; + const tokenSegment = segments[2]; - const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); - if (storedEvent) { - try { - const parsed = JSON.parse(storedEvent); - if (parsed && parsed.id) { - const normalizedStored = normalizeEvent(parsed); - setEvent(normalizedStored); - if (normalizedStored) { - sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored)); - } - } - } catch (err) { - sessionStorage.removeItem(`gallery_event_${currentSlug}`); + if (!identifier) { + if (!cancelled) { + setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true }); } + return; } + const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment; + + if (looksLikeToken) { + if (lastResolvedIdentifier.current === identifier) { + setRouteInfo(prev => ({ + slug: prev.slug, + token: prev.token, + identifier, + ready: true, + })); + setRouteError(null); + return; + } + + try { + const resolved = await galleryService.resolveIdentifier(identifier); + if (cancelled) return; + lastResolvedIdentifier.current = identifier; + setRouteInfo({ + slug: resolved.slug, + token: resolved.token, + identifier, + ready: true, + }); + setRouteError(null); + } catch (err: any) { + if (cancelled) return; + lastResolvedIdentifier.current = identifier; + setRouteInfo({ + slug: null, + token: undefined, + identifier, + ready: true, + }); + setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link'); + } + } else { + lastResolvedIdentifier.current = null; + setRouteInfo({ + slug: identifier, + token: tokenSegment, + identifier, + ready: true, + }); + setRouteError(null); + } + }; + + setRouteInfo(prev => ({ ...prev, ready: false })); + parseRoute(); + + return () => { + cancelled = true; + }; + }, [location.pathname]); + + useEffect(() => { + if (!routeInfo.ready) { + return; + } + + if (!routeInfo.slug) { + clearActiveGallerySlug(); + setIsAuthenticated(false); + setEvent(null); + setIsLoading(false); + return; + } + + const currentSlug = routeInfo.slug; + setActiveGallerySlug(currentSlug); + + const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); + if (storedEvent) { + try { + const parsed = JSON.parse(storedEvent); + if (parsed && parsed.id) { + const normalizedStored = normalizeEvent(parsed); + setEvent(normalizedStored); + if (normalizedStored) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored)); + } + } + } catch { + sessionStorage.removeItem(`gallery_event_${currentSlug}`); + } + } + + const initialise = async () => { try { setIsLoading(true); const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>( @@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC = ({ childr setIsAuthenticated(true); if (!storedEvent) { - // Fetch gallery details to hydrate context const galleryData = await galleryService.getGalleryPhotos(currentSlug); if (galleryData?.event) { const normalizedEvent = normalizeEvent(galleryData.event); @@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC = ({ childr return; } - // If no active session, check for share token in URL - const parts = window.location.pathname.split('/'); - const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined); - - if (urlToken) { - const verify = await galleryService.verifyToken(currentSlug, urlToken); + if (routeInfo.token) { + const verify = await galleryService.verifyToken(currentSlug, routeInfo.token); if (verify?.valid) { - const response = await authService.shareLinkLogin(currentSlug, urlToken); + const response = await authService.shareLinkLogin(currentSlug, routeInfo.token); if (response?.event) { const normalizedEvent = normalizeEvent(response.event); setEvent(normalizedEvent); @@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC = ({ childr } } - // No valid session found setIsAuthenticated(false); sessionStorage.removeItem(`gallery_event_${currentSlug}`); setEvent(null); clearGalleryToken(currentSlug); - } catch (error) { + } catch (initialiseError: any) { setIsAuthenticated(false); sessionStorage.removeItem(`gallery_event_${currentSlug}`); setEvent(null); clearGalleryToken(currentSlug); + if (initialiseError?.response?.data?.error) { + setError(initialiseError.response.data.error); + } } finally { setIsLoading(false); } }; initialise(); + return () => { clearActiveGallerySlug(); }; - }, []); + }, [routeInfo]); const login = async (slug: string, password?: string, recaptchaToken?: string | null) => { try { + setRouteError(null); setError(null); setIsLoading(true); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); @@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC = ({ childr } setActiveGallerySlug(slug); - // Store event data for quick reloads (non-sensitive) if (normalizedEvent) { sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent)); } @@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC = ({ childr }; const logout = () => { - const currentSlug = getCurrentGallerySlug(); + const currentSlug = routeInfo.slug; if (currentSlug) { sessionStorage.removeItem(`gallery_event_${currentSlug}`); clearGalleryToken(currentSlug); @@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC = ({ childr login, logout, isLoading, - error, + error: routeError ?? error, }} > {children} diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index fbd2fa2..5fd0186 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { galleryService } from '../services'; import { toast } from 'react-toastify'; -export const useGalleryInfo = (slug: string, token?: string) => { +export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => { return useQuery({ queryKey: ['gallery-info', slug, token], - queryFn: () => galleryService.getGalleryInfo(slug, token), + queryFn: () => { + if (!slug) { + throw new Error('Gallery slug is required'); + } + return galleryService.getGalleryInfo(slug, token); + }, retry: 1, staleTime: 5 * 60 * 1000, // 5 minutes + enabled: Boolean(slug) && enabled, }); }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 490b605..b4cf3d4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -783,6 +783,8 @@ "enableWatermark": "Wasserzeichen auf Fotos aktivieren", "enableAnalytics": "Analytics-Tracking aktivieren", "enableRegistration": "Selbstregistrierung für Admins erlauben", + "enableShortGalleryUrls": "Kurze Galerie-Links verwenden", + "enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.", "maintenanceMode": "Wartungsmodus aktivieren", "language": "Sprache", "defaultLanguage": "Standardsprache", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0e4ac17..7a144bf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -463,6 +463,8 @@ "enableWatermark": "Enable watermark on photos", "enableAnalytics": "Enable analytics tracking", "enableRegistration": "Allow self-registration for admins", + "enableShortGalleryUrls": "Use short gallery URLs", + "enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.", "maintenanceMode": "Enable maintenance mode", "language": "Language", "defaultLanguage": "Default Language", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 36277df..12fd94b 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; import { analyticsService } from '../services/analytics.service'; +import { galleryService } from '../services'; import { api } from '../config/api'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { buildResourceUrl } from '../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; export const GalleryPage: React.FC = () => { - const { slug, token } = useParams<{ slug: string; token?: string }>(); + const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>(); const { isAuthenticated, login, event } = useGalleryAuth(); const { t, i18n } = useTranslation(); const { format } = useLocalizedDate(); @@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => { const [loginError, setLoginError] = useState(null); const [recaptchaToken, setRecaptchaToken] = useState(null); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); + const [resolvedSlug, setResolvedSlug] = useState(() => { + if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) { + return null; + } + return rawSlug || null; + }); + const [resolvedToken, setResolvedToken] = useState(rawToken); + const [isResolvingIdentifier, setIsResolvingIdentifier] = useState(() => + Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) + ); + const [identifierError, setIdentifierError] = useState(null); + const lastResolvedIdentifier = React.useRef(null); - // Fetch gallery info (public data) - const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); + React.useEffect(() => { + let cancelled = false; + + const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)); + + if (!rawSlug) { + lastResolvedIdentifier.current = null; + setResolvedSlug(null); + setResolvedToken(rawToken); + setIsResolvingIdentifier(false); + setIdentifierError(null); + } else if (!looksLikeToken) { + lastResolvedIdentifier.current = null; + setResolvedSlug(rawSlug); + setResolvedToken(rawToken); + setIsResolvingIdentifier(false); + setIdentifierError(null); + } else if (lastResolvedIdentifier.current !== rawSlug) { + setIsResolvingIdentifier(true); + setIdentifierError(null); + + galleryService.resolveIdentifier(rawSlug) + .then((data) => { + if (cancelled) return; + lastResolvedIdentifier.current = rawSlug; + setResolvedSlug(data.slug); + setResolvedToken(data.token); + setIdentifierError(null); + }) + .catch((error: any) => { + if (cancelled) return; + lastResolvedIdentifier.current = rawSlug; + setResolvedSlug(null); + setResolvedToken(undefined); + const message = error?.response?.data?.error || 'Unable to resolve gallery link'; + setIdentifierError(message); + }) + .finally(() => { + if (!cancelled) { + setIsResolvingIdentifier(false); + } + }); + } else { + setIsResolvingIdentifier(false); + } + + return () => { + cancelled = true; + }; + }, [rawSlug, rawToken]); + + const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier; + const { + data: galleryInfo, + isLoading: isLoadingInfoQuery, + error: infoError + } = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo); + const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier; const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true); + + React.useEffect(() => { + setAutoLoginAttempted(false); + }, [resolvedSlug]); // Fetch branding settings const { data: settingsData } = useQuery({ @@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => { }, [galleryInfo, settingsData, isAuthenticated, setTheme]); React.useEffect(() => { - if (!slug) { + if (!resolvedSlug || isResolvingIdentifier) { return; } if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) { setAutoLoginAttempted(true); setIsLoggingIn(true); - login(slug, '') + login(resolvedSlug, '') .then(() => { setLoginError(null); }) @@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => { setIsLoggingIn(false); }); } - }, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]); + }, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]); // Calculate days until expiration const daysUntilExpiration = galleryInfo @@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => { try { setIsLoggingIn(true); setLoginError(null); - await login(slug!, requiresPassword ? password : '', recaptchaToken); + if (!resolvedSlug) { + setLoginError(t('errors.galleryNotFound')); + return; + } + + await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken); if (requiresPassword) { analyticsService.trackGalleryEvent('password_entry', { - gallery: slug, + gallery: resolvedSlug, success: true }); } @@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => { // Track failed password entry if (requiresPassword) { analyticsService.trackGalleryEvent('password_entry', { - gallery: slug, + gallery: resolvedSlug ?? rawSlug ?? 'unknown', success: false, statusCode }); @@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => { ); } + if (identifierError && !resolvedSlug && !isResolvingIdentifier) { + return ( +
+
+ {settingsData?.branding_logo_url && ( +
+ {settingsData.branding_company_name +
+ )} + +
+ + + +

+ {t('errors.galleryNotFound')} +

+

+ {identifierError} +

+
+
+
+ +
+
+ + {t('legal.impressum')} + + | + + {t('legal.datenschutz')} + +
+

+ Powered by PicPeak +

+
+
+
+ ); + } + // Show error state if (infoError) { // Check if it's an archived gallery error @@ -299,9 +430,11 @@ export const GalleryPage: React.FC = () => { ); } + const gallerySlugForView = resolvedSlug ?? rawSlug ?? ''; + // Show gallery view if authenticated if (isAuthenticated && event) { - return ; + return ; } // Show login form diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index ea14659..12ecb3c 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -100,6 +100,7 @@ export const SettingsPage: React.FC = () => { enable_analytics: true, enable_registration: false, maintenance_mode: false, + short_gallery_urls: false, default_language: 'en', date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' } }); @@ -156,6 +157,7 @@ export const SettingsPage: React.FC = () => { enable_analytics: toBoolean(settings.general_enable_analytics, true), enable_registration: toBoolean(settings.general_enable_registration, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false), + short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false), default_language: settings.general_default_language || 'en', date_format: settings.general_date_format ? (typeof settings.general_date_format === 'string' @@ -761,6 +763,21 @@ export const SettingsPage: React.FC = () => { /> {t('settings.general.maintenanceMode')} + +
+ +

+ {t('settings.general.enableShortGalleryUrlsHelp')} +

+
diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index c71b08b..5744452 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -1,5 +1,5 @@ import { api } from '../config/api'; -import type { GalleryInfo, GalleryData, GalleryStats } from '../types'; +import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; export const galleryService = { @@ -119,4 +119,9 @@ export const galleryService = { const response = await api.get(`/gallery/${slug}/stats`); return response.data; }, + + async resolveIdentifier(identifier: string): Promise { + const response = await api.get(`/gallery/resolve/${identifier}`); + return response.data; + }, }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b726dee..3281c64 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -111,6 +111,17 @@ export interface GalleryStats { unique_visitors: number; } +export interface ResolvedGalleryIdentifier { + slug: string; + token: string; + matchType: string; + share_link: string; + share_path: string; + share_url: string; + short_enabled: boolean; + requires_password: boolean; +} + // Auth types export interface AdminUser { id: number; diff --git a/playwright.config.ts b/playwright.config.ts index 6048922..3b23f83 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ timeout: 60_000, retries: 0, use: { - baseURL: 'http://localhost:3000', + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', headless: true, viewport: { width: 1280, height: 800 }, ignoreHTTPSErrors: true, @@ -15,4 +15,3 @@ export default defineConfig({ { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, ], }); - diff --git a/tests/e2e/auth-smoke.spec.ts b/tests/e2e/auth-smoke.spec.ts index 24cd833..d876caf 100644 --- a/tests/e2e/auth-smoke.spec.ts +++ b/tests/e2e/auth-smoke.spec.ts @@ -6,23 +6,32 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; -async function createEventWithPhotos(page: Page) { +async function createEventWithPhotos(page: Page, adminToken?: string, attempt = 1) { const api = page.request; - const loginResponse = await api.post('/api/auth/admin/login', { - data: { - username: ADMIN_EMAIL, - password: ADMIN_PASSWORD, - }, - }); - expect(loginResponse.ok()).toBeTruthy(); - const { token } = await loginResponse.json(); - expect(token).toBeTruthy(); + let token = adminToken; + + if (!token) { + const loginResponse = await api.post('/api/auth/admin/login', { + data: { + username: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + }, + }); + expect(loginResponse.ok()).toBeTruthy(); + const loginData = await loginResponse.json(); + token = loginData.token; + expect(token).toBeTruthy(); + } const eventName = `Playwright Smoke ${Date.now()}`; const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); + if (!token) { + throw new Error('Failed to acquire admin token'); + } + const eventResponse = await api.post('/api/admin/events', { headers: { Authorization: `Bearer ${token}`, @@ -34,6 +43,8 @@ async function createEventWithPhotos(page: Page) { event_date: eventDate, customer_name: 'Playwright Host', customer_email: 'host@example.com', + host_name: 'Playwright Host', + host_email: 'host@example.com', admin_email: ADMIN_EMAIL, password: GALLERY_PASSWORD, expiration_days: 30, @@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) { watermark_downloads: false, }, }); - expect(eventResponse.ok()).toBeTruthy(); + if (!eventResponse.ok()) { + const message = await eventResponse.text(); + if ( + attempt < 3 && + /UNIQUE constraint failed: events\.slug/i.test(message || '') + ) { + await page.waitForTimeout(150); + return createEventWithPhotos(page, token, attempt + 1); + } + throw new Error(`Event creation failed: ${eventResponse.status()} ${message}`); + } const event = await eventResponse.json(); const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png'); @@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) { event, shareLink: event.share_link, slug: event.slug, + adminToken: token, }; } +async function updateShortGallerySetting(page: Page, adminToken: string, enabled: boolean) { + const response = await page.request.put('/api/admin/settings/general', { + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json', + }, + data: { + general_short_gallery_urls: enabled, + }, + }); + expect(response.ok()).toBeTruthy(); +} + +async function openGalleryShareLink(page: Page, shareLink: string) { + await page.context().clearCookies(); + await page.goto(shareLink); + await page.waitForLoadState('domcontentloaded'); + + try { + await page.getByText(/Enter Gallery Password/i).first().waitFor({ timeout: 5000 }); + } catch { + // No password prompt shown (public gallery) + } + + let passwordEntered = false; + const passwordTextbox = page.getByRole('textbox', { name: /password/i }).first(); + if (await passwordTextbox.count()) { + await passwordTextbox.fill(GALLERY_PASSWORD); + passwordEntered = true; + } + + const galleryPasswordField = page.getByPlaceholder(/gallery password/i); + if (!passwordEntered && await galleryPasswordField.count()) { + await galleryPasswordField.fill(GALLERY_PASSWORD); + passwordEntered = true; + } else if (!passwordEntered) { + const genericPasswordField = page.getByPlaceholder(/password/i).first(); + if (await genericPasswordField.count()) { + await genericPasswordField.fill(GALLERY_PASSWORD); + passwordEntered = true; + } else { + const labelledPasswordField = page.getByLabel(/password/i).first(); + if (await labelledPasswordField.count()) { + await labelledPasswordField.fill(GALLERY_PASSWORD); + passwordEntered = true; + } + } + } + + if (!passwordEntered) { + const fallbackPasswordField = page.locator('input').first(); + if (await fallbackPasswordField.count()) { + await fallbackPasswordField.fill(GALLERY_PASSWORD); + passwordEntered = true; + } + } + + const viewButton = page.getByRole('button', { name: /View Gallery/i }); + if (await viewButton.count()) { + try { + await viewButton.click({ noWaitAfter: true, timeout: 2000 }); + } catch { + // Already navigated into gallery view. + } + } + + const tiles = page.locator('.relative.group'); + await expect(tiles.first()).toBeVisible({ timeout: 20000 }); + return tiles; +} + test('admin login and gallery viewing smoke test', async ({ page }) => { - const { shareLink } = await createEventWithPhotos(page); + const { shareLink, adminToken } = await createEventWithPhotos(page); // Admin UI login await page.goto('/admin/login'); @@ -83,32 +176,39 @@ test('admin login and gallery viewing smoke test', async ({ page }) => { } await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 }); - // Visit gallery share link and authenticate - await page.goto(shareLink); - const passwordField = page.getByPlaceholder(/gallery password/i); - if (await passwordField.count()) { - try { - await passwordField.fill(GALLERY_PASSWORD, { timeout: 2000 }); - } catch { - // Field may disappear if gallery bypasses password; ignore. - } + let resetToken = adminToken; + try { + // Verify long-form share link works + const tiles = await openGalleryShareLink(page, shareLink); + await tiles.first().hover(); + await tiles.first().getByRole('button', { name: /View full size/i }).click(); + await expect(page.getByRole('button', { name: /Close/i })).toBeVisible(); + await page.getByRole('button', { name: /Close/i }).click(); + + // Enable short gallery URLs + await updateShortGallerySetting(page, adminToken, true); + + const settingsResponse = await page.request.get('/api/admin/settings', { + headers: { + Authorization: `Bearer ${adminToken}`, + }, + }); + expect(settingsResponse.ok()).toBeTruthy(); + const adminSettings = await settingsResponse.json(); + expect(adminSettings.general_short_gallery_urls === true || adminSettings.general_short_gallery_urls === 'true').toBeTruthy(); + + const { shareLink: shortShareLink, event: shortEvent } = await createEventWithPhotos(page, adminToken); + expect(shortShareLink).toMatch(/\/gallery\/[0-9a-fA-F]{32}$/); + expect(shortShareLink).not.toContain(shortEvent.slug); + + // Verify short share link works + await openGalleryShareLink(page, shortShareLink); + + // Legacy share link should still work after enabling short URLs + await openGalleryShareLink(page, shareLink); + } finally { + await updateShortGallerySetting(page, resetToken, false).catch(() => { + /* noop */ + }); } - - const viewButton = page.getByRole('button', { name: /View Gallery/i }); - if (await viewButton.count()) { - try { - await viewButton.click({ noWaitAfter: true, timeout: 2000 }); - } catch { - // Already inside gallery view. - } - } - - // Wait for photos grid to appear - const tiles = page.locator('.relative.group'); - await expect(tiles.first()).toBeVisible({ timeout: 20000 }); - - // Open lightbox to ensure media renders - await tiles.first().hover(); - await tiles.first().getByRole('button', { name: /View full size/i }).click(); - await expect(page.getByRole('button', { name: /Close/i })).toBeVisible(); });