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:
Paul Nothaft
2026-05-13 13:47:02 +02:00
parent 16e4d191c2
commit 0bc7e2af17
14 changed files with 488 additions and 4 deletions
@@ -0,0 +1,43 @@
/**
* Migration: Per-event opt-in for using the gallery hero photo as the
* Open Graph share image (#474).
*
* Background: galleryOgService already serves OG/Twitter Card meta
* tags to social-crawler User-Agents (WhatsApp, Facebook, Slack,
* Telegram, Discord, etc.) for /gallery/<slug> URLs — see
* frontend/nginx.conf and backend/src/services/galleryOgService.js.
* Today the og:image is always the brand logo, with the inline
* rationale "no protected photo content."
*
* #474 asks for a hero/cover photo preview. The trade-off is that
* the og:image is fetched unauthenticated by every link-preview
* crawler, so any opted-in image is effectively public. We ship
* this as a per-event boolean, default FALSE, so existing galleries
* never start surfacing photos until the admin consciously flips it
* on per gallery.
*
* When set to TRUE and the event has a hero_photo_id with a
* generated thumbnail, galleryOgService points og:image at the new
* /og/gallery/:slug/cover endpoint. With it set to FALSE (or no
* hero photo selected) the brand logo is used as before.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (await knex.schema.hasColumn('events', 'og_image_share_enabled')) return;
await knex.schema.alterTable('events', (table) => {
// Default false everywhere so an upgrade never starts leaking the
// hero photo of a password-protected gallery without admin intent.
table.boolean('og_image_share_enabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('events'))) return;
if (!(await knex.schema.hasColumn('events', 'og_image_share_enabled'))) return;
await knex.schema.alterTable('events', (table) => {
table.dropColumn('og_image_share_enabled');
});
};