fix(og): route branded short URLs + slideshow links to OG, add Viber (#699)

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/<slug>, #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/<slug>/show/<token>) 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/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
This commit is contained in:
Paul Nothaft
2026-07-05 21:19:53 +02:00
parent f15e104702
commit 0dffe0ce92
3 changed files with 35 additions and 3 deletions
@@ -343,6 +343,8 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
// 3rd-party preview services used by business-messaging stacks // 3rd-party preview services used by business-messaging stacks
'LinkPreview/1.0', 'LinkPreview/1.0',
'Slack-ImgProxy/1.0', 'Slack-ImgProxy/1.0',
// Viber link-preview fetcher (#699 follow-up)
'Mozilla/5.0 (compatible; Viber)',
]; ];
for (const ua of knownBots) { for (const ua of knownBots) {
expect(isSocialCrawler(ua)).toBe(true); expect(isSocialCrawler(ua)).toBe(true);
+5 -1
View File
@@ -34,7 +34,11 @@ const SOCIAL_CRAWLER_PATTERNS = [
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the // messaging stacks (Twilio, LinkPreview.net, etc.). Match the
// canonical lowercase substring; the /i flag handles case. // canonical lowercase substring; the /i flag handles case.
/LinkPreview/i, /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) { function isSocialCrawler(userAgent) {
+28 -2
View File
@@ -237,12 +237,22 @@ server {
# Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side # 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 # 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. # via internal rewrite; humans fall through to the SPA via try_files.
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ { # {0,2} extra path segments so the deeper gallery share shapes match too:
# /gallery/<slug> (public / share-token link)
# /gallery/<slug>/client-access (client-access; token is in ?query)
# /gallery/<slug>/show/<token> (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/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+){0,2}/?$" {
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in # Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot # backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
# and LinkPreview / Slack-ImgProxy added in #521 to catch # and LinkPreview / Slack-ImgProxy added in #521 to catch
# business-API preview fetchers that aren't the main WhatsApp app. # 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; rewrite ^ /og/gallery/$gallery_slug last;
} }
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@@ -260,6 +270,22 @@ server {
proxy_set_header X-Forwarded-Proto $real_proto; proxy_set_header X-Forwarded-Proto $real_proto;
} }
# Branded URL shortener (#699). The backend `/s/<short_slug>` 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 # SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;