Files
picpeak/backend/src/services/shareLinkService.js
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

203 lines
5.7 KiB
JavaScript

const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const logger = require('../utils/logger');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
let cachedSetting = null;
let cacheExpiresAt = 0;
const parseSettingValue = (rawValue) => {
if (rawValue === undefined || rawValue === null) {
return null;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
if (typeof rawValue === 'number') {
return rawValue !== 0;
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parseSettingValue(parsed);
} catch {
const normalized = trimmed.toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false;
}
return null;
}
}
if (typeof rawValue === 'object') {
try {
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
} catch {
return null;
}
}
return null;
};
const getRawSettingValue = async () => {
try {
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
return setting?.setting_value ?? null;
} catch (error) {
logger.error('Failed to read gallery URL setting:', error.message);
return null;
}
};
const isShortGalleryUrlsEnabled = async () => {
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
return cachedSetting;
}
const rawValue = await getRawSettingValue();
const parsed = parseSettingValue(rawValue);
cachedSetting = parsed === null ? false : Boolean(parsed);
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return cachedSetting;
};
const clearShareLinkSettingsCache = () => {
cachedSetting = null;
cacheExpiresAt = 0;
};
const buildShareLinkVariants = async ({ slug, shareToken }) => {
if (!shareToken) {
throw new Error('shareToken is required to build share link variants');
}
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = await getFrontendBaseUrl();
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
shortEnabled,
sharePath,
shareUrl,
shareLinkToStore: sharePath
};
};
const getEventShareToken = (event) => {
if (!event) {
return null;
}
if (event.share_token) {
return event.share_token;
}
return extractShareToken(event.share_link);
};
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
};
// Same filter minus the draft gate, for admin preview only (#1386). Callers
// MUST authorize before returning anything it matched — see the /resolve
// route, which only reaches for it after a verified admin preview.
const UNPUBLISHED_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier, { includeDrafts = false } = {}) => {
if (!identifier) {
return null;
}
const trimmed = String(identifier).trim();
if (!trimmed) {
return null;
}
const baseQuery = db('events')
.select(
'id',
'slug',
'share_link',
'share_token',
'require_password',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'is_draft'
)
.where(includeDrafts ? UNPUBLISHED_EVENT_FILTER : ACTIVE_EVENT_FILTER);
let event = await baseQuery.clone().where({ slug: trimmed }).first();
if (event) {
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_token: trimmed }).first();
if (event) {
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_link: trimmed }).first();
if (event) {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
// GHSA-rh8r hardening: `trimmed` is attacker-controlled, so escape LIKE
// wildcards (`%`, `_`, and the escape char itself) before embedding it.
// Otherwise an anonymous `/resolve/________…________` (32 underscores)
// matches ANY share_link via single-char wildcards, resolves as
// matchType 'link_partial', and the /resolve route hands back the
// gallery's bearer token — reopening the very hole the token-withholding
// fix closed. Explicit ESCAPE clause because SQLite has no default LIKE
// escape character (Postgres defaults to backslash, but we set it for both).
const likeTail = `%/${trimmed.replace(/[\\%_]/g, (c) => `\\${c}`)}`;
event = await baseQuery.clone().whereRaw('share_link LIKE ? ESCAPE \'\\\'', [likeTail]).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}
// As a final fallback, if identifier looks like a token but we did not match via share_token
if (isPotentialShareToken(trimmed)) {
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
if (event) {
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
}
}
return null;
};
module.exports = {
isShortGalleryUrlsEnabled,
clearShareLinkSettingsCache,
buildShareLinkVariants,
getEventShareToken,
resolveShareIdentifier
};