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

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

Account-verhuizingen slice 2: de uitgaande Move (FEP-7628)

De vertrekkende helft van shaer-0j2: deze Klonkt als het oude huis. Twee
eisen voordat er iets de deur uit gaat. Een: geen guardians; een warded
account verhuizen zonder de guardianship te hertargeten zou het vangnet
van het kind stil breken, dus tot shaer-tge er is weigert een bewaakt
account met een heldere melding. Twee: het nieuwe profiel claimt dit
adres in alsoKnownAs, dezelfde back-reference die elke ontvangende
server (onze slice 1 incluis) eist; zonder die claim is de Move overal
dood bij aankomst.

De Move gaat duurzaam (retry-queue) naar elke volger-inbox; hun servers
doen de re-follow. moved_to wordt vastgelegd; het serveren ervan op de
actor en het beleid van de oude site zijn slice 3. In Beheer > Sites >
bewerken zit de aankondiging als eigen formulier met bevestiging, nooit
als bijeffect van Opslaan: een verhuizing is een deur die je achter je
dichttrekt.

Changed files:
src/services/ActivityPubService.js

  • moveAccount(site, target, {fetchActorFn, deliverFn}): guardian- weigering, resolutie (URL/handle), back-reference-check, moved_to, Move naar alle volger-inboxen (shared inbox de-dupe)

src/config/database.js

  • sites.moved_to (TEXT)

src/routes/admin-sites.js

  • POST /:slug/move met per-fout een heldere redirect-melding

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

  • Verhuizen-sectie (eigen form + confirm), toont moved_to

src/services/i18n.js

  • asite.move* in nl/en/de

test/move-actor.test.js

  • guarded weigert (niets vastgelegd), zonder back-reference weigert, happy path: moved_to + een Move per volger-inbox (shared voorop)

remarks: gebouwd op vps/main (f434294) in een worktree, los van de
lopende boost-toggle-sessie in de hoofd-clone. Suite op 370 groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 17.3 KB
RevLine 
[7bc636b]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';
[8cb1dc7]23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
[7bc636b]24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
[8f6225c]26import { toWebp } from '../services/ImageWebpService.js';
[e2c3d09]27import { mediaDir } from '../config/paths.js';
[ccaa530]28import AP from '../services/ActivityPubService.js';
[7bc636b]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.
[e2c3d09]34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
[7bc636b]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
[ccaa530]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
[7bc636b]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.
[8cb1dc7]104router.post('/upload-photo', requireAuth, (req, res) => {
[7bc636b]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,
[8f6225c]110 url: `/media/avatars/${toWebp(req.file)}`,
[7bc636b]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',
[8f2f97c]119 'account', 'sites', 'comments', 'posts', 'media', 'audio',
[a1c8cb8]120 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
[7bc636b]121 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
[318d0c2]122 'paid', 'push', 'guardian',
[7bc636b]123]);
124
125function siteEditableFields() {
126 return {
127 title: '',
128 description: '',
129 tagline: '',
130 language: 'nl',
[dd7e2a2]131 palette: 'klonkt',
132 accent: '#e8b04b',
[7bc636b]133 profile_photo: '',
134 profile_enabled: 1,
135 profile_name: '',
136 profile_bio: '',
137 is_public: 1,
138 robots_index: 1,
[74544fb]139 require_login_to_comment: 1,
[7bc636b]140 enable_audio_player: 1,
[8ea3d0d]141 comments_moderation_mode: 'moderate',
142 feed_view_default: 'grid',
[7bc636b]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
[834bcc3]159/** Valid user-id for owner assignment, or null if empty/unknown. */
[98ecf51]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
[834bcc3]166/** Grant a user admin rights on a site (idempotent upsert). */
[98ecf51]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
[834bcc3]174/** Candidate owners for the owner selector field (god-only). */
[98ecf51]175function listOwnerCandidates() {
176 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
177}
178
[7bc636b]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,
[7881080]183 s.is_public, s.robots_index, s.is_primary,
[7bc636b]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
[7881080]187 ORDER BY s.is_primary DESC, s.created_at DESC
[7bc636b]188 `).all();
189
190 renderPage(req, res, 'pages/admin-sites', {
[3487567]191 pageTitleKey: 'admin.t_sites',
[7bc636b]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', {
[3487567]202 pageTitleKey: 'admin.t_newsite',
[7bc636b]203 bodyClass: 'on-admin',
204 isNew: true,
[834bcc3]205 // ?owner=<id> (from the users page: "give this user a Klonkt") is
206 // pre-selected; otherwise defaults to the creating god.
[86793f9]207 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
[98ecf51]208 users: listOwnerCandidates(),
[7bc636b]209 palettes: ThemeService.listPalettes(),
210 accents: ThemeService.listAccents(),
211 platforms: listPlatforms(),
212 parsedLinks: [],
[5462bab]213 apAliases: '',
[7bc636b]214 error: null,
215 });
216});
217
218// ==================== CREATE ====================
219router.post('/create', requireGod, (req, res) => {
220 const slug = (req.body.slug || '').toString().toLowerCase().trim();
221 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
222 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
223 }
224 if (RESERVED_SITE_SLUGS.has(slug)) {
225 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
226 }
227 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
228 if (existing) {
229 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
230 }
231
232 const f = { ...siteEditableFields(), ...req.body };
[98ecf51]233
[834bcc3]234 // Owner: god may assign the site to a DIFFERENT user — this is the core of
235 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
236 // creating god themselves.
[98ecf51]237 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
238
[7bc636b]239 const siteId = uuid();
240 db.prepare(`
241 INSERT INTO sites (
242 id, slug, title, description, tagline, owner_id,
243 language, palette, accent, profile_photo,
[8ea3d0d]244 is_public, robots_index, require_login_to_comment, enable_audio_player,
[abdcf33]245 feed_view_default
246 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
[7bc636b]247 `).run(
248 siteId, slug,
249 (f.title || slug).slice(0, 200),
250 (f.description || '').slice(0, 500),
251 (f.tagline || '').slice(0, 200),
[98ecf51]252 ownerId,
[7bc636b]253 f.language || 'nl',
[dd7e2a2]254 f.palette || 'klonkt',
255 ThemeService.validateAccent(f.accent) || '#e8b04b',
[7bc636b]256 f.profile_photo || null,
257 f.is_public ? 1 : 0,
258 f.robots_index ? 1 : 0,
259 f.require_login_to_comment ? 1 : 0,
260 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
[8ea3d0d]261 f.feed_view_default === 'timeline' ? 'timeline' : 'grid',
[7bc636b]262 );
263
[834bcc3]264 // The OWNER (not necessarily the creator) gets a site_members admin row → this
265 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
[98ecf51]266 grantSiteAdmin(siteId, ownerId);
[7bc636b]267
[98ecf51]268 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
[7bc636b]269});
270
271// ==================== EDIT (form) ====================
[8cb1dc7]272router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
[7bc636b]273 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
274 if (!site) return res.redirect('/admin/sites?error=Not+found');
275
276 let parsedLinks = [];
277 if (site.profile_links) {
278 try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}
279 }
280
[ccaa530]281 let apAliases = '';
282 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
283
[7bc636b]284 renderPage(req, res, 'pages/admin-site-edit', {
[3487567]285 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
[7bc636b]286 bodyClass: 'on-admin',
287 isNew: false,
288 site,
[98ecf51]289 users: listOwnerCandidates(),
[7bc636b]290 palettes: ThemeService.listPalettes(),
291 accents: ThemeService.listAccents(),
292 platforms: listPlatforms(),
293 parsedLinks,
[ccaa530]294 apAliases,
[7bc636b]295 success: req.query.success || null,
296 error: req.query.error || null,
297 });
298});
299
[0ca7e9a4]300// ==================== MOVE (FEP-7628, slice 2) ====================
301// The explicit departure: announce to every follower that this account now
302// lives elsewhere. Deliberately its own POST with its own button, never a
303// side effect of Save: a Move is a door you close behind you.
304router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
305 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
306 if (!site) return res.redirect('/admin/sites?error=Not+found');
307 const r = await AP.moveAccount(site, req.body.move_target || '');
308 if (r && r.ok) {
309 return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
310 }
311 const msg = {
312 guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
313 no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
314 not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
315 unreachable: 'Het nieuwe profiel is niet bereikbaar.',
316 self: 'Dat is dit account zelf.',
317 }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
318 res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
319});
320
[7bc636b]321// ==================== SAVE ====================
[ccaa530]322router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
323 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
[7bc636b]324 if (!site) return res.redirect('/admin/sites?error=Not+found');
325
326 const f = req.body;
327 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
328 const profileLinksJson = buildProfileLinks(f);
329
[ccaa530]330 // FEP-7628 aliases — validated/resolved before anything is written.
331 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
332 let apAliasesJson = null;
333 try {
334 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
335 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
336 } catch (e) {
337 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
338 }
339
[7bc636b]340 // theme_override: only accept the three legal values. Empty string means
341 // "Auto" — defer to user's prefers-color-scheme on first paint.
342 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
343
344 // accent: only accept colors from the curated ACCENTS list. Falls back to
345 // the orange default if the submitted value isn't recognised.
[dd7e2a2]346 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
[7bc636b]347
348 db.prepare(`
349 UPDATE sites SET
350 title = ?, description = ?, tagline = ?, language = ?,
351 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
[2c246d4]352 profile_enabled = ?,
[7bc636b]353 profile_links = ?,
[ccaa530]354 ap_aliases = ?,
[7bc636b]355 is_public = ?, robots_index = ?, require_login_to_comment = ?,
[8f2f97c]356 enable_audio_player = ?,
[7bc636b]357 feed_view_default = ?, feed_view_switch = ?,
358 show_search = ?, show_archive_link = ?,
359 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
360 updated_at = CURRENT_TIMESTAMP
361 WHERE id = ?
362 `).run(
363 (f.title || '').slice(0, 200),
364 (f.description || '').slice(0, 500),
365 (f.tagline || '').slice(0, 200),
366 f.language || 'nl',
[dd7e2a2]367 f.palette || 'klonkt',
[7bc636b]368 accent,
369 themeOverride,
370 f.profile_photo || null,
371 f.profile_enabled ? 1 : 0,
372 profileLinksJson,
[ccaa530]373 apAliasesJson,
[7bc636b]374 f.is_public ? 1 : 0,
375 f.robots_index ? 1 : 0,
376 f.require_login_to_comment ? 1 : 0,
377 f.enable_audio_player ? 1 : 0,
378 feedViewDef,
379 f.feed_view_switch ? 1 : 0,
380 f.show_search ? 1 : 0,
381 f.show_archive_link ? 1 : 0,
382 f.custom_css || null,
383 f.custom_head_html || null,
384 f.custom_foot_html || null,
385 site.id,
386 );
387
[834bcc3]388 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
389 // change the owner (the field is not shown to non-god users either).
[98ecf51]390 if (req.session.user.role === 'god') {
391 const newOwner = validOwnerId(req.body.owner_id);
392 if (newOwner) {
393 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
394 grantSiteAdmin(site.id, newOwner);
395 }
396 }
397
[ccaa530]398 // Alias change → broadcast an actor Update so remote caches refresh. The old
399 // server re-fetches the actor live during a Move anyway; this is freshness,
400 // not correctness, hence best-effort.
401 if ((site.ap_aliases || null) !== apAliasesJson) {
402 try {
403 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
404 AP.deliverActorUpdate(fresh).catch(() => {});
405 } catch { /* never blocks the save */ }
406 }
407
[98ecf51]408 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
[7bc636b]409});
410
[834bcc3]411// ==================== MAKE PRIMARY ====================
412// God chooses which site is the primary/main site (the label/company site in hub;
413// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
[7881080]414router.post('/:slug/make-primary', requireGod, (req, res) => {
415 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
416 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
417 db.transaction(() => {
418 db.prepare('UPDATE sites SET is_primary = 0').run();
419 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
420 })();
421 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
422});
423
[7bc636b]424// ==================== DELETE ====================
425router.post('/:slug/delete', requireGod, (req, res) => {
426 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
427 if (!site) return res.redirect('/admin/sites?error=Not+found');
428
429 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
430 if (postCount > 0) {
431 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
432 }
433
434 // Clean up site_members and audio_tracks (no posts to worry about).
435 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
436 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
437 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
438
439 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
440});
441
442export default router;
Note: See TracBrowser for help on using the repository browser.