source: Klonkt/src/routes/admin-sites.js@ ccaa530

main
Last change on this file since ccaa530 was ccaa530, checked in by Robin <roboburr@…>, 6 weeks ago

Account-verhuizingen: inkomende Move plus alsoKnownAs-aliassen (FEP-7628)

De ontvangende helft van accountverhuizingen, in twee delen die samen een
verhuizing NAAR Klonkt mogelijk maken (shaer-0j2, slice 1).

Deel 1: een account dat onze sites volgen verhuist. Op een geldige Move volgt
elke lokale volger automatisch mee naar het nieuwe adres, met behoud van de
eigen auto-boost-keuze. Geldigheid heeft twee onafhankelijke benen en beide
zijn verplicht: de ondertekenaar is zelf partij in de verhuizing (oude actor
in push-modus of nieuwe actor in pull-modus), en de nieuwe actor claimt de
oude identiteit in alsoKnownAs. Zonder het eerste kan elke ondertekende
vreemde onze follows ompointen; zonder het tweede kan wie een kant beheerst
de volgers van de andere kant kapen. Herbezorging is idempotent, een
geblokkeerde bestemming wordt geweigerd, en guardianship-relaties worden
bewust NIET geretarget: een guardian is een beveiligingsanker en verhuist
pas onder de regels van shaer-tge.

Deel 2: eigen aliassen instellen via Beheer, Sites, site bewerken. Een regel
per oud account, als @naam@server of als actor-URL. Handles resolven via
WebFinger op het moment van opslaan, zodat een typfout direct zichtbaar is
in plaats van pas bij een mislukkende Move op de oude server. De aliassen
verschijnen als alsoKnownAs op de publieke actor; dat is de claim die de
oude server controleert voor hij volgers hierheen verhuist.

Let op: FEP-7628 heeft status DRAFT. De vorm is sinds 2019 de facto
Mastodon-standaard, maar de spec kan nog wijzigen.

Changed files:
src/services/ActivityPubService.js

  • Move toegevoegd aan de GATED-lijst (handtekening verplicht)
  • dispatch-blok voor Move, voor de Follow-afhandeling (geen race met de re-Follow-golf van de nieuwe actor)
  • handleMoveInbox met injecteerbare afhankelijkheden voor de tests
  • alsoKnownAs en movedTo als JSON-LD-termen in de context
  • buildActor publiceert alsoKnownAs uit sites.ap_aliases, eigen id en niet-URLs gefilterd

src/config/database.js

  • kolom sites.ap_aliases (JSON-array van actor-URIs)

src/routes/admin-sites.js

  • parseApAliases: max 5, dedupe, WebFinger-resolutie bij opslaan, fout met de betreffende regel terug naar het formulier
  • save-route asynchroon; ap_aliases in de UPDATE
  • actor-Update naar volgers wanneer de lijst wijzigt (best-effort)

src/views/pages/admin-site-edit.ejs

  • veld Fediverse-aliassen onder de profiel-links

src/services/i18n.js

  • asite.aliases en asite.aliases_hint in nl, en en de

New file:
test/move-actor.test.js

  • 11 tests: derde-partij-weigering, ontbrekend aliasbewijs, push- en pull-modus, idempotentie, al-volgend overslaan, geblokkeerd doel, misvormde activiteiten, ongesigneerd, actor-publicatie met filtering

-robo
Co-Authored-By: Claude Fable 5 <noreply@…>

  • Property mode set to 100644
File size: 15.9 KB
Line 
1/**
2 * Admin: Site management — Phase E.
3 *
4 * GET /admin/sites -> list all sites
5 * GET /admin/sites/new -> create form
6 * POST /admin/sites/create -> insert + redirect to edit
7 * GET /admin/sites/:slug/edit -> edit form
8 * POST /admin/sites/:slug/save -> update
9 * POST /admin/sites/:slug/delete-> delete (refuses if site has posts)
10 *
11 * God-only (requireGod middleware). Slug is immutable after create — too
12 * many things hang off it (URLs, manifest scope, federation). If you really
13 * need to rename: delete + re-create.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { v4 as uuid } from 'uuid';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
26import { toWebp } from '../services/ImageWebpService.js';
27import { mediaDir } from '../config/paths.js';
28import AP from '../services/ActivityPubService.js';
29
30
31// Profile photos share the avatar directory with user avatars — same physical
32// folder, same URL prefix. Filenames are uuid-prefixed so site photos and
33// user avatars never collide.
34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
35fs.mkdirSync(PHOTO_DIR, { recursive: true });
36
37const ALLOWED_PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
38const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
39const photoUpload = multer({
40 storage: multer.diskStorage({
41 destination: (req, file, cb) => cb(null, PHOTO_DIR),
42 filename: (req, file, cb) => {
43 const ext = path.extname(file.originalname || '').toLowerCase() || '.jpg';
44 cb(null, `site-${uuid()}${ext}`);
45 },
46 }),
47 limits: { fileSize: MAX_PHOTO_BYTES },
48 fileFilter: (req, file, cb) => {
49 const ext = path.extname(file.originalname || '').toLowerCase();
50 if (!ALLOWED_PHOTO_EXT.has(ext)) {
51 return cb(new Error('Alleen JPG/PNG/WebP/GIF toegestaan'));
52 }
53 cb(null, true);
54 },
55});
56
57/** Coerce req.body fields into the JSON profile_links array. */
58function buildProfileLinks(body) {
59 const platforms = body.profile_link_platform || [];
60 const urls = body.profile_link_url || [];
61 const arr = [];
62 const platformsArr = Array.isArray(platforms) ? platforms : [platforms];
63 const urlsArr = Array.isArray(urls) ? urls : [urls];
64 for (let i = 0; i < platformsArr.length; i++) {
65 const p = (platformsArr[i] || '').toString().trim();
66 const u = (urlsArr[i] || '').toString().trim();
67 if (!p || !u) continue;
68 if (!PLATFORMS[p]) continue;
69 if (!/^https?:\/\//i.test(u) && p !== 'email') continue;
70 if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;
71 arr.push({ platform: p, url: u });
72 }
73 return arr.length ? JSON.stringify(arr) : null;
74}
75
76/**
77 * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
78 * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
79 * purpose — a typo'd alias that silently lands on the actor would make a later
80 * Move fail at the old server with no hint why. Throws the offending line.
81 */
82async function parseApAliases(raw, ownActorUri) {
83 const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
84 if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
85 const out = [];
86 for (const line of lines) {
87 let uri = null;
88 if (/^https?:\/\//i.test(line)) uri = line;
89 else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
90 if (!uri) throw new Error(line);
91 if (uri === ownActorUri) continue; // claiming yourself adds nothing
92 if (!out.includes(uri)) out.push(uri);
93 }
94 return out;
95}
96
97const router = express.Router();
98
99// ==================== UPLOAD PROFILE PHOTO (JSON) ====================
100// POST /admin/sites/upload-photo → { ok: true, url: '/media/avatars/<filename>' }
101// Used by the admin-site-edit form's photo picker. The form itself still
102// holds the URL string in `profile_photo` — this endpoint just stores the
103// file and hands back a URL that the form can paste into the input field.
104router.post('/upload-photo', requireAuth, (req, res) => {
105 photoUpload.single('photo')(req, res, (err) => {
106 if (err) return res.status(400).json({ ok: false, error: err.message });
107 if (!req.file) return res.status(400).json({ ok: false, error: 'Geen bestand ontvangen' });
108 res.json({
109 ok: true,
110 url: `/media/avatars/${toWebp(req.file)}`,
111 size: req.file.size,
112 mime: req.file.mimetype,
113 });
114 });
115});
116
117const RESERVED_SITE_SLUGS = new Set([
118 'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
119 'account', 'sites', 'comments', 'posts', 'media', 'audio',
120 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
121 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
122 'paid', 'push', 'guardian',
123]);
124
125function siteEditableFields() {
126 return {
127 title: '',
128 description: '',
129 tagline: '',
130 language: 'nl',
131 palette: 'klonkt',
132 accent: '#e8b04b',
133 profile_photo: '',
134 profile_enabled: 1,
135 profile_name: '',
136 profile_bio: '',
137 is_public: 1,
138 robots_index: 1,
139 require_login_to_comment: 1,
140 enable_audio_player: 1,
141 comments_moderation_mode: 'moderate',
142 feed_view_default: 'grid',
143 feed_view_switch: 1,
144 show_search: 1,
145 show_archive_link: 1,
146 title_template: '{title} — {site}',
147 twitter: '',
148 canonical: '',
149 google_verification: '',
150 bing_verification: '',
151 pinterest_verification: '',
152 yandex_verification: '',
153 custom_css: '',
154 custom_head_html: '',
155 custom_foot_html: '',
156 };
157}
158
159/** Valid user-id for owner assignment, or null if empty/unknown. */
160function validOwnerId(raw) {
161 const id = (raw || '').toString().trim();
162 if (!id) return null;
163 return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
164}
165
166/** Grant a user admin rights on a site (idempotent upsert). */
167function grantSiteAdmin(siteId, userId) {
168 db.prepare(`
169 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
170 ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
171 `).run(siteId, userId);
172}
173
174/** Candidate owners for the owner selector field (god-only). */
175function listOwnerCandidates() {
176 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
177}
178
179// ==================== LIST ====================
180router.get('/', requireGod, (req, res) => {
181 const sites = db.prepare(`
182 SELECT s.id, s.slug, s.title, s.description, s.created_at,
183 s.is_public, s.robots_index, s.is_primary,
184 u.username AS owner_username,
185 (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
186 FROM sites s LEFT JOIN users u ON u.id = s.owner_id
187 ORDER BY s.is_primary DESC, s.created_at DESC
188 `).all();
189
190 renderPage(req, res, 'pages/admin-sites', {
191 pageTitleKey: 'admin.t_sites',
192 bodyClass: 'on-admin',
193 sites,
194 success: req.query.success || null,
195 error: req.query.error || null,
196 });
197});
198
199// ==================== NEW (form) ====================
200router.get('/new', requireGod, (req, res) => {
201 renderPage(req, res, 'pages/admin-site-edit', {
202 pageTitleKey: 'admin.t_newsite',
203 bodyClass: 'on-admin',
204 isNew: true,
205 // ?owner=<id> (from the users page: "give this user a Klonkt") is
206 // pre-selected; otherwise defaults to the creating god.
207 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
208 users: listOwnerCandidates(),
209 palettes: ThemeService.listPalettes(),
210 accents: ThemeService.listAccents(),
211 platforms: listPlatforms(),
212 parsedLinks: [],
213 error: null,
214 });
215});
216
217// ==================== CREATE ====================
218router.post('/create', requireGod, (req, res) => {
219 const slug = (req.body.slug || '').toString().toLowerCase().trim();
220 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
221 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
222 }
223 if (RESERVED_SITE_SLUGS.has(slug)) {
224 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
225 }
226 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
227 if (existing) {
228 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
229 }
230
231 const f = { ...siteEditableFields(), ...req.body };
232
233 // Owner: god may assign the site to a DIFFERENT user — this is the core of
234 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
235 // creating god themselves.
236 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
237
238 const siteId = uuid();
239 db.prepare(`
240 INSERT INTO sites (
241 id, slug, title, description, tagline, owner_id,
242 language, palette, accent, profile_photo,
243 is_public, robots_index, require_login_to_comment, enable_audio_player,
244 feed_view_default
245 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
246 `).run(
247 siteId, slug,
248 (f.title || slug).slice(0, 200),
249 (f.description || '').slice(0, 500),
250 (f.tagline || '').slice(0, 200),
251 ownerId,
252 f.language || 'nl',
253 f.palette || 'klonkt',
254 ThemeService.validateAccent(f.accent) || '#e8b04b',
255 f.profile_photo || null,
256 f.is_public ? 1 : 0,
257 f.robots_index ? 1 : 0,
258 f.require_login_to_comment ? 1 : 0,
259 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
260 f.feed_view_default === 'timeline' ? 'timeline' : 'grid',
261 );
262
263 // The OWNER (not necessarily the creator) gets a site_members admin row → this
264 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
265 grantSiteAdmin(siteId, ownerId);
266
267 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
268});
269
270// ==================== EDIT (form) ====================
271router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
272 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
273 if (!site) return res.redirect('/admin/sites?error=Not+found');
274
275 let parsedLinks = [];
276 if (site.profile_links) {
277 try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}
278 }
279
280 let apAliases = '';
281 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
282
283 renderPage(req, res, 'pages/admin-site-edit', {
284 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
285 bodyClass: 'on-admin',
286 isNew: false,
287 site,
288 users: listOwnerCandidates(),
289 palettes: ThemeService.listPalettes(),
290 accents: ThemeService.listAccents(),
291 platforms: listPlatforms(),
292 parsedLinks,
293 apAliases,
294 success: req.query.success || null,
295 error: req.query.error || null,
296 });
297});
298
299// ==================== SAVE ====================
300router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
301 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
302 if (!site) return res.redirect('/admin/sites?error=Not+found');
303
304 const f = req.body;
305 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
306 const profileLinksJson = buildProfileLinks(f);
307
308 // FEP-7628 aliases — validated/resolved before anything is written.
309 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
310 let apAliasesJson = null;
311 try {
312 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
313 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
314 } catch (e) {
315 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
316 }
317
318 // theme_override: only accept the three legal values. Empty string means
319 // "Auto" — defer to user's prefers-color-scheme on first paint.
320 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
321
322 // accent: only accept colors from the curated ACCENTS list. Falls back to
323 // the orange default if the submitted value isn't recognised.
324 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
325
326 db.prepare(`
327 UPDATE sites SET
328 title = ?, description = ?, tagline = ?, language = ?,
329 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
330 profile_enabled = ?,
331 profile_links = ?,
332 ap_aliases = ?,
333 is_public = ?, robots_index = ?, require_login_to_comment = ?,
334 enable_audio_player = ?,
335 feed_view_default = ?, feed_view_switch = ?,
336 show_search = ?, show_archive_link = ?,
337 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
338 updated_at = CURRENT_TIMESTAMP
339 WHERE id = ?
340 `).run(
341 (f.title || '').slice(0, 200),
342 (f.description || '').slice(0, 500),
343 (f.tagline || '').slice(0, 200),
344 f.language || 'nl',
345 f.palette || 'klonkt',
346 accent,
347 themeOverride,
348 f.profile_photo || null,
349 f.profile_enabled ? 1 : 0,
350 profileLinksJson,
351 apAliasesJson,
352 f.is_public ? 1 : 0,
353 f.robots_index ? 1 : 0,
354 f.require_login_to_comment ? 1 : 0,
355 f.enable_audio_player ? 1 : 0,
356 feedViewDef,
357 f.feed_view_switch ? 1 : 0,
358 f.show_search ? 1 : 0,
359 f.show_archive_link ? 1 : 0,
360 f.custom_css || null,
361 f.custom_head_html || null,
362 f.custom_foot_html || null,
363 site.id,
364 );
365
366 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
367 // change the owner (the field is not shown to non-god users either).
368 if (req.session.user.role === 'god') {
369 const newOwner = validOwnerId(req.body.owner_id);
370 if (newOwner) {
371 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
372 grantSiteAdmin(site.id, newOwner);
373 }
374 }
375
376 // Alias change → broadcast an actor Update so remote caches refresh. The old
377 // server re-fetches the actor live during a Move anyway; this is freshness,
378 // not correctness, hence best-effort.
379 if ((site.ap_aliases || null) !== apAliasesJson) {
380 try {
381 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
382 AP.deliverActorUpdate(fresh).catch(() => {});
383 } catch { /* never blocks the save */ }
384 }
385
386 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
387});
388
389// ==================== MAKE PRIMARY ====================
390// God chooses which site is the primary/main site (the label/company site in hub;
391// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
392router.post('/:slug/make-primary', requireGod, (req, res) => {
393 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
394 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
395 db.transaction(() => {
396 db.prepare('UPDATE sites SET is_primary = 0').run();
397 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
398 })();
399 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
400});
401
402// ==================== DELETE ====================
403router.post('/:slug/delete', requireGod, (req, res) => {
404 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
405 if (!site) return res.redirect('/admin/sites?error=Not+found');
406
407 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
408 if (postCount > 0) {
409 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
410 }
411
412 // Clean up site_members and audio_tracks (no posts to worry about).
413 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
414 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
415 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
416
417 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
418});
419
420export default router;
Note: See TracBrowser for help on using the repository browser.