Add short gallery URL toggle and token support (#38)
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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,52 +62,133 @@ 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 {
|
|
||||||
clearActiveGallerySlug();
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialise = async () => {
|
const parseRoute = async () => {
|
||||||
const currentSlug = getCurrentGallerySlug();
|
const segments = location.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
if (!currentSlug) {
|
if (segments[0] !== 'gallery') {
|
||||||
setIsLoading(false);
|
if (!cancelled) {
|
||||||
|
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||||
|
setRouteError(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveGallerySlug(currentSlug);
|
const identifier = segments[1] || null;
|
||||||
|
const tokenSegment = segments[2];
|
||||||
|
|
||||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
if (!identifier) {
|
||||||
if (storedEvent) {
|
if (!cancelled) {
|
||||||
try {
|
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,10 +28,82 @@ 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({
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'] } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+139
-39
@@ -6,23 +6,32 @@ 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;
|
||||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
let token = adminToken;
|
||||||
data: {
|
|
||||||
username: ADMIN_EMAIL,
|
if (!token) {
|
||||||
password: ADMIN_PASSWORD,
|
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||||
},
|
data: {
|
||||||
});
|
username: ADMIN_EMAIL,
|
||||||
expect(loginResponse.ok()).toBeTruthy();
|
password: ADMIN_PASSWORD,
|
||||||
const { token } = await loginResponse.json();
|
},
|
||||||
expect(token).toBeTruthy();
|
});
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
token = loginData.token;
|
||||||
|
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);
|
try {
|
||||||
const passwordField = page.getByPlaceholder(/gallery password/i);
|
// Verify long-form share link works
|
||||||
if (await passwordField.count()) {
|
const tiles = await openGalleryShareLink(page, shareLink);
|
||||||
try {
|
await tiles.first().hover();
|
||||||
await passwordField.fill(GALLERY_PASSWORD, { timeout: 2000 });
|
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||||
} catch {
|
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||||
// Field may disappear if gallery bypasses password; ignore.
|
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user