OpenGraph meta on the SPA's shareable routes

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
  /<username>/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 <title>
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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:05:43 +02:00
commit 7964d499e2
2 changed files with 202 additions and 0 deletions

View file

@ -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' }));
}

177
server/og.ts Normal file
View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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)}</title>`)
.replace(
/<meta name="description" content="[^"]*" \/>/,
`<meta name="description" content="${e(desc)}" />`,
)
.replace('</head>', ` ${block}\n </head>`);
}
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`,
};
}