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:
@@ -152,9 +152,13 @@ describe('Reveal mode (#838)', () => {
|
|||||||
expect(res.body.photos).toHaveLength(2);
|
expect(res.body.photos).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/photos serves the admin preview everything', async () => {
|
it('/photos serves the admin preview everything (new transport: ?admin_preview=1 + admin cookie, even with a coexisting gallery session)', async () => {
|
||||||
|
// #868/#981: reveal-mode hiding is bypassed for an admin preview via the
|
||||||
|
// new transport (explicit flag + httpOnly admin_token cookie), NOT the
|
||||||
|
// retired ?preview=<jwt>. The coexisting gallery Bearer must not shadow it.
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get(`/api/gallery/${SLUG}/photos?preview=${encodeURIComponent(adminToken)}`)
|
.get(`/api/gallery/${SLUG}/photos?admin_preview=1`)
|
||||||
|
.set('Cookie', [`admin_token=${adminToken}`])
|
||||||
.set('Authorization', `Bearer ${galleryToken()}`);
|
.set('Authorization', `Bearer ${galleryToken()}`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.hidden_until_reveal).toBe(false);
|
expect(res.body.hidden_until_reveal).toBe(false);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* #868 — the admin gallery-preview gate. isAdminPreview must fail CLOSED: it
|
||||||
|
* grants the draft/password bypass only for an explicit `?admin_preview=1` flag
|
||||||
|
* AND a verified admin JWT (type 'admin', issuer 'picpeak-auth') read from the
|
||||||
|
* httpOnly admin_token cookie or a Bearer header — never from the URL, never for
|
||||||
|
* a guest/gallery token.
|
||||||
|
*/
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-preview-test-secret';
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { isAdminPreview } = require('../../src/middleware/gallery');
|
||||||
|
|
||||||
|
// Read the secret at call time — a jest setup file can set JWT_SECRET after this
|
||||||
|
// module loads, and isAdminPreview verifies against the live value.
|
||||||
|
const adminToken = () => jwt.sign({ type: 'admin', id: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1 }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
|
||||||
|
function req({ flag, cookie, bearer } = {}) {
|
||||||
|
return {
|
||||||
|
query: flag === undefined ? {} : { admin_preview: flag },
|
||||||
|
cookies: cookie ? { admin_token: cookie } : {},
|
||||||
|
headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isAdminPreview (#868) fails closed', () => {
|
||||||
|
it('false without the explicit flag, even with a valid admin cookie (plain link stays guest-identical)', () => {
|
||||||
|
expect(isAdminPreview(req({ cookie: adminToken() }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false with the flag but no session token', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1' }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('true with the flag + a valid admin cookie', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', cookie: adminToken() }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('true with the flag + a valid admin Bearer header', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', bearer: adminToken() }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false for a gallery (guest) token — must be type admin', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', cookie: galleryToken() }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('true from the admin cookie even when a gallery Bearer is also present (#981 coexisting session)', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', cookie: adminToken(), bearer: galleryToken() }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false when only a gallery Bearer is present — a gallery header can never satisfy it (#981)', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', bearer: galleryToken() }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false on a tampered token', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: '1', cookie: `${adminToken()}x` }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false on the wrong issuer', () => {
|
||||||
|
const t = jwt.sign({ type: 'admin' }, process.env.JWT_SECRET, { issuer: 'not-picpeak' });
|
||||||
|
expect(isAdminPreview(req({ flag: '1', cookie: t }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('false when the flag is anything other than exactly "1"', () => {
|
||||||
|
expect(isAdminPreview(req({ flag: 'true', cookie: adminToken() }))).toBe(false);
|
||||||
|
expect(isAdminPreview(req({ flag: '0', cookie: adminToken() }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,22 +4,75 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Check if the request carries a valid admin preview token (Feature 3)
|
/**
|
||||||
|
* True when a logged-in admin is explicitly previewing this gallery (#868).
|
||||||
|
*
|
||||||
|
* Two conditions, both required:
|
||||||
|
* 1. The explicit intent flag `?admin_preview=1` is present. The plain share
|
||||||
|
* link stays byte-identical to a guest's, so the password gate is still
|
||||||
|
* testable as a guest while logged in as admin — and the bypass is visible
|
||||||
|
* in the URL without being reusable (it carries no secret).
|
||||||
|
* 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along
|
||||||
|
* on same-origin API calls) or an Authorization: Bearer header, never the
|
||||||
|
* URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`.
|
||||||
|
*
|
||||||
|
* The cookie is tried FIRST and the Bearer is accepted only when it is itself an
|
||||||
|
* admin token (#981 review): the frontend attaches a gallery Bearer to gallery
|
||||||
|
* endpoints, and a header-first, type-blind read would let a coexisting gallery
|
||||||
|
* session shadow the admin cookie and wrongly disable the preview.
|
||||||
|
*
|
||||||
|
* Fails closed on any verification error. Replaces the old `?preview=<raw-JWT>`
|
||||||
|
* scheme, which leaked a 24h admin token into the address bar.
|
||||||
|
*/
|
||||||
function isAdminPreview(req) {
|
function isAdminPreview(req) {
|
||||||
const previewToken = req.query?.preview;
|
if (req.query?.admin_preview !== '1') return false;
|
||||||
if (!previewToken) return false;
|
// Cookie first, then a Bearer — but only an admin-typed token satisfies it.
|
||||||
try {
|
const candidates = [];
|
||||||
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token);
|
||||||
return decoded.type === 'admin';
|
const header = req.headers?.authorization;
|
||||||
} catch {
|
if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7));
|
||||||
return false;
|
for (const token of candidates) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
if (decoded.type === 'admin') return true;
|
||||||
|
} catch { /* try the next candidate */ }
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
async function verifyGalleryAccess(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||||
|
|
||||||
|
// Admin preview (#868) is resolved BEFORE any gallery credential (#981
|
||||||
|
// review): a coexisting gallery token/Bearer must not shadow it, and the
|
||||||
|
// admin session must never fall into the `type !== 'gallery'` reject path
|
||||||
|
// below. Per-request bypass — draft + password relaxed, NO gallery JWT
|
||||||
|
// minted (a lingering guest cookie would muddy the coexisting-cookies case).
|
||||||
|
// req.isAdminPreview flags downstream logging to keep it out of guest stats.
|
||||||
|
if (isAdminPreview(req)) {
|
||||||
|
if (!requestedSlug) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
const previewEvent = await withRetry(async () => db('events')
|
||||||
|
.where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
|
.select('*').first());
|
||||||
|
if (!previewEvent) {
|
||||||
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
}
|
||||||
|
req.event = previewEvent;
|
||||||
|
req.isAdminPreview = true;
|
||||||
|
req.sessionID = `gallery_admin_preview_${previewEvent.id}`;
|
||||||
|
req.clientInfo = {
|
||||||
|
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
userAgent: req.get('User-Agent') || 'unknown',
|
||||||
|
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||||
let event;
|
let event;
|
||||||
|
|
||||||
@@ -28,19 +81,14 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPreview = isAdminPreview(req);
|
event = await withRetry(async () => db('events')
|
||||||
event = await withRetry(async () => {
|
.where({
|
||||||
const q = db('events')
|
slug: requestedSlug,
|
||||||
.where({
|
is_active: formatBoolean(true),
|
||||||
slug: requestedSlug,
|
is_archived: formatBoolean(false),
|
||||||
is_active: formatBoolean(true),
|
is_draft: formatBoolean(false)
|
||||||
is_archived: formatBoolean(false)
|
})
|
||||||
});
|
.select('*').first());
|
||||||
if (!adminPreview) {
|
|
||||||
q.where({ is_draft: formatBoolean(false) });
|
|
||||||
}
|
|
||||||
return await q.select('*').first();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
@@ -89,22 +137,19 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
// If we have a slug in the URL params or from pre-middleware, verify it matches.
|
||||||
|
// (Admin preview never reaches here — it returns above — so drafts stay
|
||||||
|
// filtered for every real gallery-token request.)
|
||||||
if (requestedSlug) {
|
if (requestedSlug) {
|
||||||
// Verify by slug and ensure it matches the token's event
|
// Verify by slug and ensure it matches the token's event
|
||||||
const adminPreviewToken = isAdminPreview(req);
|
event = await withRetry(async () => db('events')
|
||||||
event = await withRetry(async () => {
|
.where({
|
||||||
const q = db('events')
|
slug: requestedSlug,
|
||||||
.where({
|
is_active: formatBoolean(true),
|
||||||
slug: requestedSlug,
|
is_archived: formatBoolean(false),
|
||||||
is_active: formatBoolean(true),
|
is_draft: formatBoolean(false)
|
||||||
is_archived: formatBoolean(false)
|
})
|
||||||
});
|
.select('*').first());
|
||||||
if (!adminPreviewToken) {
|
|
||||||
q.where({ is_draft: formatBoolean(false) });
|
|
||||||
}
|
|
||||||
return await q.select('*').first();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify the token's eventId matches
|
// Verify the token's eventId matches
|
||||||
if (event && event.id !== decoded.eventId) {
|
if (event && event.id !== decoded.eventId) {
|
||||||
@@ -112,19 +157,14 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to using eventId from token
|
// Fallback to using eventId from token
|
||||||
const adminPreviewFallback = isAdminPreview(req);
|
event = await withRetry(async () => db('events')
|
||||||
event = await withRetry(async () => {
|
.where({
|
||||||
const q = db('events')
|
id: decoded.eventId,
|
||||||
.where({
|
is_active: formatBoolean(true),
|
||||||
id: decoded.eventId,
|
is_archived: formatBoolean(false),
|
||||||
is_active: formatBoolean(true),
|
is_draft: formatBoolean(false)
|
||||||
is_archived: formatBoolean(false)
|
})
|
||||||
});
|
.select('*').first());
|
||||||
if (!adminPreviewFallback) {
|
|
||||||
q.where({ is_draft: formatBoolean(false) });
|
|
||||||
}
|
|
||||||
return await q.select('*').first();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
|
|||||||
+117
-77
@@ -20,6 +20,10 @@ function resolveHeroLogoVisible(perEvent, globalDefault) {
|
|||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||||
|
// Preserve the admin-preview flag across internal photo redirects (#981 review).
|
||||||
|
// The redirected request carries no gallery JWT, so without the flag it would
|
||||||
|
// fall back to the draft/password gate and 404 the derivative.
|
||||||
|
const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url);
|
||||||
const { resolveGuest } = require('../middleware/guestAuth');
|
const { resolveGuest } = require('../middleware/guestAuth');
|
||||||
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
@@ -265,8 +269,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin preview (#868) bypasses both the draft gate and — below — the
|
||||||
|
// password gate. Computed once and reused.
|
||||||
|
const adminPreview = isAdminPreview(req);
|
||||||
// Check if event is a draft (allow admin preview)
|
// Check if event is a draft (allow admin preview)
|
||||||
if (event.is_draft && !isAdminPreview(req)) {
|
if (event.is_draft && !adminPreview) {
|
||||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +285,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
// Admin preview skips the guest password on published, protected galleries
|
||||||
|
// (#868) — the admin already sees every photo through the admin routes.
|
||||||
|
const requiresPassword = adminPreview
|
||||||
|
? false
|
||||||
|
: !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||||
|
|
||||||
@@ -820,7 +831,9 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
// refetches this list on every new-upload poll, which would massively
|
// refetches this list on every new-upload poll, which would massively
|
||||||
// inflate total_views / unique_visitors. The slideshow is explicitly
|
// inflate total_views / unique_visitors. The slideshow is explicitly
|
||||||
// excluded from real visitor analytics (migration 138 design).
|
// excluded from real visitor analytics (migration 138 design).
|
||||||
if (req.accessLevel !== 'slideshow') {
|
// Admin preview (#868) is excluded from guest analytics + the "gallery
|
||||||
|
// opened" bell — it's the photographer looking at their own gallery.
|
||||||
|
if (req.accessLevel !== 'slideshow' && !req.isAdminPreview) {
|
||||||
await db('access_logs').insert({
|
await db('access_logs').insert({
|
||||||
event_id: req.event.id,
|
event_id: req.event.id,
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
@@ -914,8 +927,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
categories: categories,
|
categories: categories,
|
||||||
photos: photos.map(photo => {
|
photos: photos.map(photo => {
|
||||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||||
// Add watermark version to URLs for cache busting when settings change
|
// Watermark version (cache-busting) + admin-preview flag (#868). In
|
||||||
const wmQuery = wmVersion ? `?${wmVersion}` : '';
|
// preview mode no gallery cookie is minted, so each <img> request must
|
||||||
|
// re-assert the admin session — thread the flag onto every /api/gallery
|
||||||
|
// image URL so the browser sends it (the admin_token cookie rides along
|
||||||
|
// same-origin).
|
||||||
|
const imgQuery = [wmVersion, req.isAdminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&');
|
||||||
|
const wmQuery = imgQuery ? `?${imgQuery}` : '';
|
||||||
const photoUrl = useJwtUrl ?
|
const photoUrl = useJwtUrl ?
|
||||||
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
|
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
|
||||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||||
@@ -1092,23 +1110,27 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update download count
|
// Admin preview (#868) downloads are excluded from the download count +
|
||||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
// guest analytics — kept out of client-facing stats.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
// Update download count
|
||||||
|
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||||
|
|
||||||
// Log download
|
// Log download
|
||||||
await db('access_logs').insert({
|
await db('access_logs').insert({
|
||||||
event_id: req.event.id,
|
event_id: req.event.id,
|
||||||
ip_address: req.ip,
|
ip_address: req.ip,
|
||||||
user_agent: req.headers['user-agent'],
|
user_agent: req.headers['user-agent'],
|
||||||
action: 'download',
|
action: 'download',
|
||||||
photo_id: photoId
|
photo_id: photoId
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Surface in the admin notification bell (#746) — debounced, and only
|
// Surface in the admin notification bell (#746) — debounced, and only
|
||||||
// once the response actually finished: notifying up-front would log a
|
// once the response actually finished: notifying up-front would log a
|
||||||
// download that then 404s/fails and the debounce would suppress the
|
// download that then 404s/fails and the debounce would suppress the
|
||||||
// next real one for an hour (codex review of #849).
|
// next real one for an hour (codex review of #849).
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
if (res.statusCode < 400) notifySinglePhotoDownload(req.event, req);
|
if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req);
|
||||||
});
|
});
|
||||||
|
|
||||||
let filePath;
|
let filePath;
|
||||||
@@ -1231,15 +1253,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
if (wantsPresigned && storage.kind() === 's3' && !watermarkOnEvent) {
|
if (wantsPresigned && storage.kind() === 's3' && !watermarkOnEvent) {
|
||||||
try {
|
try {
|
||||||
const url = await storage.signedUrl(zipInfo.key, 300); // 5 min
|
const url = await storage.signedUrl(zipInfo.key, 300); // 5 min
|
||||||
db('access_logs').insert({
|
// Admin preview (#868): stream the ZIP but keep it out of stats.
|
||||||
event_id: req.event.id,
|
if (!req.isAdminPreview) {
|
||||||
ip_address: req.ip,
|
db('access_logs').insert({
|
||||||
user_agent: req.headers['user-agent'],
|
event_id: req.event.id,
|
||||||
action: 'download_all_presigned'
|
ip_address: req.ip,
|
||||||
}).catch(() => {});
|
user_agent: req.headers['user-agent'],
|
||||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
action: 'download_all_presigned'
|
||||||
// Surface in the admin notification bell (#746).
|
}).catch(() => {});
|
||||||
logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||||
|
// Surface in the admin notification bell (#746).
|
||||||
|
logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||||
|
}
|
||||||
res.redirect(302, url);
|
res.redirect(302, url);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1256,20 +1281,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
const stream = await storage.get(zipInfo.key);
|
const stream = await storage.get(zipInfo.key);
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
|
|
||||||
// Log bulk download
|
// Log bulk download (admin preview #868 excluded — stats stay client-only).
|
||||||
db('access_logs').insert({
|
if (!req.isAdminPreview) {
|
||||||
event_id: req.event.id,
|
db('access_logs').insert({
|
||||||
ip_address: req.ip,
|
event_id: req.event.id,
|
||||||
user_agent: req.headers['user-agent'],
|
ip_address: req.ip,
|
||||||
action: 'download_all'
|
user_agent: req.headers['user-agent'],
|
||||||
}).catch(() => {});
|
action: 'download_all'
|
||||||
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
}).catch(() => {});
|
||||||
// Surface in the admin notification bell (#746) — only once the
|
bumpEventDownloadCounts(req.event.id).catch(() => {});
|
||||||
// stream actually finished; logging at pipe-time would report
|
// Surface in the admin notification bell (#746) — only once the
|
||||||
// downloads that then broke mid-transfer (codex review of #849).
|
// stream actually finished; logging at pipe-time would report
|
||||||
res.on('finish', () => {
|
// downloads that then broke mid-transfer (codex review of #849).
|
||||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
res.on('finish', () => {
|
||||||
});
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1405,23 +1432,28 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
// Notification only after the response actually finished — finalize()
|
// Notification only after the response actually finished — finalize()
|
||||||
// ends Archiver's input, not the HTTP transfer (codex review of #849,
|
// ends Archiver's input, not the HTTP transfer (codex review of #849,
|
||||||
// confirmation round). Registered before finalize so it can't be missed.
|
// confirmation round). Registered before finalize so it can't be missed.
|
||||||
res.on('finish', () => {
|
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
if (!req.isAdminPreview) {
|
||||||
});
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
|
|
||||||
// Log bulk download
|
if (!req.isAdminPreview) {
|
||||||
await db('access_logs').insert({
|
// Log bulk download
|
||||||
event_id: req.event.id,
|
await db('access_logs').insert({
|
||||||
ip_address: req.ip,
|
event_id: req.event.id,
|
||||||
user_agent: req.headers['user-agent'],
|
ip_address: req.ip,
|
||||||
action: 'download_all'
|
user_agent: req.headers['user-agent'],
|
||||||
});
|
action: 'download_all'
|
||||||
// Exactly the photos that made it into this archive (#895) — skipped
|
});
|
||||||
// (missing/corrupt) sources don't count.
|
// Exactly the photos that made it into this archive (#895) — skipped
|
||||||
if (appendedIds.length > 0) {
|
// (missing/corrupt) sources don't count.
|
||||||
db('photos').whereIn('id', appendedIds)
|
if (appendedIds.length > 0) {
|
||||||
.increment('download_count', 1).catch(() => {});
|
db('photos').whereIn('id', appendedIds)
|
||||||
|
.increment('download_count', 1).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||||
@@ -1552,22 +1584,27 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// See download-all: notify only on response 'finish'.
|
// See download-all: notify only on response 'finish'.
|
||||||
res.on('finish', () => {
|
// Admin preview (#868) streams the archive but is excluded from stats.
|
||||||
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
|
if (!req.isAdminPreview) {
|
||||||
});
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req));
|
||||||
|
});
|
||||||
|
}
|
||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
|
|
||||||
await db('access_logs').insert({
|
if (!req.isAdminPreview) {
|
||||||
event_id: req.event.id,
|
await db('access_logs').insert({
|
||||||
ip_address: req.ip,
|
event_id: req.event.id,
|
||||||
user_agent: req.headers['user-agent'],
|
ip_address: req.ip,
|
||||||
action: 'download_selected'
|
user_agent: req.headers['user-agent'],
|
||||||
});
|
action: 'download_selected'
|
||||||
// Exactly the photos that made it into this archive (#895) — skipped
|
});
|
||||||
// (missing/corrupt) sources don't count.
|
// Exactly the photos that made it into this archive (#895) — skipped
|
||||||
if (appendedIds.length > 0) {
|
// (missing/corrupt) sources don't count.
|
||||||
db('photos').whereIn('id', appendedIds)
|
if (appendedIds.length > 0) {
|
||||||
.increment('download_count', 1).catch(() => {});
|
db('photos').whereIn('id', appendedIds)
|
||||||
|
.increment('download_count', 1).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||||
@@ -1600,7 +1637,10 @@ router.post('/:slug/photo/:photoId/view',
|
|||||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||||
return res.status(403).json({ error: 'Photo not available' });
|
return res.status(403).json({ error: 'Photo not available' });
|
||||||
}
|
}
|
||||||
await db('photos').where('id', photo.id).increment('view_count', 1);
|
// Admin preview (#981 review) is excluded from per-photo view analytics.
|
||||||
|
if (!req.isAdminPreview) {
|
||||||
|
await db('photos').where('id', photo.id).increment('view_count', 1);
|
||||||
|
}
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to record view');
|
errorResponse(res, error, 500, 'Failed to record view');
|
||||||
@@ -1959,7 +1999,7 @@ router.get('/:slug/hero/:photoId',
|
|||||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// For videos, redirect to the regular photo endpoint
|
// For videos, redirect to the regular photo endpoint
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure hero image exists and is valid, regenerate if needed
|
// Ensure hero image exists and is valid, regenerate if needed
|
||||||
@@ -1968,7 +2008,7 @@ router.get('/:slug/hero/:photoId',
|
|||||||
if (!heroPath) {
|
if (!heroPath) {
|
||||||
// If hero generation fails, fall back to original photo
|
// If hero generation fails, fall back to original photo
|
||||||
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hero images are always written via the storage abstraction (see
|
// Hero images are always written via the storage abstraction (see
|
||||||
@@ -1983,7 +2023,7 @@ router.get('/:slug/hero/:photoId',
|
|||||||
eventId: req.event.id,
|
eventId: req.event.id,
|
||||||
heroPath
|
heroPath
|
||||||
});
|
});
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
@@ -2022,7 +2062,7 @@ router.get('/:slug/hero/:photoId',
|
|||||||
eventId: req.event?.id
|
eventId: req.event?.id
|
||||||
});
|
});
|
||||||
// Fall back to original photo on any error
|
// Fall back to original photo on any error
|
||||||
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
|
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -2059,7 +2099,7 @@ router.get('/:slug/preview/:photoId',
|
|||||||
// belt-and-braces in case a stale tab does.
|
// belt-and-braces in case a stale tab does.
|
||||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy generation: ensurePreviewImage returns null on any
|
// Lazy generation: ensurePreviewImage returns null on any
|
||||||
@@ -2068,7 +2108,7 @@ router.get('/:slug/preview/:photoId',
|
|||||||
const previewPath = await ensurePreviewImage(photo);
|
const previewPath = await ensurePreviewImage(photo);
|
||||||
if (!previewPath) {
|
if (!previewPath) {
|
||||||
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
|
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -2077,7 +2117,7 @@ router.get('/:slug/preview/:photoId',
|
|||||||
logger.error('Preview file does not exist in storage backend', {
|
logger.error('Preview file does not exist in storage backend', {
|
||||||
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
|
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
|
||||||
});
|
});
|
||||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||||
@@ -2118,7 +2158,7 @@ router.get('/:slug/preview/:photoId',
|
|||||||
photoId: req.params.photoId,
|
photoId: req.params.photoId,
|
||||||
eventId: req.event?.id,
|
eventId: req.event?.id,
|
||||||
});
|
});
|
||||||
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
|
res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ api.interceptors.request.use(
|
|||||||
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
|
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
|
||||||
|
|
||||||
if (isGalleryEndpoint || isGallerySessionCheck) {
|
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()
|
const fallbackSlug = getActiveGallerySlug()
|
||||||
|| inferGallerySlugFromLocation();
|
|| inferGallerySlugFromLocation();
|
||||||
const slug = pathSlug || paramSlug || fallbackSlug;
|
const slug = pathSlug || paramSlug || fallbackSlug;
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export const GalleryPage: React.FC = () => {
|
|||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
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
|
// Evaluate once per mount — UA doesn't change at runtime, and using useMemo
|
||||||
// avoids re-running detection on every render of the form.
|
// avoids re-running detection on every render of the form.
|
||||||
const iabDetection = React.useMemo(() => detectInAppBrowser(), []);
|
const iabDetection = React.useMemo(() => detectInAppBrowser(), []);
|
||||||
@@ -182,7 +191,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted && !isLoadingSettings) {
|
if (galleryInfo && !isAdminPreview && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted && !isLoadingSettings) {
|
||||||
setAutoLoginAttempted(true);
|
setAutoLoginAttempted(true);
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
login(resolvedSlug, '')
|
login(resolvedSlug, '')
|
||||||
@@ -199,7 +208,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
setIsLoggingIn(false);
|
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)
|
// Calculate days until expiration (null if no expiration set)
|
||||||
const daysUntilExpiration = galleryInfo?.expires_at
|
const daysUntilExpiration = galleryInfo?.expires_at
|
||||||
@@ -388,6 +397,28 @@ export const GalleryPage: React.FC = () => {
|
|||||||
|
|
||||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
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
|
// Show gallery view if authenticated
|
||||||
if (isAuthenticated && event) {
|
if (isAuthenticated && event) {
|
||||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import type { Event } from '../../../types';
|
|||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||||
import { eventsService } from '../../../services/events.service';
|
|
||||||
import { buildShareLinkUrl } from '../../../utils/url';
|
import { buildShareLinkUrl } from '../../../utils/url';
|
||||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
import { isGalleryPublic } from '../../../utils/accessControl';
|
||||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||||
@@ -190,10 +189,13 @@ export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{event.share_link && !isEditing && (
|
{event.share_link && !isEditing && (
|
||||||
<a
|
<a
|
||||||
href={event.is_draft
|
// Admin preview (#868): an explicit intent flag, no token in the
|
||||||
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
|
// URL. The httpOnly admin_token cookie authenticates server-side
|
||||||
: buildShareLinkUrl(event.share_link)
|
// 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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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;
|
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
|
// Rename event
|
||||||
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
|
async renameEvent(eventId: number, newEventName: string, resendEmail: boolean = false): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier
|
|||||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
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
|
// iOS is the only platform whose system share sheet exposes a
|
||||||
// first-party "Save Image" / "Save to Photos" action for files
|
// first-party "Save Image" / "Save to Photos" action for files
|
||||||
// shared via navigator.share(). On Android the share sheet only
|
// 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> {
|
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
|
||||||
if (!isIOS()) {
|
if (!isIOS()) {
|
||||||
this.triggerDirectDownload(
|
this.triggerDirectDownload(
|
||||||
api.getUri({ url: `/gallery/${slug}/download/${photoId}` }),
|
withAdminPreview(api.getUri({ url: `/gallery/${slug}/download/${photoId}` })),
|
||||||
filename,
|
filename,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -215,7 +225,7 @@ export const galleryService = {
|
|||||||
// Native browser download — the server sends Content-Length so
|
// Native browser download — the server sends Content-Length so
|
||||||
// the browser shows a real progress bar and mobile doesn't crash.
|
// the browser shows a real progress bar and mobile doesn't crash.
|
||||||
const link = document.createElement('a');
|
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`);
|
link.setAttribute('download', `${slug}.zip`);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|||||||
Reference in New Issue
Block a user