feat(gallery): admin preview skips the password on protected galleries (#981)
Closes #868. A logged-in admin opening a published, password-protected gallery is let straight in, mirroring the existing draft-visibility bypass. Mechanism: an explicit ?admin_preview=1 intent flag AND a verified admin session read from the httpOnly admin_token cookie (or an admin-typed Bearer) — never a token from the URL. This retires the old ?preview=<raw-admin-JWT> scheme, which leaked a 24h admin token into the address bar, referrers and proxy logs. Per-request bypass only: no gallery JWT is minted, the password endpoint is never reached so the login_attempts lockout buckets stay clean, and admin previews are excluded from guest analytics (access_logs, download counts, per-photo view_count, notification bells). Review (two rounds) closed three blockers and two concerns: - Transport: verifyGalleryAccess now resolves admin preview before any gallery credential, and isAdminPreview reads the admin cookie first and type-checks every candidate — so an admin Bearer no longer 403s on the type gate, and a coexisting gallery session can no longer shadow the admin cookie. - Reveal mode (#838) is a second consumer of isAdminPreview; its bypass is unchanged, only the transport moves. revealMode.test.js updated off the retired scheme and now carries a coexisting gallery Bearer. - Admin previews no longer inflate per-photo view counts, and the internal photo redirects preserve the flag via withPreview() so they still authorise. - Happy path: GalleryPage renders GalleryView directly for a preview instead of attempting the public empty-password auto-login, which 401'd against a genuinely protected gallery and stranded the page on the skeleton. The backend job timed out once at the 10-minute CI limit; a re-run completed in 2m02s, in line with main's ~2m10s baseline, so that was a runner flake rather than a hang.
This commit is contained in:
@@ -72,6 +72,16 @@ api.interceptors.request.use(
|
||||
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
|
||||
|
||||
if (isGalleryEndpoint || isGallerySessionCheck) {
|
||||
// Admin preview (#868): the gallery tab was opened with ?admin_preview=1.
|
||||
// Forward that intent flag on every gallery API call so the backend
|
||||
// applies the admin draft/password bypass. The httpOnly admin_token
|
||||
// cookie authenticates server-side (withCredentials) — no secret in the
|
||||
// URL. Harmless for guests: without a valid admin cookie the backend
|
||||
// fails the check closed.
|
||||
if (new URLSearchParams(window.location.search).get('admin_preview') === '1') {
|
||||
config.params = { ...(config.params as Record<string, unknown> | undefined), admin_preview: 1 };
|
||||
}
|
||||
|
||||
const fallbackSlug = getActiveGallerySlug()
|
||||
|| inferGallerySlugFromLocation();
|
||||
const slug = pathSlug || paramSlug || fallbackSlug;
|
||||
|
||||
@@ -29,6 +29,15 @@ export const GalleryPage: React.FC = () => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
// #868 admin preview: signalled by ?admin_preview=1 in the (dedicated) gallery
|
||||
// tab URL. It renders the gallery directly with NO gallery session — the
|
||||
// backend grants each request per the flag + admin cookie. We must skip the
|
||||
// public empty-password auto-login below, which would otherwise POST an empty
|
||||
// password against a genuinely protected gallery and 401 (#981 review).
|
||||
const isAdminPreview = React.useMemo(
|
||||
() => new URLSearchParams(window.location.search).get('admin_preview') === '1',
|
||||
[],
|
||||
);
|
||||
// Evaluate once per mount — UA doesn't change at runtime, and using useMemo
|
||||
// avoids re-running detection on every render of the form.
|
||||
const iabDetection = React.useMemo(() => detectInAppBrowser(), []);
|
||||
@@ -182,7 +191,7 @@ export const GalleryPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted && !isLoadingSettings) {
|
||||
if (galleryInfo && !isAdminPreview && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted && !isLoadingSettings) {
|
||||
setAutoLoginAttempted(true);
|
||||
setIsLoggingIn(true);
|
||||
login(resolvedSlug, '')
|
||||
@@ -199,7 +208,7 @@ export const GalleryPage: React.FC = () => {
|
||||
setIsLoggingIn(false);
|
||||
});
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier, isLoadingSettings]);
|
||||
}, [galleryInfo, isAdminPreview, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier, isLoadingSettings]);
|
||||
|
||||
// Calculate days until expiration (null if no expiration set)
|
||||
const daysUntilExpiration = galleryInfo?.expires_at
|
||||
@@ -388,6 +397,28 @@ export const GalleryPage: React.FC = () => {
|
||||
|
||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
||||
|
||||
// Admin preview (#868): render the gallery directly, no gallery session.
|
||||
// GalleryView fetches photos by slug (the axios interceptor forwards
|
||||
// admin_preview=1 + the admin cookie), and reads its live event from that
|
||||
// response; this prop only seeds the initial header from /info.
|
||||
if (isAdminPreview && galleryInfo) {
|
||||
return (
|
||||
<GalleryView
|
||||
slug={gallerySlugForView}
|
||||
event={{
|
||||
id: 0,
|
||||
event_name: galleryInfo.event_name,
|
||||
event_type: galleryInfo.event_type,
|
||||
event_date: galleryInfo.event_date,
|
||||
color_theme: galleryInfo.color_theme,
|
||||
expires_at: galleryInfo.expires_at,
|
||||
allow_user_uploads: galleryInfo.allow_user_uploads,
|
||||
allow_downloads: galleryInfo.allow_downloads,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { buildShareLinkUrl } from '../../../utils/url';
|
||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||
@@ -190,10 +189,13 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
)}
|
||||
{event.share_link && !isEditing && (
|
||||
<a
|
||||
href={event.is_draft
|
||||
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
|
||||
: buildShareLinkUrl(event.share_link)
|
||||
}
|
||||
// Admin preview (#868): an explicit intent flag, no token in the
|
||||
// URL. The httpOnly admin_token cookie authenticates server-side
|
||||
// on the same-origin API calls. Works for BOTH draft (bypasses
|
||||
// published-visibility) and published+password galleries
|
||||
// (bypasses the guest password) — retires the old
|
||||
// ?preview=<raw-admin-JWT> scheme that leaked the token.
|
||||
href={`${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}admin_preview=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-accent hover:opacity-80 border border-accent-dark rounded-lg hover:bg-accent-dark/15 transition-colors"
|
||||
|
||||
@@ -296,12 +296,6 @@ export const eventsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get admin preview token (uses existing admin session token)
|
||||
getPreviewToken(): string | null {
|
||||
const token = sessionStorage.getItem('admin_token') || localStorage.getItem('admin_token');
|
||||
return token;
|
||||
},
|
||||
|
||||
// Rename event
|
||||
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -3,6 +3,16 @@ import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||
|
||||
// Admin preview (#868): the preview tab carries `?admin_preview=1`. Browser-native
|
||||
// download navigations (a real `<a href>` / `api.getUri`) bypass the axios request
|
||||
// interceptor that forwards the flag on API calls, so append it to those URLs
|
||||
// directly. The httpOnly admin_token cookie authenticates server-side.
|
||||
function withAdminPreview(url: string): string {
|
||||
if (typeof window === 'undefined') return url;
|
||||
if (new URLSearchParams(window.location.search).get('admin_preview') !== '1') return url;
|
||||
return `${url}${url.includes('?') ? '&' : '?'}admin_preview=1`;
|
||||
}
|
||||
|
||||
// iOS is the only platform whose system share sheet exposes a
|
||||
// first-party "Save Image" / "Save to Photos" action for files
|
||||
// shared via navigator.share(). On Android the share sheet only
|
||||
@@ -88,7 +98,7 @@ export const galleryService = {
|
||||
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
if (!isIOS()) {
|
||||
this.triggerDirectDownload(
|
||||
api.getUri({ url: `/gallery/${slug}/download/${photoId}` }),
|
||||
withAdminPreview(api.getUri({ url: `/gallery/${slug}/download/${photoId}` })),
|
||||
filename,
|
||||
);
|
||||
return;
|
||||
@@ -215,7 +225,7 @@ export const galleryService = {
|
||||
// Native browser download — the server sends Content-Length so
|
||||
// the browser shows a real progress bar and mobile doesn't crash.
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/gallery/${slug}/download-all`;
|
||||
link.href = withAdminPreview(`/api/gallery/${slug}/download-all`);
|
||||
link.setAttribute('download', `${slug}.zip`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
Reference in New Issue
Block a user