source: Klonkt/src/routes/admin-circle.js@ 029f047

main
Last change on this file since 029f047 was 0091cb7, checked in by roboburr <roboburr@…>, 3 months ago

Circles v1 — steps 4+5: Admin UI + /cirkel feed

  • Mode 'Circles' in Admin > Settings (radio + section).
  • Admin > Circle (admin-circle): add sources/list/status, per source Refresh (syncOne) + Delete (cleans up orphaned cache), and a visibility toggle (sites.allow_circle, opt-out for surfacing).
  • /cirkel feed: cached remote_posts as static cards (source/avatar, title, summary, cover, link to source). Only when tenancy=circle.
  • allow_circle column (ensureColumn) + CircleFederation respects it.
  • Output escaped everywhere + URL-scheme guards (http/https) against XSS import.

v1 functionally complete (publish + pull + UI + feed). Remaining: hardening/review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 4.2 KB
Line 
1/**
2 * Admin: Cirkel-beheer (god-only).
3 * GET /admin/circle -> lijst van cirkel-links + status
4 * POST /admin/circle/add -> Klonkt-URL toevoegen
5 * POST /admin/circle/:id/remove
6 * POST /admin/circle/:id/sync -> nu verversen (pull + verifieer)
7 * POST /admin/circle/allow -> toggle "mag in cirkels van anderen verschijnen"
8 *
9 * Zie docs/cirkels-v1-spec.md §5d.
10 */
11
12import express from 'express';
13import crypto from 'crypto';
14import { renderPage } from '../middleware/render.js';
15import { requireGod } from '../middleware/auth.js';
16import db from '../config/database.js';
17import { getTenancy } from '../services/SettingsService.js';
18import { syncOne } from '../services/CircleService.js';
19
20const router = express.Router();
21
22function primarySite() {
23 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
24}
25
26router.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
50router.post('/add', requireGod, (req, res) => {
51 const site = primarySite();
52 if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
53 const url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
54 if (!/^https:\/\/[^\s/]+(\/[^\s]*)?$/i.test(url)) {
55 return res.redirect('/admin/circle?error=' + encodeURIComponent('Voer een geldige https-URL in'));
56 }
57 const label = (req.body.label || '').toString().slice(0, 80).trim() || null;
58 try {
59 db.prepare("INSERT INTO circle_links (id, local_site_id, remote_url, label, status) VALUES (?, ?, ?, ?, 'active')")
60 .run(crypto.randomUUID(), site.id, url, label);
61 } catch (e) {
62 return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
63 }
64 res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — klik "Verversen" om op te halen'));
65});
66
67router.post('/:id/remove', requireGod, (req, res) => {
68 const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
69 if (link) {
70 db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
71 // Gecachte content opruimen als geen andere link nog naar deze actor wijst.
72 if (link.remote_actor_id) {
73 const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
74 if (!other) {
75 db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id);
76 db.prepare('DELETE FROM remote_actors WHERE id = ?').run(link.remote_actor_id);
77 }
78 }
79 }
80 res.redirect('/admin/circle?success=' + encodeURIComponent('Verwijderd'));
81});
82
83router.post('/:id/sync', requireGod, async (req, res) => {
84 const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(req.params.id);
85 if (!link) return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet gevonden'));
86 try {
87 const r = await syncOne(link);
88 res.redirect('/admin/circle?success=' + encodeURIComponent(`Bijgewerkt — ${r.items} posts opgehaald`));
89 } catch (e) {
90 const msg = String((e && e.message) || e).slice(0, 300);
91 db.prepare("UPDATE circle_links SET status='error', last_error=?, last_synced=CURRENT_TIMESTAMP WHERE id=?")
92 .run(msg, link.id);
93 res.redirect('/admin/circle?error=' + encodeURIComponent(msg));
94 }
95});
96
97router.post('/allow', requireGod, (req, res) => {
98 const site = primarySite();
99 if (site) {
100 const v = (req.body.allow_circle === 'on' || req.body.allow_circle === '1') ? 1 : 0;
101 db.prepare('UPDATE sites SET allow_circle = ? WHERE id = ?').run(v, site.id);
102 }
103 res.redirect('/admin/circle?success=' + encodeURIComponent('Opgeslagen'));
104});
105
106export default router;
Note: See TracBrowser for help on using the repository browser.