| 1 | /**
|
|---|
| 2 | * Link-in-bio + click stats (premium feature #6).
|
|---|
| 3 | *
|
|---|
| 4 | * GET /links -> Linktree-style page with the site's profile_links
|
|---|
| 5 | * GET /links/go/:i -> counts the click (per url) and redirects to the external URL
|
|---|
| 6 | *
|
|---|
| 7 | * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the
|
|---|
| 8 | * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats).
|
|---|
| 9 | * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the
|
|---|
| 10 | * site's own profile_links. Hub: via /user/:slug/links.
|
|---|
| 11 | */
|
|---|
| 12 |
|
|---|
| 13 | import express from 'express';
|
|---|
| 14 | import db from '../config/database.js';
|
|---|
| 15 | import { renderPage } from '../middleware/render.js';
|
|---|
| 16 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| 17 | import { PLATFORMS } from '../services/PlatformIcons.js';
|
|---|
| 18 |
|
|---|
| 19 | const router = express.Router();
|
|---|
| 20 |
|
|---|
| 21 | function parseLinks(site) {
|
|---|
| 22 | if (!site || !site.profile_links) return [];
|
|---|
| 23 | try { return JSON.parse(site.profile_links) || []; } catch { return []; }
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | router.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 |
|
|---|
| 41 | router.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 | // Only external http(s) or mailto links (no 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 { /* counting must never break the redirect */ }
|
|---|
| 58 | res.redirect(302, url);
|
|---|
| 59 | });
|
|---|
| 60 |
|
|---|
| 61 | export default router;
|
|---|