diff --git a/backend/src/__tests__/galleryOgService.shareImage.test.js b/backend/src/__tests__/galleryOgService.shareImage.test.js index 7e9e623a..c6c8b60e 100644 --- a/backend/src/__tests__/galleryOgService.shareImage.test.js +++ b/backend/src/__tests__/galleryOgService.shareImage.test.js @@ -35,6 +35,7 @@ const { getStorage } = require('../services/storage'); const { buildOgMetadata, handleGalleryOgCover, + isSocialCrawler, } = require('../services/galleryOgService'); // The service hits two tables in sequence: @@ -237,3 +238,52 @@ describe('handleGalleryOgCover — 404 unless explicitly opted in', () => { expect(ensureThumbnail).not.toHaveBeenCalled(); }); }); + +// Regression for #521 — WhatsApp Business API + 3rd-party preview +// services use UAs that aren't "WhatsApp/X.Y.Z". If isSocialCrawler +// misses them, those requests fall through to the static SPA shell +// and the link preview ends up unbranded. +describe('isSocialCrawler — extended bot coverage (#521)', () => { + it('matches every UA the README/changelog claims to support', () => { + // Pin the contract: each listed UA must hit the crawler path so the + // nginx rewrite + backend OG handler stay in sync. Adding a new UA + // here without also adding it to nginx.conf would silently regress. + const knownBots = [ + // Main WhatsApp app + 'WhatsApp/2.23.20.0', + // WhatsApp Business / Cloud API variants + 'WhatsAppBot/1.0', + 'wa-bot/2.0', + // Other messaging app crawlers + 'facebookexternalhit/1.1', + 'Twitterbot/1.0', + 'Slackbot-LinkExpanding 1.0', + 'TelegramBot (like TwitterBot)', + // 3rd-party preview services used by business-messaging stacks + 'LinkPreview/1.0', + 'Slack-ImgProxy/1.0', + ]; + for (const ua of knownBots) { + expect(isSocialCrawler(ua)).toBe(true); + } + }); + + it('does not match a regular browser UA', () => { + const browsers = [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15', + // Browser UA that happens to contain "Mobile" — guard against an + // over-broad regex landing on it. + 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36', + ]; + for (const ua of browsers) { + expect(isSocialCrawler(ua)).toBe(false); + } + }); + + it('returns false for null/empty/undefined UAs', () => { + expect(isSocialCrawler(null)).toBe(false); + expect(isSocialCrawler(undefined)).toBe(false); + expect(isSocialCrawler('')).toBe(false); + }); +}); diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index 038a279c..7381701f 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -7,7 +7,12 @@ const SOCIAL_CRAWLER_PATTERNS = [ /facebookexternalhit/i, /facebot/i, /Twitterbot/i, + // WhatsApp's main app crawler is "WhatsApp/X.Y.Z"; the Business + // API and some Cloud API senders use "WhatsAppBot" or "wa-bot/" — + // detect both so API-driven sends get the rich preview too (#521). /WhatsApp/i, + /WhatsAppBot/i, + /wa-bot/i, /Slackbot/i, /TelegramBot/i, /SkypeUriPreview/i, @@ -24,7 +29,12 @@ const SOCIAL_CRAWLER_PATTERNS = [ /Mastodon/i, /Bluesky/i, /OpenGraph/i, - /opengraph/i + /opengraph/i, + // Generic preview/scrape services commonly used in business + // messaging stacks (Twilio, LinkPreview.net, etc.). Match the + // canonical lowercase substring; the /i flag handles case. + /LinkPreview/i, + /Slack-ImgProxy/i ]; function isSocialCrawler(userAgent) { diff --git a/frontend/.env.example b/frontend/.env.example index 6000b0ec..33bc0f99 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,19 @@ +# Static HTML fallback for social-link previews (#521). +# +# Most link previews (WhatsApp, Facebook, Slack, etc.) hit the backend's +# per-event OG endpoint and get the actual event name + branding. Some +# third-party preview services and the WhatsApp Business API cache +# metadata with a non-crawler User-Agent and end up reading these static +# values instead. Set these to your brand so that fallback isn't generic +# "PicPeak - Photo Sharing Platform". +# +# These are baked into index.html at build time, so they take effect on +# the next `npm run build` / docker build. Live admin Branding settings +# do NOT propagate here — for that, use the per-event OG endpoint, which +# always serves the live branded preview. +VITE_DEFAULT_TITLE=PicPeak +VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak. + # Backend API URL # For local development with Docker: VITE_API_URL=http://localhost:3001/api diff --git a/frontend/.env.production.example b/frontend/.env.production.example index 8640bbd1..ddbdd7ea 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -1,6 +1,12 @@ # Production Environment Configuration # When running behind a reverse proxy like Traefik, use relative URLs +# Static HTML fallback for social-link previews (#521). +# Override these with your brand so previews that hit the static +# index.html (vs the per-event OG endpoint) aren't generic. +VITE_DEFAULT_TITLE=PicPeak +VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak. + # Backend API URL # For production behind reverse proxy, use relative URL: VITE_API_URL=/api diff --git a/frontend/index.html b/frontend/index.html index 7b3cce53..54c2d54c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,33 @@ -