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:
@@ -1011,6 +1011,11 @@
|
||||
"mode_off": "Aus (für dieses Event ausblenden)",
|
||||
"placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))",
|
||||
"preview": "Vorschau"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Heldenbild als Vorschau für geteilte Links verwenden",
|
||||
"help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.",
|
||||
"heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -650,6 +650,11 @@
|
||||
"mode_off": "Off (hide for this event)",
|
||||
"placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Use hero photo as social-share preview",
|
||||
"help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.",
|
||||
"heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -664,6 +664,11 @@
|
||||
"mode_off": "Désactivé (masquer pour cet événement)",
|
||||
"placeholder": "Contenu Markdown (ex. **Offre :** [réservez votre prochaine séance](https://example.com))",
|
||||
"preview": "Aperçu"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Utiliser la photo principale comme aperçu de partage social",
|
||||
"help": "Quand l’URL de cette galerie est partagée sur WhatsApp, Facebook, Slack, etc., l’aperçu du lien affichera la photo principale ci-dessus. La vignette est récupérée sans authentification par les robots d’aperçu — toute personne partageant l’URL rend cette image effectivement publique. Désactivé par défaut ; choisissez une photo principale que vous êtes prêt à exposer publiquement avant d’activer.",
|
||||
"heroRequired": "Choisissez d’abord une photo principale ci-dessus — cette option l’utilise comme image d’aperçu sur WhatsApp / Facebook / Slack."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -650,6 +650,11 @@
|
||||
"mode_off": "Uit (verbergen voor dit evenement)",
|
||||
"placeholder": "Markdown-inhoud (bijv. **Aanbieding:** [boek je volgende sessie](https://example.com))",
|
||||
"preview": "Voorbeeld"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Hero-foto gebruiken als social-share-voorbeeld",
|
||||
"help": "Wanneer deze galerij-URL wordt gedeeld op WhatsApp, Facebook, Slack enz., toont de link-preview de bovenstaande hero-foto. De thumbnail wordt niet-geauthenticeerd opgehaald door link-preview-crawlers — wie de URL deelt maakt deze afbeelding daarmee in feite openbaar. Standaard uit; kies eerst een hero die je bewust openbaar wilt tonen voordat je dit inschakelt.",
|
||||
"heroRequired": "Kies eerst hierboven een hero-foto — deze optie gebruikt die als WhatsApp- / Facebook- / Slack-voorbeeldafbeelding."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -666,6 +666,11 @@
|
||||
"mode_off": "Desligado (ocultar neste evento)",
|
||||
"placeholder": "Conteúdo em Markdown (ex.: **Oferta especial:** [agende sua próxima sessão](https://example.com))",
|
||||
"preview": "Pré-visualização"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Usar a foto principal como visualização ao compartilhar",
|
||||
"help": "Quando o URL desta galeria é compartilhado no WhatsApp, Facebook, Slack etc., a pré-visualização do link mostrará a foto principal acima. A miniatura é obtida sem autenticação pelos rastreadores de pré-visualização — quem compartilha o URL torna esta imagem efetivamente pública. Desativado por padrão; escolha uma foto principal que você esteja confortável em expor publicamente antes de ativar.",
|
||||
"heroRequired": "Escolha primeiro uma foto principal acima — esta opção a usa como imagem de pré-visualização no WhatsApp / Facebook / Slack."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -682,6 +682,11 @@
|
||||
"mode_off": "Выключено (скрыть для этого события)",
|
||||
"placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))",
|
||||
"preview": "Предпросмотр"
|
||||
},
|
||||
"ogShare": {
|
||||
"title": "Использовать главное фото как превью при шаринге",
|
||||
"help": "Когда ссылку на эту галерею делят в WhatsApp, Facebook, Slack и т.п., в превью будет показано выбранное выше главное фото. Миниатюру забирают краулеры превью без авторизации — каждый, кто делится ссылкой, фактически делает это изображение публичным. По умолчанию выключено; выберите главное фото, которое вы готовы показать публично, прежде чем включать.",
|
||||
"heroRequired": "Сначала выберите главное фото выше — эта опция использует его как превью в WhatsApp / Facebook / Slack."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -295,6 +295,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// the GET /admin/events/:id response and sent back as a flat id
|
||||
// array on save.
|
||||
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
|
||||
// Per-event opt-in for hero photo as social-share preview (#474).
|
||||
og_image_share_enabled: boolean;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -337,6 +339,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
promo_markdown: '',
|
||||
// Customer accounts (#354) — hydrated from event response.
|
||||
customer_accounts: [],
|
||||
// Per-event social-share opt-in (#474). Default false everywhere
|
||||
// so a freshly opened editor never displays "on" against the saved
|
||||
// (off) state.
|
||||
og_image_share_enabled: false,
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -600,6 +606,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// the picker's shape.
|
||||
customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || [])
|
||||
.map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })),
|
||||
// Per-event social-share opt-in (#474). Coerce explicitly so
|
||||
// SQLite's 0/1 and Postgres's true/false both render the switch
|
||||
// in the right state on first paint.
|
||||
og_image_share_enabled: event.og_image_share_enabled === true,
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -764,6 +774,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
if (editForm.hero_photo_id !== undefined) {
|
||||
updateData.hero_photo_id = editForm.hero_photo_id;
|
||||
}
|
||||
// Per-event hero-photo OG share opt-in (#474). Always send the
|
||||
// current state — the backend writes through formatBoolean either
|
||||
// way, so an explicit save can flip the value back to false.
|
||||
updateData.og_image_share_enabled = editForm.og_image_share_enabled;
|
||||
updateData.source_mode = editForm.source_mode;
|
||||
updateData.external_path = editForm.source_mode === 'reference'
|
||||
? externalPathToSave
|
||||
@@ -1173,6 +1187,35 @@ export const EventDetailsPage: React.FC = () => {
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
{/* Per-event social-share opt-in (#474). Toggle is
|
||||
disabled when no hero photo is picked — there's
|
||||
nothing to surface as the cover. The help text
|
||||
deliberately spells out the public-by-design
|
||||
consequence so an admin doesn't flip this on for
|
||||
a sensitive gallery without realising what they're
|
||||
sharing with link-preview crawlers. */}
|
||||
<div className="ml-6 mt-3">
|
||||
<label className={`flex items-start gap-2 cursor-pointer ${editForm.hero_photo_id ? '' : 'opacity-60 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={editForm.og_image_share_enabled === true}
|
||||
disabled={!editForm.hero_photo_id}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, og_image_share_enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('events.ogShare.title', 'Use hero photo as social-share preview')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{editForm.hero_photo_id
|
||||
? t('events.ogShare.help', 'When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.')
|
||||
: t('events.ogShare.heroRequired', 'Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Hero Image Focal Point Picker (#162) */}
|
||||
{editForm.hero_photo_id && (() => {
|
||||
const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
|
||||
|
||||
@@ -66,6 +66,8 @@ interface UpdateEventData {
|
||||
external_path?: string | null;
|
||||
photo_cap?: number | null;
|
||||
default_photo_sort?: string;
|
||||
// Per-event opt-in for hero photo as social-share preview (#474).
|
||||
og_image_share_enabled?: boolean;
|
||||
// Customer accounts (#354). Same semantics as on CreateEventData;
|
||||
// omit the field to leave assignments untouched, send [] to clear.
|
||||
customer_account_ids?: number[];
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface Event {
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position?: 'top' | 'center' | 'bottom';
|
||||
hero_logo_url?: string | null;
|
||||
// Per-event opt-in for using the hero photo as the social-share
|
||||
// preview image (#474). When false, og:image falls back to the
|
||||
// brand logo. Defaults false on existing rows so no admin's hero
|
||||
// photo gets surfaced via WhatsApp share until they consciously
|
||||
// flip it on.
|
||||
og_image_share_enabled?: boolean;
|
||||
// Header style settings (decoupled from layout)
|
||||
header_style?: 'hero' | 'standard' | 'minimal' | 'none';
|
||||
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
|
||||
|
||||
Reference in New Issue
Block a user