From 0dffe0ce92339e0608b3ef660e84c31a62f4a98c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 5 Jul 2026 21:19:53 +0200 Subject: [PATCH 1/2] fix(og): route branded short URLs + slideshow links to OG, add Viber (#699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #699/#700/#702 — the OG SSR handler existed but three link shapes never reached it behind the frontend nginx: - Branded short URLs (/s/, #702) had NO nginx location, so they fell through to the SPA — which has no /s/ route. Dead for humans (no 302 redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose /s/:shortSlug route already handles both. - Slideshow links (/gallery//show/) have TWO extra path segments; the crawler-detect location regex allowed only one, so they never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra segments (quoted regex — the braces would otherwise be parsed as nginx config delimiters). client-access still matches (its token is in ?query, one path segment). - Viber's preview fetcher wasn't in either UA list, so Viber shares showed no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync). Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness confirms /s/ proxies to the backend, slideshow + Viber + share-token + client-access crawler UAs all rewrite to /og/gallery/, and browsers still get the SPA. Backend isSocialCrawler test extended for Viber. --- .../galleryOgService.shareImage.test.js | 2 ++ backend/src/services/galleryOgService.js | 6 +++- frontend/nginx.conf | 30 +++++++++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backend/src/__tests__/galleryOgService.shareImage.test.js b/backend/src/__tests__/galleryOgService.shareImage.test.js index fd5f8b2e..879db273 100644 --- a/backend/src/__tests__/galleryOgService.shareImage.test.js +++ b/backend/src/__tests__/galleryOgService.shareImage.test.js @@ -343,6 +343,8 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => { // 3rd-party preview services used by business-messaging stacks 'LinkPreview/1.0', 'Slack-ImgProxy/1.0', + // Viber link-preview fetcher (#699 follow-up) + 'Mozilla/5.0 (compatible; Viber)', ]; for (const ua of knownBots) { expect(isSocialCrawler(ua)).toBe(true); diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index a1ed8ef9..038c42f7 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -34,7 +34,11 @@ const SOCIAL_CRAWLER_PATTERNS = [ // messaging stacks (Twilio, LinkPreview.net, etc.). Match the // canonical lowercase substring; the /i flag handles case. /LinkPreview/i, - /Slack-ImgProxy/i + /Slack-ImgProxy/i, + // Viber's link-preview fetcher — was never detected, so shared links + // showed no rich preview in Viber (#699 follow-up). Keep in sync with the + // UA list in frontend/nginx.conf. + /Viber/i ]; function isSocialCrawler(userAgent) { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 45175a03..c0f70c57 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -237,12 +237,22 @@ server { # Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side # meta tags never reach them. Route those UAs to backend's /og handler # via internal rewrite; humans fall through to the SPA via try_files. - location ~ ^/gallery/(?[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ { + # {0,2} extra path segments so the deeper gallery share shapes match too: + # /gallery/ (public / share-token link) + # /gallery//client-access (client-access; token is in ?query) + # /gallery//show/ (slideshow; token is a PATH segment) + # The slideshow shape has TWO extra segments (show + token) — the old + # single-segment `(?:/[^/]+)?` never matched it, so slideshow links fell + # through to the SPA and got only the generic site-wide OG (#699 follow-up). + # NB: the regex is QUOTED because the {0,2} quantifier's braces would + # otherwise be parsed as nginx config block delimiters. + location ~ "^/gallery/(?[A-Za-z0-9_-]+)(?:/[^/]+){0,2}/?$" { # Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in # backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot # and LinkPreview / Slack-ImgProxy added in #521 to catch # business-API preview fetchers that aren't the main WhatsApp app. - if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") { + # Viber added in #699 follow-up (its preview fetcher was never detected). + if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview|Viber)") { rewrite ^ /og/gallery/$gallery_slug last; } try_files $uri $uri/ /index.html; @@ -260,6 +270,22 @@ server { proxy_set_header X-Forwarded-Proto $real_proto; } + # Branded URL shortener (#699). The backend `/s/` route both + # 302-redirects humans to the target gallery AND server-renders OG for + # social crawlers. Without this proxy, `/s/...` fell through to the SPA + # (which has no /s/ route) — so branded short links were dead for humans + # and crawlers alike. `^~` beats the regex SPA fallback below. The backend + # does its own UA detection, so no crawler `if` is needed here. + location ^~ /s/ { + set $backend_upstream backend; + proxy_pass http://$backend_upstream:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $real_proto; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; From a0a28a47777db9ca9e60a5134c8d86503c060e79 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 5 Jul 2026 23:37:16 +0200 Subject: [PATCH 2/2] fix(og): broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync): Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal, Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap, Google-PageRenderer, OdklBot, ZoomBot. Deliberately NOT added: UAs shared with real human in-app browsers (WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings ('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG response is meta-only with no redirect, so matching those would serve a human the bare stub. New negative test locks that exclusion in. Verified: nginx -t passes; live harness confirms the new tokens rewrite to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15. --- .../galleryOgService.shareImage.test.js | 22 ++++++++++++++++++- backend/src/services/galleryOgService.js | 20 ++++++++++++++++- frontend/nginx.conf | 7 ++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/src/__tests__/galleryOgService.shareImage.test.js b/backend/src/__tests__/galleryOgService.shareImage.test.js index 879db273..3f10efdb 100644 --- a/backend/src/__tests__/galleryOgService.shareImage.test.js +++ b/backend/src/__tests__/galleryOgService.shareImage.test.js @@ -343,14 +343,34 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => { // 3rd-party preview services used by business-messaging stacks 'LinkPreview/1.0', 'Slack-ImgProxy/1.0', - // Viber link-preview fetcher (#699 follow-up) + // Viber + broader crawler set (#699 follow-up) 'Mozilla/5.0 (compatible; Viber)', + 'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)', + 'facebookcatalog/1.0', + 'kakaotalk-scrap/1.0', + 'Mozilla/5.0 (compatible; Synapse/1.98)', + 'Rocket.Chat/6.0', ]; for (const ua of knownBots) { expect(isSocialCrawler(ua)).toBe(true); } }); + it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => { + // These share a token with a preview bot but are also sent by real users + // browsing inside the app's webview — matching them would serve a human + // the bare OG stub. Deliberately excluded; guard against re-adding them. + const inAppBrowsers = [ + 'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app + 'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app + 'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app + 'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap + ]; + for (const ua of inAppBrowsers) { + expect(isSocialCrawler(ua)).toBe(false); + } + }); + 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', diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index 038c42f7..5cac9cf6 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -38,7 +38,25 @@ const SOCIAL_CRAWLER_PATTERNS = [ // Viber's link-preview fetcher — was never detected, so shared links // showed no rich preview in Viber (#699 follow-up). Keep in sync with the // UA list in frontend/nginx.conf. - /Viber/i + /Viber/i, + // Broader crawler coverage (#699 follow-up, from alexvaltchev's field list). + // IMPORTANT: only CRAWLER-EXCLUSIVE tokens are added here. Our OG response is + // meta-only (no client redirect), so a UA shared with a real human in-app + // browser would serve that human the bare stub. That rules out WeChat + // (MicroMessenger), LINE (Line/), Zalo, and generic strings like + // "InAppBrowser"/"preview"/"unfurl" — deliberately NOT added. + /Cardyb/i, // Bluesky's link-card service (the actual fetcher UA) + /facebookcatalog/i, // Facebook catalog crawler + /Signal/i, // Signal link preview + /Misskey/i, // fediverse (server-side preview fetch) + /Pleroma/i, // fediverse + /Synapse/i, // Matrix homeserver URL preview + /Nextcloud/i, // Nextcloud Talk/News link crawler + /Rocket\.Chat/i, // Rocket.Chat server preview + /kakaotalk-scrap/i, // KakaoTalk's scraper (NOT the in-app browser UA) + /Google-PageRenderer/i, // Google Chat previews (not Search) + /OdklBot/i, // Odnoklassniki + /ZoomBot/i // Zoom Team Chat ]; function isSocialCrawler(userAgent) { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c0f70c57..442a4306 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -251,8 +251,11 @@ server { # backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot # and LinkPreview / Slack-ImgProxy added in #521 to catch # business-API preview fetchers that aren't the main WhatsApp app. - # Viber added in #699 follow-up (its preview fetcher was never detected). - if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview|Viber)") { + # Viber + the broader set below added in #699 follow-up. Only + # CRAWLER-EXCLUSIVE tokens — the backend OG response is meta-only (no + # redirect), so UAs shared with real human in-app browsers (WeChat's + # MicroMessenger, LINE's "Line/", Zalo, "InAppBrowser") are NOT added. + if ($http_user_agent ~* "(facebookexternalhit|facebookcatalog|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|Cardyb|OpenGraph|LinkPreview|Viber|Signal|Misskey|Pleroma|Synapse|Nextcloud|Rocket\.Chat|kakaotalk-scrap|Google-PageRenderer|OdklBot|ZoomBot)") { rewrite ^ /og/gallery/$gallery_slug last; } try_files $uri $uri/ /index.html;