* fix(gallery): let an admin preview a draft through its short share URL (stable)
Stable twin of the main-branch fix.
/resolve/:identifier filtered drafts out through ACTIVE_EVENT_FILTER and
/:slug/verify-token/:token repeated the filter inline, so with "use short
gallery URLs" on the admin's own View Gallery link answered "Gallery Not
Found" for an unpublished gallery. With the setting off the link carries the
slug, /info serves it, and the preview worked — which is why this looked like
a short-URL bug rather than a draft one.
The mechanism differs from main by branch: stable identifies an admin preview
by a signed admin JWT in ?preview=, so this uses isAdminPreview, the same
predicate /info already uses for its draft gate. Both routes now match /info
rather than being stricter than the branch they live on.
The draft lookup only runs after isAdminPreview accepts the caller, so the
published path keeps its single query and an unverified caller never learns
the draft exists. GHSA-rh8r is unchanged and pinned by test: a bare slug
lookup still never returns share_token.
Relates to issue 1386
* fix(gallery): carry the admin preview credential to the API on stable
External review found the backend half of the previous commit was unreachable:
`preview=` appeared in exactly two places in the whole frontend — building the
View Gallery link and reading the token — and nothing forwarded it into the API
calls the gallery page then makes. So the new /resolve fallback exited at its
guard for every real browser request, and the /info draft escape that has been
there all along was equally inert. Draft preview on this branch was broken for
both URL forms, not just short ones.
The request interceptor now forwards the credential as x-admin-preview, and
isAdminPreview accepts it there as well as in ?preview=. A header rather than a
query parameter because the credential is the admin's own session JWT, and
query strings reach nginx access logs, browser history and Referer headers.
?preview= stays accepted: the gallery PAGE url is what the browser navigates
to, and hand-built links rely on it.
The tests only exercised ?preview=, which the browser never sends on an API
call — so they passed while the feature stayed broken end to end. They now
cover the header transport across /resolve, /verify-token and /info.
Relates to issue 1386
* fix(gallery): authenticate the draft preview by the admin cookie on stable
The header transport in the previous commit could not work. `admin_token`
appears exactly once in this frontend — the read inside getPreviewToken() —
and nothing ever writes it: AdminAuthContext stores only admin_user and the
JWT lives in an HttpOnly cookie. So getPreviewToken() always returned null,
the View Gallery link was built as `?preview=` with an empty value, and every
transport downstream had nothing to carry. Draft preview on this branch has
never worked from the UI, by either URL form.
The machinery was already there: verifyGalleryAccess drops the is_draft
constraint for a preview in three places. Only delivery was missing.
isAdminPreview now also accepts `admin_preview=1` as an intent flag,
authenticated by the admin_token cookie the browser already sends. That fixes
every caller at once, including the native fetch() in AuthenticatedImage and
AuthenticatedVideo, which bypasses the axios interceptor entirely — without
the flag on the media URL a preview loaded its metadata and then showed no
thumbnails, hero or lightbox media at all. The flag alone authorizes nothing:
with no valid admin token the check fails closed.
`?preview=<jwt>` keeps working for hand-built links, but nothing emits it any
more, so the admin's own session JWT no longer travels in a query string where
nginx access logs, browser history and Referer headers can see it.
getPreviewToken() is deleted along with its now-orphaned import.
Relates to issue 1386
* fix(gallery): carry the preview flag on every non-axios gallery URL
Third review round found the flag still missing on the paths that never touch
the axios interceptor:
- PhotoLightbox renders VideoPlayer, which assigns the photo URL straight to
<video src>. Draft video playback 404'd. The previous commit had put the flag
in AuthenticatedVideo, which has no consumers on this branch at all — dead
code fixing nothing. Reverted; VideoPlayer carries it now, for both src and
poster.
- savePhotoToDevice builds a native anchor from api.getUri(), and
downloadAllPhotos uses a direct anchor when a zip is ready. Both downloads
404'd inside a preview.
The three call sites plus AuthenticatedImage now share utils/adminPreview.ts
rather than repeating the check. It refuses absolute URLs, and the flag is
applied while the URL is still relative — buildResourceUrl can turn it
absolute in split deployments, which would have dropped it silently.
Relates to issue 1386
* fix(gallery): authorize the admin preview against the event, not just the token
isAdminPreview verified the JWT signature and `type === 'admin'` and checked
nothing else — not that the account still exists, not that the token is
unrevoked, and not that this admin may see this event. verifyGalleryAccess
then dropped the is_draft constraint on that basis, so any valid admin token
previewed any draft gallery and its photos, including one created by a
different photographer and including an account whose role grants neither
events.view nor photos.view. main closes this through access.authorize; this
applies the same rule where this branch keeps its checks.
The predicate could not simply be tightened in place: it runs while the event
lookup is being shaped, before there is an event to authorize against. So it
splits in two. previewClaimed() stays synchronous and signature-only, and its
one legitimate use is deciding whether the lookup includes drafts.
verifyAdminPreview(req, event) then applies the real rules — revocation, an
active account, ownership (super_admin, ownerless, or own event) and
events.view + photos.view — and assertDraftPreviewAllowed gates every loaded
event behind it. Both query branches in verifyGalleryAccess converge on one
`if (!event)`, so two gates cover all three lookups.
Fails closed on a transient database fault in the revocation or permission
check, rather than treating an error as a pass. The roles-table fallback
mirrors adminAuth: an install predating that schema has admins but no role to
check, so ownership is the only gate that applies there.
The suite previously carried a test documenting the hole — "accepts any valid
admin token, matching /info on this branch". That is replaced by the three
cases it was standing in for: a non-owning admin, an admin with no gallery
permissions, and a deactivated account, each 404 now and 200 before.
Relates to issue 1411
* fix(gallery): close two gaps in the draft-preview authorization
Found by a fourth review round.
verify-token selected its own columns and omitted created_by, so
verifyAdminPreview saw an ownerless event and allowed any admin holding
events.view and photos.view — including one who does not own the draft, and
while /resolve and /info were correctly refusing them. The ownership check was
running; it just had nothing to check against.
savePhotoToDevice applied the preview flag to the output of api.getUri().
With an absolute VITE_API_URL that is an absolute URL, which withAdminPreview
refuses by design, so the flag was silently dropped and desktop and Android
preview downloads 404'd. Applied to the relative path before getUri expands it.
Relates to issue 1386
Relates to issue 1411
* fix(gallery): keep an admin draft preview out of the guest share-login flow (stable)
Twin of the main-branch fix. Making verify-token pass for a draft preview
opened a path that did not exist before it: the gallery bootstrap then called
shareLinkLogin, whose share lookup excludes drafts, so it 404'd and recorded a
failed login attempt against the caller's IP on the way out. Five preview opens
inside the attempt window locked share-link logins out for that IP — for real
guests too, and after publishing.
An admin preview needs no guest session: the admin cookie plus admin_preview=1
already authorizes every gallery call. The preview path loads the gallery
directly and never touches the login endpoint.
Relates to issue 1386
* test(gallery): move the preview revocation tests onto the new predicates
The revocation hardening that landed in the meantime shipped unit tests
against isAdminPreview, which this branch replaces with previewClaimed plus
verifyAdminPreview. They were asserting the old shape — including which where()
calls the event lookup makes — so they broke on the merge.
Rewritten against the contract that actually matters rather than the query
shape: previewClaimed is signature-only by design and deliberately does not
consult revocation, and verifyAdminPreview refuses a revoked token, fails
closed when the revocation store cannot be read, and refuses when there is no
event to authorize against. The end-to-end case is asserted through
verifyGalleryAccess: a revoked preview token widens the lookup and still gets
404 for the draft.
Found by CI, not locally — these live in backend/src/__tests__, a second test
root that the suites I had been running do not cover.
---------
Co-authored-by: Paul Nothaft <[email protected]>