diff --git a/backend/server.js b/backend/server.js index 2c809b9a..69879c89 100644 --- a/backend/server.js +++ b/backend/server.js @@ -470,6 +470,13 @@ if (process.env.NODE_ENV === 'development') { }); } +// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp, +// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags +// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to +// here; humans still get the SPA via try_files. +const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService'); +app.get('/og/gallery/:slug', handleGalleryOgRequest); + // robots.txt endpoint (dynamic, served from DB settings) const { generateRobotsTxt } = require('./src/services/robotsTxtService'); app.get('/robots.txt', async (req, res) => { @@ -576,7 +583,16 @@ try { res.sendFile(indexPath); }); - // SPA fallback for admin + gallery routes + // SPA fallback for admin + gallery routes. For gallery URLs we intercept + // social-crawler User-Agents and serve OG/Twitter-card metadata so link + // previews show the event name + branding instead of the SPA stub. + app.get('/gallery/:slug/:token?', (req, res, next) => { + if (isSocialCrawler(req.get('user-agent'))) { + return handleGalleryOgRequest(req, res); + } + return next(); + }, (req, res) => res.sendFile(indexPath)); + app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => { res.sendFile(indexPath); }); diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js new file mode 100644 index 00000000..135363eb --- /dev/null +++ b/backend/src/services/galleryOgService.js @@ -0,0 +1,218 @@ +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +const SOCIAL_CRAWLER_PATTERNS = [ + /facebookexternalhit/i, + /facebot/i, + /Twitterbot/i, + /WhatsApp/i, + /Slackbot/i, + /TelegramBot/i, + /SkypeUriPreview/i, + /Discordbot/i, + /LinkedInBot/i, + /Pinterest/i, + /vkShare/i, + /redditbot/i, + /Embedly/i, + /iframely/i, + /Snapchat/i, + /Applebot/i, + /quora link preview/i, + /Mastodon/i, + /Bluesky/i, + /OpenGraph/i, + /opengraph/i +]; + +function isSocialCrawler(userAgent) { + if (!userAgent) return false; + return SOCIAL_CRAWLER_PATTERNS.some((re) => re.test(userAgent)); +} + +function parseSettingValue(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +async function fetchBranding() { + const rows = await db('app_settings') + .whereIn('setting_key', [ + 'branding_company_name', + 'branding_company_tagline', + 'branding_logo_url' + ]); + + const branding = { companyName: null, companyTagline: null, logoUrl: null }; + for (const row of rows) { + const parsed = parseSettingValue(row.setting_value); + switch (row.setting_key) { + case 'branding_company_name': + branding.companyName = parsed || null; + break; + case 'branding_company_tagline': + branding.companyTagline = parsed || null; + break; + case 'branding_logo_url': + branding.logoUrl = parsed || null; + break; + default: + break; + } + } + return branding; +} + +async function resolveSlug(slug) { + let event = await db('events').where('slug', slug).first(); + if (event) return event; + // Honour slug redirects so renamed galleries still get rich previews. + const hasRedirects = await db.schema.hasTable('event_slug_redirects'); + if (hasRedirects) { + const redirect = await db('event_slug_redirects').where('old_slug', slug).first(); + if (redirect) { + event = await db('events').where('slug', redirect.new_slug).first(); + } + } + return event || null; +} + +function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function absoluteUrl(maybeRelative, base) { + if (!maybeRelative) return null; + if (/^https?:\/\//i.test(maybeRelative)) return maybeRelative; + try { + return new URL(maybeRelative, base).toString(); + } catch { + return null; + } +} + +function frontendBase() { + return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, ''); +} + +function formatEventDate(value) { + if (!value) return null; + try { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + } catch { + return null; + } +} + +async function buildOgMetadata(slug, requestPath) { + const event = await resolveSlug(slug); + const branding = await fetchBranding(); + const base = frontendBase(); + const siteName = branding.companyName || 'PicPeak'; + const logoUrl = absoluteUrl(branding.logoUrl, base) || `${base}/picpeak-logo-transparent.png`; + + if (!event) { + return { + title: siteName, + description: branding.companyTagline || 'Photo gallery shared with PicPeak.', + image: logoUrl, + url: `${base}${requestPath}`, + siteName + }; + } + + const eventName = event.event_name || 'Photo Gallery'; + const eventDate = formatEventDate(event.event_date); + const titleParts = [eventName]; + if (siteName && siteName !== eventName) titleParts.push(siteName); + const title = titleParts.join(' — '); + + let description; + if (event.welcome_message) { + description = String(event.welcome_message).replace(/\s+/g, ' ').trim().slice(0, 200); + } else if (eventDate) { + description = `Photo gallery from ${eventName} on ${eventDate}.`; + } else { + description = `Photo gallery from ${eventName}.`; + } + + return { + title, + description, + image: logoUrl, + url: `${base}/gallery/${event.slug}`, + siteName, + eventName, + eventDate + }; +} + +function renderOgHtml(meta) { + const t = escapeHtml(meta.title); + const d = escapeHtml(meta.description); + const i = escapeHtml(meta.image); + const u = escapeHtml(meta.url); + const s = escapeHtml(meta.siteName); + + return ` + + + + ${t} + + + + + + + + + + + + + + +

${t}

+

${d}

+

View gallery

+ +`; +} + +async function handleGalleryOgRequest(req, res) { + try { + const { slug } = req.params; + if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) { + res.status(400).type('text/plain').send('Invalid gallery slug'); + return; + } + const meta = await buildOgMetadata(slug, req.originalUrl); + res.set('Cache-Control', 'public, max-age=300'); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(renderOgHtml(meta)); + } catch (error) { + logger.error('Failed to render gallery OG page', { error: error.message }); + res.status(500).type('text/plain').send('Internal server error'); + } +} + +module.exports = { + isSocialCrawler, + buildOgMetadata, + renderOgHtml, + handleGalleryOgRequest +}; diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8b15f7c3..f3bf2e13 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -148,6 +148,29 @@ server { proxy_read_timeout 60s; } + # Social-crawler detection for gallery share URLs. Crawlers (WhatsApp, + # 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_-]+)(?:/[^/]+)?/?$ { + if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") { + rewrite ^ /og/gallery/$gallery_slug last; + } + try_files $uri $uri/ /index.html; + } + + # OG preview endpoint (proxied to backend). Public endpoint by design — + # only exposes event_name + branding logo, no protected photo content. + location ^~ /og/gallery/ { + 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 $scheme; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf index 498492b1..07692ea1 100644 --- a/frontend/nginx.dev.conf +++ b/frontend/nginx.dev.conf @@ -27,6 +27,23 @@ server { add_header Content-Type text/plain; } + # Gallery share URLs: route social-crawler UAs to backend OG handler. + location ~ ^/gallery/(?[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ { + if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") { + rewrite ^ /og/gallery/$gallery_slug last; + } + try_files $uri $uri/ /index.html; + } + + location ^~ /og/gallery/ { + proxy_pass http://backend: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 $scheme; + } + # SPA fallback location / { try_files $uri $uri/ /index.html;