| [0091cb7] | 1 | /**
|
|---|
| [834bcc3] | 2 | * Admin: Circle management (god-only).
|
|---|
| 3 | * GET /admin/circle -> list of circle links + status
|
|---|
| 4 | * POST /admin/circle/add -> add a Klonkt URL
|
|---|
| [0091cb7] | 5 | * POST /admin/circle/:id/remove
|
|---|
| [834bcc3] | 6 | * POST /admin/circle/:id/sync -> refresh now (pull + verify)
|
|---|
| 7 | * POST /admin/circle/allow -> toggle "may appear in others' circles"
|
|---|
| [0091cb7] | 8 | *
|
|---|
| [834bcc3] | 9 | * See docs/cirkels-v1-spec.md §5d.
|
|---|
| [0091cb7] | 10 | */
|
|---|
| 11 |
|
|---|
| 12 | import express from 'express';
|
|---|
| 13 | import crypto from 'crypto';
|
|---|
| 14 | import { renderPage } from '../middleware/render.js';
|
|---|
| 15 | import { requireGod } from '../middleware/auth.js';
|
|---|
| 16 | import db from '../config/database.js';
|
|---|
| 17 | import { getTenancy } from '../services/SettingsService.js';
|
|---|
| [62b899d] | 18 | import { syncOne, sync } from '../services/CircleService.js';
|
|---|
| [0091cb7] | 19 |
|
|---|
| 20 | const router = express.Router();
|
|---|
| 21 |
|
|---|
| 22 | function primarySite() {
|
|---|
| 23 | return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | router.get('/', requireGod, (req, res) => {
|
|---|
| 27 | const site = primarySite();
|
|---|
| 28 | const links = site
|
|---|
| 29 | ? db.prepare('SELECT * FROM circle_links WHERE local_site_id = ? ORDER BY added_at DESC').all(site.id)
|
|---|
| 30 | : [];
|
|---|
| 31 | const counts = {};
|
|---|
| 32 | for (const l of links) {
|
|---|
| 33 | counts[l.id] = l.remote_actor_id
|
|---|
| 34 | ? db.prepare('SELECT COUNT(*) AS n FROM remote_posts WHERE actor_id = ?').get(l.remote_actor_id).n
|
|---|
| 35 | : 0;
|
|---|
| 36 | }
|
|---|
| 37 | renderPage(req, res, 'pages/admin-circle', {
|
|---|
| 38 | pageTitle: 'Cirkel',
|
|---|
| 39 | bodyClass: 'on-admin',
|
|---|
| 40 | tenancy: getTenancy(),
|
|---|
| 41 | site,
|
|---|
| 42 | links,
|
|---|
| 43 | counts,
|
|---|
| 44 | allowCircle: site ? site.allow_circle !== 0 : true,
|
|---|
| 45 | success: req.query.success || null,
|
|---|
| 46 | error: req.query.error || null,
|
|---|
| 47 | });
|
|---|
| 48 | });
|
|---|
| 49 |
|
|---|
| [dfc5379] | 50 | router.post('/add', requireGod, async (req, res) => {
|
|---|
| [0091cb7] | 51 | const site = primarySite();
|
|---|
| 52 | if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
|
|---|
| [834bcc3] | 53 | // Auto-complete the scheme: a bare domain name → https://, a typed
|
|---|
| 54 | // http:// → https:// (federation is intentionally https-only, signed feeds). So the
|
|---|
| 55 | // user never has to type http(s):// themselves.
|
|---|
| [2520dcc] | 56 | let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
|
|---|
| 57 | if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url;
|
|---|
| 58 | url = url.replace(/^http:\/\//i, 'https://');
|
|---|
| [0091cb7] | 59 | if (!/^https:\/\/[^\s/]+(\/[^\s]*)?$/i.test(url)) {
|
|---|
| [2520dcc] | 60 | return res.redirect('/admin/circle?error=' + encodeURIComponent('Voer een geldig site-adres in (bv. voorbeeld.nl)'));
|
|---|
| [0091cb7] | 61 | }
|
|---|
| 62 | const label = (req.body.label || '').toString().slice(0, 80).trim() || null;
|
|---|
| [dfc5379] | 63 | const id = crypto.randomUUID();
|
|---|
| [0091cb7] | 64 | try {
|
|---|
| 65 | db.prepare("INSERT INTO circle_links (id, local_site_id, remote_url, label, status) VALUES (?, ?, ?, ?, 'active')")
|
|---|
| [dfc5379] | 66 | .run(id, site.id, url, label);
|
|---|
| [0091cb7] | 67 | } catch (e) {
|
|---|
| 68 | return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
|
|---|
| 69 | }
|
|---|
| [834bcc3] | 70 | // Fetch immediately instead of waiting for the 15-minute loop.
|
|---|
| [dfc5379] | 71 | try {
|
|---|
| 72 | const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id);
|
|---|
| 73 | await syncOne(link);
|
|---|
| 74 | return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd en gesynchroniseerd ✓'));
|
|---|
| 75 | } catch (e) {
|
|---|
| [2fc9b7f] | 76 | const msg = String((e && e.message) || e);
|
|---|
| [834bcc3] | 77 | // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json
|
|---|
| 78 | // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert
|
|---|
| 79 | // so no dead "error" row is left in the circle.
|
|---|
| [2fc9b7f] | 80 | if (/\b404\b/.test(msg)) {
|
|---|
| 81 | db.prepare('DELETE FROM circle_links WHERE id = ?').run(id);
|
|---|
| 82 | return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet toegevoegd: deze site doet niet mee aan cirkels. Een hub kan geen cirkel-partner zijn (en losse/niet-Klonkt-sites ook niet).'));
|
|---|
| 83 | }
|
|---|
| [834bcc3] | 84 | // Other (possibly temporary) error → link stays; use "Refresh" later to retry.
|
|---|
| [dfc5379] | 85 | return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)'));
|
|---|
| 86 | }
|
|---|
| [0091cb7] | 87 | });
|
|---|
| 88 |
|
|---|
| 89 | router.post('/:id/remove', requireGod, (req, res) => {
|
|---|
| 90 | const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
|
|---|
| 91 | if (link) {
|
|---|
| 92 | db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
|
|---|
| [834bcc3] | 93 | // Clean up cached content if no other link still points to this actor.
|
|---|
| [0091cb7] | 94 | if (link.remote_actor_id) {
|
|---|
| 95 | const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
|
|---|
| 96 | if (!other) {
|
|---|
| 97 | db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id);
|
|---|
| 98 | db.prepare('DELETE FROM remote_actors WHERE id = ?').run(link.remote_actor_id);
|
|---|
| 99 | }
|
|---|
| 100 | }
|
|---|
| 101 | }
|
|---|
| 102 | res.redirect('/admin/circle?success=' + encodeURIComponent('Verwijderd'));
|
|---|
| 103 | });
|
|---|
| 104 |
|
|---|
| 105 | router.post('/:id/sync', requireGod, async (req, res) => {
|
|---|
| 106 | const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
|
|---|
| 107 | if (!link) return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet gevonden'));
|
|---|
| 108 | try {
|
|---|
| 109 | const r = await syncOne(link);
|
|---|
| 110 | res.redirect('/admin/circle?success=' + encodeURIComponent(`Bijgewerkt — ${r.items} posts opgehaald`));
|
|---|
| 111 | } catch (e) {
|
|---|
| 112 | const msg = String((e && e.message) || e).slice(0, 300);
|
|---|
| 113 | db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
|
|---|
| 114 | .run(msg, link.id);
|
|---|
| 115 | res.redirect('/admin/circle?error=' + encodeURIComponent(msg));
|
|---|
| 116 | }
|
|---|
| 117 | });
|
|---|
| 118 |
|
|---|
| [834bcc3] | 119 | // Refresh everything at once (handy "just to be sure").
|
|---|
| [62b899d] | 120 | router.post('/sync-all', requireGod, async (req, res) => {
|
|---|
| 121 | try {
|
|---|
| 122 | const r = await sync();
|
|---|
| 123 | const n = (r && r.results) ? r.results.filter((x) => x && x.ok).length : 0;
|
|---|
| 124 | res.redirect('/admin/circle?success=' + encodeURIComponent(`Cirkel gesynchroniseerd (${n} site(s) bijgewerkt)`));
|
|---|
| 125 | } catch (e) {
|
|---|
| 126 | res.redirect('/admin/circle?error=' + encodeURIComponent(String((e && e.message) || e).slice(0, 200)));
|
|---|
| 127 | }
|
|---|
| 128 | });
|
|---|
| 129 |
|
|---|
| [0091cb7] | 130 | router.post('/allow', requireGod, (req, res) => {
|
|---|
| 131 | const site = primarySite();
|
|---|
| 132 | if (site) {
|
|---|
| 133 | const v = (req.body.allow_circle === 'on' || req.body.allow_circle === '1') ? 1 : 0;
|
|---|
| 134 | db.prepare('UPDATE sites SET allow_circle = ? WHERE id = ?').run(v, site.id);
|
|---|
| 135 | }
|
|---|
| 136 | res.redirect('/admin/circle?success=' + encodeURIComponent('Opgeslagen'));
|
|---|
| 137 | });
|
|---|
| 138 |
|
|---|
| 139 | export default router;
|
|---|