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
+5 -3
View File
@@ -2,6 +2,7 @@
// Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Enhanced event creation with password validation
router.post('/', adminAuth, [
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds());
@@ -96,7 +97,8 @@ router.post('/', adminAuth, [
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
+7 -7
View File
@@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
@@ -225,11 +226,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
@@ -266,7 +265,8 @@ router.post('/', adminAuth, [
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
@@ -318,7 +318,7 @@ router.post('/', adminAuth, [
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
@@ -336,7 +336,7 @@ router.post('/', adminAuth, [
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareLink,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString()
});
+4
View File
@@ -18,6 +18,7 @@ const {
getRawPublicSiteSettings,
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
@@ -548,6 +549,9 @@ router.put('/general', adminAuth, async (req, res) => {
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Log activity
await db('activity_logs').insert({
+11 -6
View File
@@ -22,6 +22,7 @@ const {
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const router = express.Router();
// Admin login with enhanced security
@@ -284,18 +285,22 @@ router.post('/gallery/share-login', [
const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
let expectedToken = event.share_link;
if (expectedToken && expectedToken.includes('/')) {
expectedToken = expectedToken.split('/').pop();
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
@@ -312,7 +317,7 @@ router.post('/gallery/share-login', [
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
+7 -8
View File
@@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
@@ -160,12 +161,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link (just slug/token, not full URL)
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const shareLinkSlug = `${slug}/${shareToken}`;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password (or placeholder when not required)
const password_hash = requirePassword
@@ -195,7 +193,8 @@ router.post('/', adminAuth, [
password_hash,
welcome_message,
color_theme,
share_link: shareLinkSlug,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
@@ -211,7 +210,7 @@ router.post('/', adminAuth, [
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: fullShareLink,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
@@ -220,7 +219,7 @@ router.post('/', adminAuth, [
res.json({
id: eventId,
slug,
share_link: fullShareLink,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name: customerName,
+36 -9
View File
@@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
if (!result) {
return res.status(404).json({ error: 'Gallery not found' });
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
} catch (error) {
logger.error('Error resolving gallery identifier:', error);
res.status(500).json({ error: 'Failed to resolve gallery link' });
}
});
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
@@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// Extract token from share link and verify
const expectedToken = event.share_link.split('/').pop();
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
@@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => {
'is_active',
'is_archived',
'share_link',
'share_token',
'allow_downloads',
'disable_right_click',
'watermark_downloads',
@@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
let expectedToken = event.share_link;
// Handle both formats: full URL or just token
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}