source: Klonkt/src/routes/linkbio.js@ 3f4049e

main
Last change on this file since 3f4049e was 37edecd, checked in by roboburr <roboburr@…>, 3 months ago

Link-in-bio + click stats (premium feature #6)

Linktree-style page from the existing sites.profile_links (JSON [{platform,url}])
+ PLATFORMS icons. Clicks counted per url.

  • DB: link_clicks (site_id, url, clicks), PK (site_id,url).
  • routes/linkbio.js (premium-gated): GET /links (page with profile_links as buttons), GET /links/go/:i (counts click per url + 302 redirect). Open-redirect safe: only redirects to a url in the site's own profile_links; http(s)/mailto only.
  • view pages/linkbio.ejs (photo/name/bio + brand-coloured buttons with SVG icons).
  • admin-stats: link-clicks section (top 50, per url) for the current site.
  • admin dashboard: 🔗 Link-in-bio link (premium, non-hub).

node --check passed.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 2.4 KB
Line 
1/**
2 * Link-in-bio + klikstats (premium feature #6).
3 *
4 * GET /links -> Linktree-achtige pagina met de profile_links van de site
5 * GET /links/go/:i -> telt de klik (per url) en stuurt door naar de externe URL
6 *
7 * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de
8 * PLATFORMS-iconen/labels. Klikken landen in link_clicks (zie /admin/stats).
9 * Open-redirect-veilig: /links/go/:i stuurt ALLEEN door naar een url die in de
10 * eigen profile_links staat. Hub: via /user/:slug/links.
11 */
12
13import express from 'express';
14import db from '../config/database.js';
15import { renderPage } from '../middleware/render.js';
16import { premiumUnlocked } from '../services/PatreonService.js';
17import { PLATFORMS } from '../services/PlatformIcons.js';
18
19const router = express.Router();
20
21function parseLinks(site) {
22 if (!site || !site.profile_links) return [];
23 try { return JSON.parse(site.profile_links) || []; } catch { return []; }
24}
25
26router.get('/links', (req, res, next) => {
27 if (!premiumUnlocked()) return next();
28 const site = res.locals.site;
29 if (!site) return next();
30 const links = parseLinks(site).map((l, i) => {
31 const meta = PLATFORMS[l.platform] || {};
32 return { i, url: l.url, platform: l.platform, label: meta.label || l.platform, svg: meta.svg || '', brand: meta.brand || '' };
33 });
34 renderPage(req, res, 'pages/linkbio', {
35 pageTitle: (site.title || '') + ' — links',
36 bodyClass: 'on-linkbio',
37 lbLinks: links,
38 });
39});
40
41router.get('/links/go/:i', (req, res, next) => {
42 if (!premiumUnlocked()) return next();
43 const site = res.locals.site;
44 if (!site) return next();
45 const links = parseLinks(site);
46 const idx = parseInt(req.params.i, 10);
47 const link = (Number.isInteger(idx) && idx >= 0) ? links[idx] : null;
48 if (!link || !link.url) return next();
49 const url = String(link.url);
50 // Alleen externe http(s)- of mailto-links (geen open redirect / javascript:).
51 if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
52 try {
53 db.prepare(
54 `INSERT INTO link_clicks (site_id, url, clicks, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP)
55 ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
56 ).run(site.id, url);
57 } catch { /* telling mag de redirect nooit breken */ }
58 res.redirect(302, url);
59});
60
61export default router;
Note: See TracBrowser for help on using the repository browser.