// Link-in-bio page per client: a fast public page (/l/) with the client's links and latest posts. // Every link goes through /l//go/, which counts the click (per day) and adds UTM tags when enabled. import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { DATA_DIR } from "./config.js"; import * as clients from "./clients.js"; import * as metrics from "./metrics.js"; const FILE = path.join(DATA_DIR, "bio.json"); const CLICKS = path.join(DATA_DIR, "bio-clicks.json"); const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } }; const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, f); }; const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); const RESERVED = new Set(["api", "go", "admin", "login", "quorla", "static", "assets"]); const HEX = /^#[0-9a-f]{6}$/i; export function get(slug) { clients.safeSlug(slug); const c = readJson(FILE, {})[slug]; return { enabled: false, path: slug, title: "", bio: "", links: [], showPosts: true, utm: true, theme: { bg: "#ffffff", fg: "#1f2024", button: "#242142", buttonText: "#ffffff" }, ...(c || {}) }; } export function save(slug, patch) { clients.safeSlug(slug); const db = readJson(FILE, {}); const cur = get(slug); const p = patch.path !== undefined ? String(patch.path).toLowerCase().trim() : cur.path; if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(p) || RESERVED.has(p)) throw err("Page address: 2–40 lowercase letters, numbers or dashes."); if (Object.entries(db).some(([s, v]) => s !== slug && v.path === p)) throw err("Another client already uses that address."); const links = (patch.links ?? cur.links).slice(0, 20).map((l) => { const url = String(l.url || "").trim(); if (!/^(https?:\/\/|mailto:|tel:)/i.test(url)) throw err(`“${l.title || url}”: links must start with https://, mailto: or tel:.`); return { id: l.id || crypto.randomUUID().slice(0, 6), title: String(l.title || "").trim().slice(0, 80) || url, url: url.slice(0, 1000) }; }); const theme = { ...cur.theme }; for (const k of ["bg", "fg", "button", "buttonText"]) if (patch.theme?.[k] !== undefined) { if (!HEX.test(patch.theme[k])) throw err("Colours must be like #242142."); theme[k] = patch.theme[k]; } db[slug] = { enabled: patch.enabled !== undefined ? Boolean(patch.enabled) : cur.enabled, path: p, title: patch.title !== undefined ? String(patch.title).slice(0, 80) : cur.title, bio: patch.bio !== undefined ? String(patch.bio).slice(0, 300) : cur.bio, links, showPosts: patch.showPosts !== undefined ? Boolean(patch.showPosts) : cur.showPosts, utm: patch.utm !== undefined ? Boolean(patch.utm) : cur.utm, theme, }; writeJson(FILE, db); return get(slug); } export function byPath(p) { const hit = Object.entries(readJson(FILE, {})).find(([, v]) => v.path === String(p || "").toLowerCase() && v.enabled); return hit ? { slug: hit[0], ...get(hit[0]) } : null; } function withUtm(url, slug) { if (!/^https?:/i.test(url)) return url; try { const u = new URL(url); if (!u.searchParams.has("utm_source")) { u.searchParams.set("utm_source", "instagram"); u.searchParams.set("utm_medium", "social"); u.searchParams.set("utm_campaign", "link_in_bio"); u.searchParams.set("utm_content", slug); } return u.toString(); } catch { return url; } } /** Where a click should go (counted). Null if unknown. */ export function click(p, linkId) { const page = byPath(p); const link = page?.links.find((l) => l.id === linkId); if (!link) return null; const db = readJson(CLICKS, {}); const day = new Date().toISOString().slice(0, 10); const c = ((db[page.slug] ||= {})[linkId] ||= {}); c[day] = (c[day] || 0) + 1; // Keep 120 days. const cutoff = new Date(Date.now() - 120 * 86400000).toISOString().slice(0, 10); for (const k of Object.keys(c)) if (k < cutoff) delete c[k]; writeJson(CLICKS, db); return page.utm ? withUtm(link.url, page.slug) : link.url; } /** Clicks per link over the last N days. */ export function stats(slug, days = 30) { const db = readJson(CLICKS, {})[slug] || {}; const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); const page = get(slug); const per = page.links.map((l) => ({ id: l.id, title: l.title, clicks: Object.entries(db[l.id] || {}).filter(([d]) => d >= cutoff).reduce((s, [, n]) => s + n, 0) })); return { days, total: per.reduce((s, x) => s + x.clicks, 0), links: per }; } const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); /** The public page as HTML (no JS needed). */ export function render(page) { const snap = metrics.readSnapshot(page.slug); const prof = snap?.ig?.profile || {}; const name = page.title || prof.name || clients.getClient(page.slug)?.name || ""; const bio = page.bio || prof.biography || ""; const avatar = prof.profile_picture_url || null; const posts = page.showPosts ? (snap?.ig?.posts || []).filter((p) => p.permalink && (p.thumbnail_url || p.media_url)).slice(0, 6) : []; const t = page.theme; return ` ${esc(name)} ${avatar ? `` : ""}
${avatar ? `` : ""}

${esc(name)}

${bio ? `

${esc(bio)}

` : ""} ${page.links.map((l) => `${esc(l.title)}`).join("\n")} ${posts.length ? `
${posts.map((p) => ``).join("")}
` : ""}
`; }