feat(og): per-event opt-in to use hero photo as social-share preview (#474)
Background: galleryOgService already serves OG/Twitter Card meta tags to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram, Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image is always the brand logo with the inline rationale "no protected photo content". #474 asked for a hero/cover photo preview. The trade-off is that any URL embedded in og:image is fetched unauthenticated by every link-preview crawler — so an opted-in image is effectively public to anyone the gallery URL is shared to. Ship as a per-event boolean, default FALSE, so existing galleries never start surfacing photos without explicit admin intent. Schema (migration 102): - events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE. Backend: - galleryOgService.buildOgMetadata: when opt-in is on AND a hero_photo_id is set AND the photo has a generated thumbnail, emit og:image as /og/gallery/:slug/cover. Falls back to the brand logo on any miss (deleted hero, missing thumbnail, no opt-in) so a half-configured gallery still gets a polished preview rather than a broken-image src. - galleryOgService.handleGalleryOgCover: new public endpoint that streams the hero thumbnail. Validates slug shape, checks the opt-in flag + hero presence + thumbnail existence; returns 404 on any failure. ETag = thumbnail mtime + photo id so a regenerated thumb busts crawler caches. Cache-Control: public, max-age=300 (short — admins shouldn't wait an hour for a cover swap to land in chat previews). - server.js: mount the new GET /og/gallery/:slug/cover route. The existing nginx ^~ /og/gallery/ proxy block already covers it. - adminEvents.js: validator + persistence on POST + PUT. formatBoolean coercion so SQLite (0/1) and Postgres (boolean) both behave correctly. Frontend: - Event type + UpdateEventData carry og_image_share_enabled. - EventDetailsPage adds a checkbox under the HeroPhotoSelector, disabled when no hero photo is picked. Help text deliberately spells out the public-by-design consequence — admins shouldn't flip this on for a sensitive gallery without realising what they're sharing with link-preview crawlers. Tests: 8 new in galleryOgService.shareImage.test.js — pin the cover-vs-logo decision contract (3 cases) plus the defensive fallbacks (deleted hero, missing thumbnail) and the 404 contract on the cover endpoint (4 cases). The 404 tests assert that ensureThumbnail() is NOT called when opt-in is off, so a future refactor can't accidentally widen the unauthenticated cover endpoint to expose a hero the admin hasn't shared. i18n: en + de hand-translated; nl + pt + ru + fr machine-translated and flagged for native review per project convention.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Unit tests for the per-event social-share preview opt-in (#474).
|
||||
*
|
||||
* Pins three contracts on `buildOgMetadata`:
|
||||
* - opt-in OFF (or missing) → og:image is the brand logo
|
||||
* - opt-in ON without a hero photo → og:image is the brand logo
|
||||
* - opt-in ON + hero + thumbnail → og:image is the public
|
||||
* /og/gallery/<slug>/cover URL
|
||||
*
|
||||
* Plus the `handleGalleryOgCover` 404 path so we can't accidentally
|
||||
* widen the unauthenticated cover endpoint to expose a hero photo
|
||||
* the admin hasn't opted into sharing.
|
||||
*/
|
||||
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return { db: mockDb };
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../services/imageProcessor', () => ({
|
||||
ensureThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../services/storage', () => ({
|
||||
getStorage: jest.fn(),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const {
|
||||
buildOgMetadata,
|
||||
handleGalleryOgCover,
|
||||
} = require('../services/galleryOgService');
|
||||
|
||||
// The service hits two tables in sequence:
|
||||
// 1. events (slug lookup → may then hit event_slug_redirects)
|
||||
// 2. app_settings (branding lookup)
|
||||
// then optionally a third query when og_image_share_enabled is true:
|
||||
// 3. photos (validate hero exists + has thumbnail)
|
||||
//
|
||||
// Each test queues responses on the shared mock in the order the
|
||||
// service calls them.
|
||||
function chain(result) {
|
||||
const q = {};
|
||||
['where', 'whereIn', 'andWhere', 'select', 'orderBy', 'limit', 'first']
|
||||
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
|
||||
q.first = jest.fn().mockResolvedValue(result?.first);
|
||||
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
|
||||
q.catch = () => q;
|
||||
return q;
|
||||
}
|
||||
|
||||
function mockResolveSlug(event) {
|
||||
// events table query → return event row (or null + no redirects).
|
||||
db.mockImplementationOnce(() => chain({ first: event || null }));
|
||||
if (!event) {
|
||||
// event_slug_redirects fallback — unused here, return null.
|
||||
db.schema = db.schema || {};
|
||||
db.schema.hasTable = jest.fn().mockResolvedValue(false);
|
||||
}
|
||||
}
|
||||
|
||||
function mockBranding() {
|
||||
// app_settings → fetchBranding rows. Empty = pure defaults.
|
||||
db.mockImplementationOnce(() => chain({ rows: [] }));
|
||||
}
|
||||
|
||||
function mockHeroPhoto(photo) {
|
||||
db.mockImplementationOnce(() => chain({ first: photo }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
ensureThumbnail.mockReset();
|
||||
getStorage.mockReset();
|
||||
process.env.FRONTEND_URL = 'https://gallery.example.com';
|
||||
});
|
||||
|
||||
// ---- buildOgMetadata: cover-vs-logo decision ---------------------------
|
||||
|
||||
describe('buildOgMetadata — share-image opt-in', () => {
|
||||
it('uses the brand logo when og_image_share_enabled is false (default)', async () => {
|
||||
mockResolveSlug({
|
||||
id: 1,
|
||||
slug: 'wedding-2026',
|
||||
event_name: 'Wedding 2026',
|
||||
event_date: '2026-06-12',
|
||||
welcome_message: null,
|
||||
hero_photo_id: 99, // hero IS picked
|
||||
og_image_share_enabled: false, // ...but opt-in is off
|
||||
});
|
||||
mockBranding();
|
||||
|
||||
const meta = await buildOgMetadata('wedding-2026', '/gallery/wedding-2026');
|
||||
|
||||
// Falls back to the default logo URL — the picpeak-logo asset
|
||||
// since branding has no logo configured.
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
// Confirm the photos table was NOT queried — opt-in off means no
|
||||
// hero lookup at all.
|
||||
expect(db).toHaveBeenCalledTimes(2); // events + app_settings only
|
||||
});
|
||||
|
||||
it('uses the brand logo when opt-in is on but no hero photo is picked', async () => {
|
||||
mockResolveSlug({
|
||||
id: 2,
|
||||
slug: 'engagement',
|
||||
event_name: 'Engagement',
|
||||
event_date: null,
|
||||
welcome_message: null,
|
||||
hero_photo_id: null, // no hero
|
||||
og_image_share_enabled: true, // opt-in IS on
|
||||
});
|
||||
mockBranding();
|
||||
|
||||
const meta = await buildOgMetadata('engagement', '/gallery/engagement');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
// photos table NOT queried — service short-circuits when hero_photo_id
|
||||
// is falsy, even with opt-in on.
|
||||
expect(db).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses the cover URL when opt-in is on AND hero exists with a thumbnail', async () => {
|
||||
mockResolveSlug({
|
||||
id: 3,
|
||||
slug: 'birthday-2026',
|
||||
event_name: 'Birthday 2026',
|
||||
event_date: '2026-04-15',
|
||||
welcome_message: null,
|
||||
hero_photo_id: 42,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto({
|
||||
id: 42,
|
||||
thumbnail_path: 'thumbnails/thumb_birthday_42.jpg',
|
||||
});
|
||||
|
||||
const meta = await buildOgMetadata('birthday-2026', '/gallery/birthday-2026');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/og/gallery/birthday-2026/cover');
|
||||
});
|
||||
|
||||
it('falls back to the brand logo if the hero photo row is missing', async () => {
|
||||
// Defensive: hero_photo_id points to a photo that no longer
|
||||
// exists (e.g. deleted after admin enabled the toggle). The OG
|
||||
// page must still render with the logo, never a broken image
|
||||
// src in WhatsApp previews.
|
||||
mockResolveSlug({
|
||||
id: 4,
|
||||
slug: 'orphan',
|
||||
event_name: 'Orphan',
|
||||
hero_photo_id: 999,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto(null); // photo deleted
|
||||
|
||||
const meta = await buildOgMetadata('orphan', '/gallery/orphan');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
});
|
||||
|
||||
it('falls back to the brand logo if the hero photo has no thumbnail yet', async () => {
|
||||
// The hero exists but the background processor hasn't generated
|
||||
// its thumbnail yet (or the regenerate failed). Same fallback.
|
||||
mockResolveSlug({
|
||||
id: 5,
|
||||
slug: 'just-uploaded',
|
||||
event_name: 'Just Uploaded',
|
||||
hero_photo_id: 7,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
mockBranding();
|
||||
mockHeroPhoto({ id: 7, thumbnail_path: null });
|
||||
|
||||
const meta = await buildOgMetadata('just-uploaded', '/gallery/just-uploaded');
|
||||
|
||||
expect(meta.image).toBe('https://gallery.example.com/picpeak-logo-transparent.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- handleGalleryOgCover: unauthenticated 404 contract ----------------
|
||||
|
||||
function makeRes() {
|
||||
const res = { headers: {} };
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.type = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.set = jest.fn((kv) => { Object.assign(res.headers, kv); return res; });
|
||||
res.setHeader = jest.fn((k, v) => { res.headers[k] = v; });
|
||||
res.end = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
|
||||
it('returns 400 on an invalid slug shape', async () => {
|
||||
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('returns 404 when the event has og_image_share_enabled = false', async () => {
|
||||
mockResolveSlug({
|
||||
id: 1,
|
||||
slug: 'wedding-2026',
|
||||
hero_photo_id: 99,
|
||||
og_image_share_enabled: false,
|
||||
});
|
||||
const req = { params: { slug: 'wedding-2026' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
// Crucial: ensureThumbnail must NOT be called — we never want to
|
||||
// touch the storage backend for a non-opted-in gallery.
|
||||
expect(ensureThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the event opts in but has no hero_photo_id', async () => {
|
||||
mockResolveSlug({
|
||||
id: 2,
|
||||
slug: 'engagement',
|
||||
hero_photo_id: null,
|
||||
og_image_share_enabled: true,
|
||||
});
|
||||
const req = { params: { slug: 'engagement' }, headers: {} };
|
||||
const res = makeRes();
|
||||
await handleGalleryOgCover(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(ensureThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -400,6 +400,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event opt-in for using hero photo as the social-share preview
|
||||
// image (#474). When false (default), galleryOgService falls back to
|
||||
// the brand logo for og:image / Twitter Card.
|
||||
body('og_image_share_enabled').optional().isBoolean(),
|
||||
// Customer accounts assigned to this event (#354). Optional array of
|
||||
// customer_accounts.id — many-to-many via event_customer_assignments.
|
||||
body('customer_account_ids').optional().isArray(),
|
||||
@@ -663,7 +667,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
...(client_access_enabled && client_password ? {
|
||||
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||
} : {})
|
||||
} : {}),
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Defaults
|
||||
// false on create — admin opts in from the event detail page once
|
||||
// they've picked a hero they're comfortable surfacing publicly.
|
||||
og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true),
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -1156,6 +1164,10 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event opt-in for using hero photo as the social-share preview
|
||||
// image (#474). When false (default), galleryOgService falls back to
|
||||
// the brand logo for og:image / Twitter Card.
|
||||
body('og_image_share_enabled').optional().isBoolean(),
|
||||
// Customer accounts assigned to this event (#354). Optional array of
|
||||
// customer_accounts.id — many-to-many via event_customer_assignments.
|
||||
body('customer_account_ids').optional().isArray(),
|
||||
@@ -1317,6 +1329,12 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
|
||||
// SQLite stores 0/1 and Postgres stores boolean true/false.
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) {
|
||||
updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true);
|
||||
}
|
||||
|
||||
// Per-event promotional override (#440). Normalize promo_markdown to
|
||||
// NULL when mode is anything other than 'custom' so we don't carry
|
||||
// stale text after the admin switches modes. Empty markdown also
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { ensureThumbnail } = require('./imageProcessor');
|
||||
const { getStorage } = require('./storage');
|
||||
|
||||
const SOCIAL_CRAWLER_PATTERNS = [
|
||||
/facebookexternalhit/i,
|
||||
@@ -149,10 +151,27 @@ async function buildOgMetadata(slug, requestPath) {
|
||||
description = `Photo gallery from ${eventName}.`;
|
||||
}
|
||||
|
||||
// Per-event hero-photo opt-in (#474). When the admin has flipped
|
||||
// events.og_image_share_enabled AND a hero_photo_id is set AND that
|
||||
// photo has a generated thumbnail, point og:image at the public
|
||||
// cover endpoint instead of the brand logo. Falls back silently to
|
||||
// the logo on any of those misses so a half-configured event still
|
||||
// gets a polished link preview rather than a broken image.
|
||||
let image = logoUrl;
|
||||
if (event.og_image_share_enabled && event.hero_photo_id) {
|
||||
const heroPhoto = await db('photos')
|
||||
.where({ id: event.hero_photo_id, event_id: event.id })
|
||||
.select('id', 'thumbnail_path')
|
||||
.first();
|
||||
if (heroPhoto && heroPhoto.thumbnail_path) {
|
||||
image = `${base}/og/gallery/${event.slug}/cover`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
image: logoUrl,
|
||||
image,
|
||||
url: `${base}/gallery/${event.slug}`,
|
||||
siteName,
|
||||
eventName,
|
||||
@@ -210,9 +229,85 @@ async function handleGalleryOgRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public cover-image endpoint for OG/Twitter Card previews (#474).
|
||||
*
|
||||
* Streams the gallery's hero-photo thumbnail unauthenticated — but
|
||||
* ONLY when the admin has flipped events.og_image_share_enabled on
|
||||
* that event. Any miss (slug not found, opt-in not set, no hero, no
|
||||
* thumbnail) returns 404; buildOgMetadata above falls back to the
|
||||
* brand logo for the og:image when this would 404, so callers never
|
||||
* see a broken-image preview.
|
||||
*
|
||||
* Why a dedicated endpoint instead of reusing /api/gallery/:slug/
|
||||
* thumbnail/:photoId — the latter is gated by verifyGalleryAccess
|
||||
* (gallery JWT or per-event password). Social crawlers don't carry
|
||||
* either, so we need a separate, explicitly-public path that the
|
||||
* admin opted into.
|
||||
*/
|
||||
async function handleGalleryOgCover(req, res) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) {
|
||||
res.status(400).type('text/plain').send('Invalid gallery slug');
|
||||
return;
|
||||
}
|
||||
const event = await resolveSlug(slug);
|
||||
if (!event || !event.og_image_share_enabled || !event.hero_photo_id) {
|
||||
res.status(404).type('text/plain').send('Cover not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: event.hero_photo_id, event_id: event.id })
|
||||
.first();
|
||||
if (!photo) {
|
||||
res.status(404).type('text/plain').send('Cover not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
if (!thumbnailPath) {
|
||||
res.status(404).type('text/plain').send('Cover not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(thumbnailPath);
|
||||
if (!stat) {
|
||||
res.status(404).type('text/plain').send('Cover not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// ETag = thumbnail mtime + photo id so a regenerated thumb (e.g.
|
||||
// after the admin changes thumbnail fit mode) busts crawler
|
||||
// caches. Keep the cache window short on the response itself —
|
||||
// crawlers like WhatsApp re-fetch eagerly; admins shouldn't have
|
||||
// to wait an hour for a swap to land in chat previews.
|
||||
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
|
||||
const etag = `"og-cover-${photo.id}-${mtimeMs}"`;
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'ETag': etag,
|
||||
});
|
||||
if (stat.size) res.setHeader('Content-Length', stat.size);
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stream gallery OG cover', { error: error.message });
|
||||
res.status(500).type('text/plain').send('Internal server error');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSocialCrawler,
|
||||
buildOgMetadata,
|
||||
renderOgHtml,
|
||||
handleGalleryOgRequest
|
||||
handleGalleryOgRequest,
|
||||
handleGalleryOgCover
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user