Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing

This commit is contained in:
Paul Nothaft
2025-10-15 07:21:09 +02:00
parent 775c5159ea
commit 31fd64c83c
19 changed files with 807 additions and 135 deletions
+36
View File
@@ -3,6 +3,7 @@ const path = require('path');
const knex = require('knex'); const knex = require('knex');
const knexConfig = require('../../knexfile'); const knexConfig = require('../../knexfile');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs) // Ensure SQLite directory exists when using file-based DB (native installs)
try { try {
@@ -66,11 +67,13 @@ async function initializeDatabase() {
table.string('customer_name'); table.string('customer_name');
table.string('customer_email'); table.string('customer_email');
table.string('host_email').notNullable(); table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable(); table.string('admin_email').notNullable();
table.string('password_hash').notNullable(); table.string('password_hash').notNullable();
table.text('welcome_message'); table.text('welcome_message');
table.text('color_theme'); table.text('color_theme');
table.string('share_link').unique().notNullable(); table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable(); table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true); table.boolean('is_active').defaultTo(true);
@@ -103,12 +106,14 @@ async function initializeDatabase() {
event_date DATE NOT NULL, event_date DATE NOT NULL,
customer_name TEXT, customer_name TEXT,
customer_email TEXT, customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL, host_email TEXT NOT NULL,
admin_email TEXT NOT NULL, admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
welcome_message TEXT, welcome_message TEXT,
color_theme TEXT, color_theme TEXT,
share_link TEXT UNIQUE NOT NULL, share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1, 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 // Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos'); const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) { if (!hasPhotosTable) {
+5 -3
View File
@@ -2,6 +2,7 @@
// Only the relevant parts are shown - merge with existing adminEvents.js // Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Enhanced event creation with password validation // Enhanced event creation with password validation
router.post('/', adminAuth, [ router.post('/', adminAuth, [
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
counter++; counter++;
} }
// Generate share link // Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex'); 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 // Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds()); const password_hash = await bcrypt.hash(password, getBcryptRounds());
@@ -96,7 +97,8 @@ router.post('/', adminAuth, [
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
allow_user_uploads, allow_user_uploads,
+7 -7
View File
@@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor // formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => { const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -225,11 +226,9 @@ router.post('/', adminAuth, [
counter++; counter++;
} }
// Generate share link // Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`; const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
// Hash password with configurable rounds (random placeholder when not required) // Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword const password_hash = requirePassword
@@ -266,7 +265,8 @@ router.post('/', adminAuth, [
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
allow_user_uploads, allow_user_uploads,
@@ -318,7 +318,7 @@ router.post('/', adminAuth, [
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor 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', gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
@@ -336,7 +336,7 @@ router.post('/', adminAuth, [
customer_name: customerName, customer_name: customerName,
customer_email: customerEmail, customer_email: customerEmail,
require_password: requirePassword, require_password: requirePassword,
share_link: shareLink, share_link: shareUrl,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString() created_at: new Date().toISOString()
}); });
+4
View File
@@ -18,6 +18,7 @@ const {
getRawPublicSiteSettings, getRawPublicSiteSettings,
} = require('../services/publicSiteService'); } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer'); const { sanitizeCss } = require('../utils/cssSanitizer');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const router = express.Router(); const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings'); const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
@@ -548,6 +549,9 @@ router.put('/general', adminAuth, async (req, res) => {
if (uploadLimitTouched) { if (uploadLimitTouched) {
clearMaxFilesPerUploadCache(); clearMaxFilesPerUploadCache();
} }
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Log activity // Log activity
await db('activity_logs').insert({ await db('activity_logs').insert({
+11 -6
View File
@@ -22,6 +22,7 @@ const {
getAdminTokenFromRequest, getAdminTokenFromRequest,
getGalleryTokenFromRequest, getGalleryTokenFromRequest,
} = require('../utils/tokenUtils'); } = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const router = express.Router(); const router = express.Router();
// Admin login with enhanced security // Admin login with enhanced security
@@ -284,18 +285,22 @@ router.post('/gallery/share-login', [
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || ''; 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) }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first(); .first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); return res.status(404).json({ error: 'Gallery not found' });
} }
let expectedToken = event.share_link; const expectedToken = getEventShareToken(event);
if (expectedToken && expectedToken.includes('/')) {
expectedToken = expectedToken.split('/').pop();
}
if (!expectedToken || token !== expectedToken) { if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' }); return res.status(401).json({ error: 'Invalid or expired share link' });
@@ -312,7 +317,7 @@ router.post('/gallery/share-login', [
issuer: 'picpeak-auth' issuer: 'picpeak-auth'
}); });
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug); setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
+7 -8
View File
@@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const router = express.Router(); const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => { const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -160,12 +161,9 @@ router.post('/', adminAuth, [
counter++; 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 shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`; const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const shareLinkSlug = `${slug}/${shareToken}`;
// Hash password (or placeholder when not required) // Hash password (or placeholder when not required)
const password_hash = requirePassword const password_hash = requirePassword
@@ -195,7 +193,8 @@ router.post('/', adminAuth, [
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLinkSlug, share_link: shareLinkToStore,
share_token: shareToken,
expires_at, expires_at,
require_password: formatBoolean(requirePassword) require_password: formatBoolean(requirePassword)
}).returning('id'); }).returning('id');
@@ -211,7 +210,7 @@ router.post('/', adminAuth, [
host_name: customerName, host_name: customerName,
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor 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', gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
@@ -220,7 +219,7 @@ router.post('/', adminAuth, [
res.json({ res.json({
id: eventId, id: eventId,
slug, slug,
share_link: fullShareLink, share_link: shareUrl,
expires_at, expires_at,
require_password: requirePassword, require_password: requirePassword,
customer_name: customerName, customer_name: customerName,
+36 -9
View File
@@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); 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 // Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => { router.get('/:slug/verify-token/:token', async (req, res) => {
try { try {
@@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const event = await db('events') const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link') .select('id', 'share_link', 'share_token')
.first(); .first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); return res.status(404).json({ error: 'Gallery not found' });
} }
// Extract token from share link and verify const expectedToken = getEventShareToken(event);
const expectedToken = event.share_link.split('/').pop();
if (token !== expectedToken) { if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' }); return res.status(404).json({ error: 'Invalid gallery link' });
} }
@@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => {
'is_active', 'is_active',
'is_archived', 'is_archived',
'share_link', 'share_link',
'share_token',
'allow_downloads', 'allow_downloads',
'disable_right_click', 'disable_right_click',
'watermark_downloads', 'watermark_downloads',
@@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link // If token provided, verify it matches the share link
if (token) { if (token) {
let expectedToken = event.share_link; const expectedToken = getEventShareToken(event);
// Handle both formats: full URL or just token if (!expectedToken || token !== expectedToken) {
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' }); return res.status(404).json({ error: 'Invalid gallery link' });
} }
} }
+181
View File
@@ -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
};
+63
View File
@@ -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
};
+113 -33
View File
@@ -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 type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../config/api'; import { api } from '../config/api';
import { authService, galleryService } from '../services'; import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
@@ -61,34 +62,114 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const [event, setEvent] = useState<GalleryEvent | null>(null); const [event, setEvent] = useState<GalleryEvent | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
// Get current gallery slug from URL const location = useLocation();
const getCurrentGallerySlug = () => { const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
const pathParts = window.location.pathname.split('/'); slug: null,
if (pathParts[1] === 'gallery' && pathParts[2]) { token: undefined,
return pathParts[2]; identifier: null,
} ready: false,
return null; });
}; const lastResolvedIdentifier = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
cleanupOldGalleryAuth(); cleanupOldGalleryAuth();
}, []);
const slugAtMount = getCurrentGallerySlug(); useEffect(() => {
if (slugAtMount) { let cancelled = false;
setActiveGallerySlug(slugAtMount);
} else { const parseRoute = async () => {
clearActiveGallerySlug(); const segments = location.pathname.split('/').filter(Boolean);
if (segments[0] !== 'gallery') {
if (!cancelled) {
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
setRouteError(null);
}
return;
} }
const initialise = async () => { const identifier = segments[1] || null;
const currentSlug = getCurrentGallerySlug(); const tokenSegment = segments[2];
if (!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); setIsLoading(false);
return; return;
} }
const currentSlug = routeInfo.slug;
setActiveGallerySlug(currentSlug); setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
@@ -102,11 +183,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored)); sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
} }
} }
} catch (err) { } catch {
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
} }
} }
const initialise = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>( const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(true); setIsAuthenticated(true);
if (!storedEvent) { if (!storedEvent) {
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug); const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) { if (galleryData?.event) {
const normalizedEvent = normalizeEvent(galleryData.event); const normalizedEvent = normalizeEvent(galleryData.event);
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
return; return;
} }
// If no active session, check for share token in URL if (routeInfo.token) {
const parts = window.location.pathname.split('/'); const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (verify?.valid) { if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken); const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
if (response?.event) { if (response?.event) {
const normalizedEvent = normalizeEvent(response.event); const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent); setEvent(normalizedEvent);
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
} }
} }
// No valid session found
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
} catch (error) { } catch (initialiseError: any) {
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
if (initialiseError?.response?.data?.error) {
setError(initialiseError.response.data.error);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
initialise(); initialise();
return () => { return () => {
clearActiveGallerySlug(); clearActiveGallerySlug();
}; };
}, []); }, [routeInfo]);
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => { const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
try { try {
setRouteError(null);
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
} }
setActiveGallerySlug(slug); setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
if (normalizedEvent) { if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent)); sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
} }
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}; };
const logout = () => { const logout = () => {
const currentSlug = getCurrentGallerySlug(); const currentSlug = routeInfo.slug;
if (currentSlug) { if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
login, login,
logout, logout,
isLoading, isLoading,
error, error: routeError ?? error,
}} }}
> >
{children} {children}
+8 -2
View File
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { galleryService } from '../services'; import { galleryService } from '../services';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export const useGalleryInfo = (slug: string, token?: string) => { export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
return useQuery({ return useQuery({
queryKey: ['gallery-info', slug, token], 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, retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
enabled: Boolean(slug) && enabled,
}); });
}; };
+2
View File
@@ -783,6 +783,8 @@
"enableWatermark": "Wasserzeichen auf Fotos aktivieren", "enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableAnalytics": "Analytics-Tracking aktivieren", "enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben", "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", "maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache", "language": "Sprache",
"defaultLanguage": "Standardsprache", "defaultLanguage": "Standardsprache",
+2
View File
@@ -463,6 +463,8 @@
"enableWatermark": "Enable watermark on photos", "enableWatermark": "Enable watermark on photos",
"enableAnalytics": "Enable analytics tracking", "enableAnalytics": "Enable analytics tracking",
"enableRegistration": "Allow self-registration for admins", "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", "maintenanceMode": "Enable maintenance mode",
"language": "Language", "language": "Language",
"defaultLanguage": "Default Language", "defaultLanguage": "Default Language",
+143 -10
View File
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery'; import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery'; import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service'; import { analyticsService } from '../services/analytics.service';
import { galleryService } from '../services';
import { api } from '../config/api'; import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url'; import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl'; import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
export const GalleryPage: React.FC = () => { 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 { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
@@ -27,11 +28,83 @@ export const GalleryPage: React.FC = () => {
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null); const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false); const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
return null;
}
return rawSlug || null;
});
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
);
const [identifierError, setIdentifierError] = useState<string | null>(null);
const lastResolvedIdentifier = React.useRef<string | null>(null);
// Fetch gallery info (public data) React.useEffect(() => {
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); 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); const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
React.useEffect(() => {
setAutoLoginAttempted(false);
}, [resolvedSlug]);
// Fetch branding settings // Fetch branding settings
const { data: settingsData } = useQuery({ const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'], queryKey: ['gallery-settings'],
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
}, [galleryInfo, settingsData, isAuthenticated, setTheme]); }, [galleryInfo, settingsData, isAuthenticated, setTheme]);
React.useEffect(() => { React.useEffect(() => {
if (!slug) { if (!resolvedSlug || isResolvingIdentifier) {
return; return;
} }
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) { if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
setAutoLoginAttempted(true); setAutoLoginAttempted(true);
setIsLoggingIn(true); setIsLoggingIn(true);
login(slug, '') login(resolvedSlug, '')
.then(() => { .then(() => {
setLoginError(null); setLoginError(null);
}) })
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
setIsLoggingIn(false); setIsLoggingIn(false);
}); });
} }
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]); }, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
// Calculate days until expiration // Calculate days until expiration
const daysUntilExpiration = galleryInfo const daysUntilExpiration = galleryInfo
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
try { try {
setIsLoggingIn(true); setIsLoggingIn(true);
setLoginError(null); setLoginError(null);
await login(slug!, requiresPassword ? password : '', recaptchaToken); if (!resolvedSlug) {
setLoginError(t('errors.galleryNotFound'));
return;
}
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
if (requiresPassword) { if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', { analyticsService.trackGalleryEvent('password_entry', {
gallery: slug, gallery: resolvedSlug,
success: true success: true
}); });
} }
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
// Track failed password entry // Track failed password entry
if (requiresPassword) { if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', { analyticsService.trackGalleryEvent('password_entry', {
gallery: slug, gallery: resolvedSlug ?? rawSlug ?? 'unknown',
success: false, success: false,
statusCode statusCode
}); });
@@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => {
); );
} }
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">
{t('errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{identifierError}
</p>
</CardContent>
</Card>
</div>
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// Show error state // Show error state
if (infoError) { if (infoError) {
// Check if it's an archived gallery error // 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 // Show gallery view if authenticated
if (isAuthenticated && event) { if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />; return <GalleryView slug={gallerySlugForView} event={event} />;
} }
// Show login form // Show login form
+17
View File
@@ -100,6 +100,7 @@ export const SettingsPage: React.FC = () => {
enable_analytics: true, enable_analytics: true,
enable_registration: false, enable_registration: false,
maintenance_mode: false, maintenance_mode: false,
short_gallery_urls: false,
default_language: 'en', default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' } 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_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false), enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, 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', default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string' ? (typeof settings.general_date_format === 'string'
@@ -761,6 +763,21 @@ export const SettingsPage: React.FC = () => {
/> />
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span> <span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
</label> </label>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.short_gallery_urls}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
</label>
<p className="text-xs text-neutral-500 ml-6 mt-1">
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
</div> </div>
</Card> </Card>
+6 -1
View File
@@ -1,5 +1,5 @@
import { api } from '../config/api'; 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'; import { normalizeRequirePassword } from '../utils/accessControl';
export const galleryService = { export const galleryService = {
@@ -119,4 +119,9 @@ export const galleryService = {
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`); const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
return response.data; return response.data;
}, },
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
return response.data;
},
}; };
+11
View File
@@ -111,6 +111,17 @@ export interface GalleryStats {
unique_visitors: number; 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 // Auth types
export interface AdminUser { export interface AdminUser {
id: number; id: number;
+1 -2
View File
@@ -5,7 +5,7 @@ export default defineConfig({
timeout: 60_000, timeout: 60_000,
retries: 0, retries: 0,
use: { use: {
baseURL: 'http://localhost:3000', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true, headless: true,
viewport: { width: 1280, height: 800 }, viewport: { width: 1280, height: 800 },
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
@@ -15,4 +15,3 @@ export default defineConfig({
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
], ],
}); });
+128 -28
View File
@@ -6,8 +6,11 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; 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 api = page.request;
let token = adminToken;
if (!token) {
const loginResponse = await api.post('/api/auth/admin/login', { const loginResponse = await api.post('/api/auth/admin/login', {
data: { data: {
username: ADMIN_EMAIL, username: ADMIN_EMAIL,
@@ -15,14 +18,20 @@ async function createEventWithPhotos(page: Page) {
}, },
}); });
expect(loginResponse.ok()).toBeTruthy(); expect(loginResponse.ok()).toBeTruthy();
const { token } = await loginResponse.json(); const loginData = await loginResponse.json();
token = loginData.token;
expect(token).toBeTruthy(); expect(token).toBeTruthy();
}
const eventName = `Playwright Smoke ${Date.now()}`; const eventName = `Playwright Smoke ${Date.now()}`;
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString() .toISOString()
.slice(0, 10); .slice(0, 10);
if (!token) {
throw new Error('Failed to acquire admin token');
}
const eventResponse = await api.post('/api/admin/events', { const eventResponse = await api.post('/api/admin/events', {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -34,6 +43,8 @@ async function createEventWithPhotos(page: Page) {
event_date: eventDate, event_date: eventDate,
customer_name: 'Playwright Host', customer_name: 'Playwright Host',
customer_email: 'host@example.com', customer_email: 'host@example.com',
host_name: 'Playwright Host',
host_email: 'host@example.com',
admin_email: ADMIN_EMAIL, admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD, password: GALLERY_PASSWORD,
expiration_days: 30, expiration_days: 30,
@@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) {
watermark_downloads: false, 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 event = await eventResponse.json();
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png'); const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
@@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) {
event, event,
shareLink: event.share_link, shareLink: event.share_link,
slug: event.slug, 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 }) => { test('admin login and gallery viewing smoke test', async ({ page }) => {
const { shareLink } = await createEventWithPhotos(page); const { shareLink, adminToken } = await createEventWithPhotos(page);
// Admin UI login // Admin UI login
await page.goto('/admin/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 }); await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
// Visit gallery share link and authenticate let resetToken = adminToken;
await page.goto(shareLink);
const passwordField = page.getByPlaceholder(/gallery password/i);
if (await passwordField.count()) {
try { try {
await passwordField.fill(GALLERY_PASSWORD, { timeout: 2000 }); // Verify long-form share link works
} catch { const tiles = await openGalleryShareLink(page, shareLink);
// Field may disappear if gallery bypasses password; ignore.
}
}
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().hover();
await tiles.first().getByRole('button', { name: /View full size/i }).click(); await tiles.first().getByRole('button', { name: /View full size/i }).click();
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible(); 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 */
});
}
}); });