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:
parent
93b87dec0e
commit
7964d499e2
2 changed files with 202 additions and 0 deletions
|
|
@ -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
177
server/og.ts
Normal 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('&', '&')
|
||||
.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)}</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`,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue