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 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>/, `${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`,
+ };
+}