Files
picpeak/frontend/src/contexts/GalleryAuthContext.tsx
T
Paul Nothaft f92d4bb2d9 fix(gallery): let an admin preview a draft through its short share URL (#1405)
fix(gallery): keep an admin draft preview out of the guest share-login flow

Making verify-token pass for a draft preview opened a path that did not exist
before it: the gallery bootstrap then called shareLinkLogin, which refuses a
draft AND records a failed login attempt against the caller's IP while doing
it. Five preview opens inside the attempt window therefore locked share-link
logins out for that IP — including for real guests, and including after the
gallery was published.

An admin preview does not need a guest session at all. The admin cookie plus
admin_preview=1 already authorizes every gallery call, which is exactly how
preview works on a published gallery, so the preview path loads the gallery
directly and never touches the login endpoint.

Deliberately not fixed by relaxing shareLinkLogin's draft check: that endpoint
mints a guest token, and a draft should not be handing those out.

Relates to issue 1386

fix(gallery): let an admin preview a draft through its short share URL

/info has honoured admin_preview since issue 868, but two sibling routes on the
short-URL path never did:

- GET /resolve/:identifier filtered drafts out through ACTIVE_EVENT_FILTER
  (shareLinkService.js), with no escape for a verified admin.
- GET /:slug/verify-token/:token repeated the same filter inline, so clearing
  the first would only have moved the 404 one step later.

With "use short gallery URLs" off the View Gallery link carries the slug,
GalleryPage never calls /resolve, and the preview worked. With it on the link
is the token form, GalleryPage resolves it first, and the draft answered
"Gallery Not Found".

resolveShareIdentifier takes an includeDrafts option, and /resolve reaches for
it only after the published lookup misses AND verifyAdminPreview accepts the
caller — so the published path keeps its single query and an unverified caller
never learns the draft exists. The frontend already sends admin_preview=1
(EventDetailsHeader.tsx:203, forwarded by config/api.ts:81); only the backend
had to change.

GHSA-rh8r's rule is unchanged and now pinned by test: a bare slug lookup still
never returns share_token, draft or not.

Relates to issue 1386
2026-09-11 10:41:25 +02:00

406 lines
14 KiB
TypeScript

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';
import { normalizeRequirePassword } from '../utils/accessControl';
import {
clearActiveGallerySlug,
clearGalleryToken,
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
import { clearGuestIdentity } from '../utils/guestIdentityStorage';
import type { GalleryAccessLevel } from '../types';
interface GalleryEvent {
id: number;
event_name: string;
event_type: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string | null;
require_password?: boolean;
}
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
if (!incoming) {
return null;
}
return {
...incoming,
require_password: normalizeRequirePassword(incoming.require_password, true),
};
};
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
accessLevel: GalleryAccessLevel;
isClient: boolean;
/** Session was minted by the customer portal — credentialed, bypasses reveal. */
viaCustomer: boolean;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
}
const GalleryAuthContext = createContext<GalleryAuthContextType | undefined>(undefined);
export const useGalleryAuth = () => {
const context = useContext(GalleryAuthContext);
if (!context) {
throw new Error('useGalleryAuth must be used within a GalleryAuthProvider');
}
return context;
};
interface GalleryAuthProviderProps {
children: ReactNode;
}
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [viaCustomer, setViaCustomer] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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();
}, []);
useEffect(() => {
let cancelled = false;
const parseRoute = async () => {
const segments = location.pathname.split('/').filter(Boolean);
if (segments[0] !== 'gallery') {
if (!cancelled) {
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
setRouteError(null);
}
return;
}
const identifier = segments[1] || null;
const tokenSegment = segments[2];
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}`);
}
}
// Restore access level from session storage
const storedAccessLevel = sessionStorage.getItem(`gallery_access_level_${currentSlug}`);
if (storedAccessLevel === 'client') {
setAccessLevel('client');
} else {
setAccessLevel('guest');
}
const initialise = async () => {
try {
setIsLoading(true);
const sessionResponse = await api.get<{
valid: boolean; type: string; eventSlug?: string;
accessLevel?: GalleryAccessLevel; viaCustomer?: boolean;
}>(
'/auth/session',
{ params: { slug: currentSlug } }
);
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
setIsAuthenticated(true);
// The SERVER's view of this session, not the per-tab sessionStorage
// guess above (#1149). A second tab has no sessionStorage but the
// same cookie, so the stored value silently downgraded a client
// session to 'guest' while the backend kept serving it as a client.
if (sessionResponse.data.accessLevel === 'client') {
setAccessLevel('client');
sessionStorage.setItem(`gallery_access_level_${currentSlug}`, 'client');
}
setViaCustomer(Boolean(sessionResponse.data.viaCustomer));
// Always refresh from the server — the stored event from sessionStorage
// is shown above as an instant placeholder for perceived perf, but it
// must NOT win permanently: admin edits to welcome_message / event_name /
// hero_logo / colour theme need to land on the next page load for
// returning guests. sessionStorage survives Cmd+Shift+R, so without
// this refresh the cache could only be cleared by closing the tab or
// wiping site data manually (#625).
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) {
const normalizedEvent = normalizeEvent(galleryData.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
}
return;
}
if (routeInfo.token) {
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
if (verify?.valid) {
// An admin preview does not take a guest session (#1386). The admin
// cookie plus admin_preview=1 already authorizes every gallery call,
// and shareLinkLogin refuses drafts AND records a failed attempt
// when it does — so opening a draft preview five times would lock
// share-link logins out for that IP, even after publishing.
const isAdminPreview = typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).get('admin_preview') === '1';
if (isAdminPreview) {
const previewData = await galleryService.getGalleryPhotos(currentSlug);
if (previewData?.event) {
const previewEvent = normalizeEvent(previewData.event);
setEvent(previewEvent);
setActiveGallerySlug(currentSlug);
setIsAuthenticated(true);
return;
}
}
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
if (response?.event) {
// Store token and slug BEFORE setting authenticated state to avoid
// race condition where photo queries fire before token is available
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
setIsAuthenticated(true);
return;
}
}
}
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} 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);
// Store token and slug BEFORE setting authenticated state to avoid
// race condition where photo queries fire before token is available
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
} finally {
setIsLoading(false);
}
};
const clientLoginFn = async (slug: string, password: string) => {
try {
setRouteError(null);
setError(null);
setIsLoading(true);
const response = await authService.clientLogin(slug, password);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setAccessLevel(response.accessLevel || 'client');
sessionStorage.setItem(`gallery_access_level_${slug}`, 'client');
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid PIN');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = routeInfo.slug;
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
clearGalleryToken(currentSlug);
// Logout is the "I am leaving this device" signal. Now that the guest
// identity outlives the tab (#1265), leaving it behind would greet the
// next person who enters the shared gallery password by this guest's
// name — with "forget me", which erases THIS guest's selections
// server-side, one click away. Local only; the server row is untouched.
clearGuestIdentity(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
setViaCustomer(false);
clearActiveGallerySlug();
};
return (
<GalleryAuthContext.Provider
value={{
isAuthenticated,
event,
accessLevel,
isClient: accessLevel === 'client',
viaCustomer,
login,
clientLogin: clientLoginFn,
logout,
isLoading,
error: routeError ?? error,
}}
>
{children}
</GalleryAuthContext.Provider>
);
};