From 7964d499e204ecc03a0556c688ba5da3305f2fc3 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 16:05:43 +0200 Subject: [PATCH] OpenGraph meta on the SPA's shareable routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side render OG + Twitter Card meta for the routes where rich link previews matter: / — homepage /personvern — privacy + how-it-works /a/:id — activity permalink /tags/:tag — tag page //list — public list (opt-in) Everything else falls through to the unmodified SPA shell. Approach: in production mode, register Hono handlers BEFORE the catch-all that read the prebuilt index.html template, swap and <meta name="description"> with route-specific values, and inject an OG/Twitter meta block before </head>. Same HTML to scrapers and real users — no UA sniffing. The SPA still bootstraps the same way (the <script type="module"> and <div id="app"> are untouched). Information-leak guard: private activities and not-opted-in public lists fall back to the SAME generic "not found" OG as truly missing URLs. Otherwise a scraper could distinguish "exists but hidden" from "doesn't exist," which the regular API correctly hides. Implementation notes: - Template read once on first request and cached. New deploys require a server restart to pick up a rebuilt index.html, which is the normal deploy flow anyway. - All meta attribute values pass through escapeAttr() so user content (activity titles, tag names, display names) can't break out of the attribute or inject HTML. - Description capped at 200 chars (what most scrapers actually render). - Base URL prefers PUBLIC_BASE_URL env var, falling back to request URL — works behind a reverse proxy if the env var is set. - PNG fallback for OG image is deliberately not shipped; the SVG works on every modern scraper that matters. iOS home-screen previews are the only place this falls back to the page screenshot, which is fine. Smoke-tested via curl against a production-mode server: all five routes render the right title, description, og:url, and image, and the "not found" + "hidden" cases collapse to the same OG shape. --- server/index.ts | 25 +++++++ server/og.ts | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 server/og.ts diff --git a/server/index.ts b/server/index.ts index f34203b..7f55be5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,10 @@ import { adminRoutes } from './admin'; import { settingsRoutes } from './settings'; import { invitesRoutes } from './invites'; import { friendsRoutes } from './friends'; +import { + renderWithOG, ogForHome, ogForPersonvern, + ogForActivity, ogForTag, ogForUserList, +} from './og'; // Initialise DB up front so the server fails fast on schema problems. getDb(); @@ -45,6 +49,27 @@ if (process.env.NODE_ENV === 'production') { for (const file of ['favicon.svg', 'icon.svg', 'manifest.webmanifest', 'sw.js']) { app.get(`/${file}`, serveStatic({ path: `./frontend/dist/${file}` })); } + + // OG-aware routes: render the SPA shell with route-specific OpenGraph + // meta injected, so shared links get rich previews. The SPA still + // bootstraps the same way — only the <head> changes per route. + // Order matters: specific paths first so /personvern doesn't get + // captured by /:username/list (10 chars matches the slug regex). + const html = (body: string) => + new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + app.get('/', (c) => html(renderWithOG(ogForHome(c.req.raw)))); + app.get('/personvern', (c) => html(renderWithOG(ogForPersonvern(c.req.raw)))); + app.get('/a/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id'))))); + app.get('/tags/:tag', (c) => { + let tag: string; + try { tag = decodeURIComponent(c.req.param('tag')); } + catch { tag = c.req.param('tag'); } + return html(renderWithOG(ogForTag(c.req.raw, tag))); + }); + app.get('/:username/list', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username'))))); + + // SPA fallback for anything else (login/signup/profile/admin/feedback views, + // /invite/<token>, etc. — none need route-specific OG). app.get('*', serveStatic({ path: './frontend/dist/index.html' })); } diff --git a/server/og.ts b/server/og.ts new file mode 100644 index 0000000..0f11f30 --- /dev/null +++ b/server/og.ts @@ -0,0 +1,177 @@ +/** + * OpenGraph meta injection for rich link previews. + * + * Most OG scrapers don't run JavaScript, so the bare SPA shell gives every + * shared link the same boring "Vinterliste" preview. For the routes where + * a rich preview matters (homepage, personvern, activity permalink, tag + * page, user public list) we render the SPA shell with route-specific OG + * tags injected just before `</head>`. + * + * No user-agent sniffing: real users and scrapers both get the same HTML. + * The SPA bootstraps the same way regardless of meta content. + * + * Information leak guard: private activities and not-opted-in public lists + * return the SAME generic "not found" meta as truly nonexistent URLs. + * Otherwise a scraper could distinguish "exists but hidden" from "doesn't + * exist," which the regular API correctly hides. + */ +import { readFileSync } from 'node:fs'; +import { getDb } from './db'; + +const TEMPLATE_PATH = './frontend/dist/index.html'; +let cachedTemplate: string | null = null; +let cacheReady = false; + +function template(): string { + if (cacheReady && cachedTemplate) return cachedTemplate; + try { + cachedTemplate = readFileSync(TEMPLATE_PATH, 'utf-8'); + cacheReady = true; + return cachedTemplate; + } catch { + // Dev mode (no frontend build) — return a minimal stub so the server + // doesn't crash. In dev the OG routes don't get registered anyway. + cacheReady = true; + cachedTemplate = '<!doctype html><html><head></head><body></body></html>'; + return cachedTemplate; + } +} + +function escapeAttr(s: string): string { + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const DESC_MAX = 200; // ~ what most scrapers actually render + +export interface OG { + title: string; + description: string; + url: string; + image: string; + type?: 'website' | 'article' | 'profile'; +} + +function metaBlock(og: OG): string { + const e = escapeAttr; + const desc = og.description.slice(0, DESC_MAX).trim(); + return [ + `<meta property="og:title" content="${e(og.title)}" />`, + `<meta property="og:description" content="${e(desc)}" />`, + `<meta property="og:type" content="${e(og.type ?? 'website')}" />`, + `<meta property="og:url" content="${e(og.url)}" />`, + `<meta property="og:image" content="${e(og.image)}" />`, + `<meta property="og:image:alt" content="Vinterliste" />`, + `<meta property="og:site_name" content="Vinterliste" />`, + `<meta name="twitter:card" content="summary" />`, + `<meta name="twitter:title" content="${e(og.title)}" />`, + `<meta name="twitter:description" content="${e(desc)}" />`, + `<meta name="twitter:image" content="${e(og.image)}" />`, + ].join('\n '); +} + +/** + * Render the SPA shell with OG meta injected. Also replaces <title> and the + * existing <meta name="description"> so old crawlers (Google etc) see the + * route-specific values too. + */ +export function renderWithOG(og: OG): string { + const block = metaBlock(og); + const e = escapeAttr; + const desc = og.description.slice(0, DESC_MAX).trim(); + return template() + .replace(/<title>[^<]*<\/title>/, `<title>${e(og.title)}`) + .replace( + //, + ``, + ) + .replace('', ` ${block}\n `); +} + +function baseUrl(req: Request): string { + if (process.env.PUBLIC_BASE_URL) return process.env.PUBLIC_BASE_URL.replace(/\/$/, ''); + const u = new URL(req.url); + return `${u.protocol}//${u.host}`; +} + +// --- Per-route resolvers -------------------------------------------------- + +const NOT_FOUND_OG = (req: Request, path: string): OG => ({ + title: 'Vinterliste', + description: 'Lag og del lister over ting å gjøre om vinteren — eller hva som helst annet.', + url: `${baseUrl(req)}${path}`, + image: `${baseUrl(req)}/icon.svg`, +}); + +export function ogForHome(req: Request): OG { + return { + title: 'Vinterliste', + description: 'Lag og del lister over ting å gjøre om vinteren — eller hva som helst annet. Privat, anonymt, for venner, eller åpent.', + url: `${baseUrl(req)}/`, + image: `${baseUrl(req)}/icon.svg`, + }; +} + +export function ogForPersonvern(req: Request): OG { + return { + title: 'Personvern og hvordan det virker · Vinterliste', + description: 'Vinterliste lar deg lage og dele vinteraktivitetslister. Innhold merket privat krypteres i nettleseren din før det forlater enheten.', + url: `${baseUrl(req)}/personvern`, + image: `${baseUrl(req)}/icon.svg`, + }; +} + +export function ogForActivity(req: Request, id: string): OG { + const row = getDb() + .prepare(`SELECT visibility, title, description FROM activities WHERE id = ?`) + .get(id) as { visibility: string; title: string | null; description: string | null } | null; + + // Private and friends-only behave like nonexistent rows for the scraper: + // we don't reveal that the id resolves to a hidden activity. + if (!row || row.visibility === 'private' || row.visibility === 'friends') { + return NOT_FOUND_OG(req, `/a/${id}`); + } + return { + title: `${row.title ?? 'Vinteraktivitet'} · Vinterliste`, + description: row.description ?? 'Vinteraktivitet delt på Vinterliste.', + type: 'article', + url: `${baseUrl(req)}/a/${id}`, + image: `${baseUrl(req)}/icon.svg`, + }; +} + +export function ogForTag(req: Request, tag: string): OG { + const cleaned = tag.trim().toLowerCase(); + return { + title: `Etikett: ${cleaned} · Vinterliste`, + description: `Aktiviteter merket med «${cleaned}» på Vinterliste.`, + url: `${baseUrl(req)}/tags/${encodeURIComponent(cleaned)}`, + image: `${baseUrl(req)}/icon.svg`, + }; +} + +export function ogForUserList(req: Request, username: string): OG { + const row = getDb() + .prepare(`SELECT display_name, public_list_enabled FROM users WHERE username = ?`) + .get(username.toLowerCase()) as + | { display_name: string | null; public_list_enabled: number | null } + | null; + + // Same "not found" treatment for not-opted-in users as for truly missing + // usernames — matches what GET /api/users/:username/list returns. + if (!row || row.public_list_enabled !== 1) { + return NOT_FOUND_OG(req, `/${username}/list`); + } + const name = row.display_name?.trim() || username; + return { + title: `${name} · Vinterliste`, + description: `Offentlige vinteraktiviteter fra ${name}.`, + type: 'profile', + url: `${baseUrl(req)}/${username}/list`, + image: `${baseUrl(req)}/icon.svg`, + }; +}