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 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/, 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 ``. + * + * 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 = ''; + 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 [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join('\n '); +} + +/** + * Render the SPA shell with OG meta injected. Also replaces 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`, + }; +}