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
+128 -48
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 { useLocation } from 'react-router-dom';
import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
@@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get current gallery slug from URL
const getCurrentGallerySlug = () => {
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
return pathParts[2];
}
return null;
};
const [routeError, setRouteError] = useState<string | null>(null);
const location = useLocation();
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
slug: null,
token: undefined,
identifier: null,
ready: false,
});
const lastResolvedIdentifier = useRef<string | null>(null);
useEffect(() => {
cleanupOldGalleryAuth();
}, []);
const slugAtMount = getCurrentGallerySlug();
if (slugAtMount) {
setActiveGallerySlug(slugAtMount);
} else {
clearActiveGallerySlug();
}
useEffect(() => {
let cancelled = false;
const initialise = async () => {
const currentSlug = getCurrentGallerySlug();
const parseRoute = async () => {
const segments = location.pathname.split('/').filter(Boolean);
if (!currentSlug) {
setIsLoading(false);
if (segments[0] !== 'gallery') {
if (!cancelled) {
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
setRouteError(null);
}
return;
}
setActiveGallerySlug(currentSlug);
const identifier = segments[1] || null;
const tokenSegment = segments[2];
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
const normalizedStored = normalizeEvent(parsed);
setEvent(normalizedStored);
if (normalizedStored) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
}
}
} catch (err) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
if (!identifier) {
if (!cancelled) {
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
}
return;
}
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
if (looksLikeToken) {
if (lastResolvedIdentifier.current === identifier) {
setRouteInfo(prev => ({
slug: prev.slug,
token: prev.token,
identifier,
ready: true,
}));
setRouteError(null);
return;
}
try {
const resolved = await galleryService.resolveIdentifier(identifier);
if (cancelled) return;
lastResolvedIdentifier.current = identifier;
setRouteInfo({
slug: resolved.slug,
token: resolved.token,
identifier,
ready: true,
});
setRouteError(null);
} catch (err: any) {
if (cancelled) return;
lastResolvedIdentifier.current = identifier;
setRouteInfo({
slug: null,
token: undefined,
identifier,
ready: true,
});
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
}
} else {
lastResolvedIdentifier.current = null;
setRouteInfo({
slug: identifier,
token: tokenSegment,
identifier,
ready: true,
});
setRouteError(null);
}
};
setRouteInfo(prev => ({ ...prev, ready: false }));
parseRoute();
return () => {
cancelled = true;
};
}, [location.pathname]);
useEffect(() => {
if (!routeInfo.ready) {
return;
}
if (!routeInfo.slug) {
clearActiveGallerySlug();
setIsAuthenticated(false);
setEvent(null);
setIsLoading(false);
return;
}
const currentSlug = routeInfo.slug;
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
const normalizedStored = normalizeEvent(parsed);
setEvent(normalizedStored);
if (normalizedStored) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
}
}
} catch {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
}
}
const initialise = async () => {
try {
setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(true);
if (!storedEvent) {
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) {
const normalizedEvent = normalizeEvent(galleryData.event);
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
return;
}
// If no active session, check for share token in URL
const parts = window.location.pathname.split('/');
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (routeInfo.token) {
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken);
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
if (response?.event) {
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
}
// No valid session found
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} catch (error) {
} catch (initialiseError: any) {
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
if (initialiseError?.response?.data?.error) {
setError(initialiseError.response.data.error);
}
} finally {
setIsLoading(false);
}
};
initialise();
return () => {
clearActiveGallerySlug();
};
}, []);
}, [routeInfo]);
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
try {
setRouteError(null);
setError(null);
setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
};
const logout = () => {
const currentSlug = getCurrentGallerySlug();
const currentSlug = routeInfo.slug;
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug);
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
login,
logout,
isLoading,
error,
error: routeError ?? error,
}}
>
{children}
+8 -2
View File
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { galleryService } from '../services';
import { toast } from 'react-toastify';
export const useGalleryInfo = (slug: string, token?: string) => {
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
return useQuery({
queryKey: ['gallery-info', slug, token],
queryFn: () => galleryService.getGalleryInfo(slug, token),
queryFn: () => {
if (!slug) {
throw new Error('Gallery slug is required');
}
return galleryService.getGalleryInfo(slug, token);
},
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: Boolean(slug) && enabled,
});
};
+2
View File
@@ -783,6 +783,8 @@
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
"maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache",
"defaultLanguage": "Standardsprache",
+2
View File
@@ -463,6 +463,8 @@
"enableWatermark": "Enable watermark on photos",
"enableAnalytics": "Enable analytics tracking",
"enableRegistration": "Allow self-registration for admins",
"enableShortGalleryUrls": "Use short gallery URLs",
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
"maintenanceMode": "Enable maintenance mode",
"language": "Language",
"defaultLanguage": "Default Language",
+143 -10
View File
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { galleryService } from '../services';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
@@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => {
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
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)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
React.useEffect(() => {
let cancelled = false;
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
if (!rawSlug) {
lastResolvedIdentifier.current = null;
setResolvedSlug(null);
setResolvedToken(rawToken);
setIsResolvingIdentifier(false);
setIdentifierError(null);
} else if (!looksLikeToken) {
lastResolvedIdentifier.current = null;
setResolvedSlug(rawSlug);
setResolvedToken(rawToken);
setIsResolvingIdentifier(false);
setIdentifierError(null);
} else if (lastResolvedIdentifier.current !== rawSlug) {
setIsResolvingIdentifier(true);
setIdentifierError(null);
galleryService.resolveIdentifier(rawSlug)
.then((data) => {
if (cancelled) return;
lastResolvedIdentifier.current = rawSlug;
setResolvedSlug(data.slug);
setResolvedToken(data.token);
setIdentifierError(null);
})
.catch((error: any) => {
if (cancelled) return;
lastResolvedIdentifier.current = rawSlug;
setResolvedSlug(null);
setResolvedToken(undefined);
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
setIdentifierError(message);
})
.finally(() => {
if (!cancelled) {
setIsResolvingIdentifier(false);
}
});
} else {
setIsResolvingIdentifier(false);
}
return () => {
cancelled = true;
};
}, [rawSlug, rawToken]);
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
const {
data: galleryInfo,
isLoading: isLoadingInfoQuery,
error: infoError
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
React.useEffect(() => {
setAutoLoginAttempted(false);
}, [resolvedSlug]);
// Fetch branding settings
const { data: settingsData } = useQuery({
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
React.useEffect(() => {
if (!slug) {
if (!resolvedSlug || isResolvingIdentifier) {
return;
}
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
setAutoLoginAttempted(true);
setIsLoggingIn(true);
login(slug, '')
login(resolvedSlug, '')
.then(() => {
setLoginError(null);
})
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
setIsLoggingIn(false);
});
}
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, requiresPassword ? password : '', recaptchaToken);
if (!resolvedSlug) {
setLoginError(t('errors.galleryNotFound'));
return;
}
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
gallery: resolvedSlug,
success: true
});
}
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
// Track failed password entry
if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
success: false,
statusCode
});
@@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => {
);
}
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
return (
<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
if (infoError) {
// Check if it's an archived gallery error
@@ -299,9 +430,11 @@ export const GalleryPage: React.FC = () => {
);
}
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />;
return <GalleryView slug={gallerySlugForView} event={event} />;
}
// Show login form
+17
View File
@@ -100,6 +100,7 @@ export const SettingsPage: React.FC = () => {
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
short_gallery_urls: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
@@ -156,6 +157,7 @@ export const SettingsPage: React.FC = () => {
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string'
@@ -761,6 +763,21 @@ export const SettingsPage: React.FC = () => {
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
</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>
</Card>
+6 -1
View File
@@ -1,5 +1,5 @@
import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
export const galleryService = {
@@ -119,4 +119,9 @@ export const galleryService = {
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
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;
}
export interface ResolvedGalleryIdentifier {
slug: string;
token: string;
matchType: string;
share_link: string;
share_path: string;
share_url: string;
short_enabled: boolean;
requires_password: boolean;
}
// Auth types
export interface AdminUser {
id: number;