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

main
Last change on this file since 0ca7e9a4 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
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 apAliases: '',
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 };
233
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.
237 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
238
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,
244 is_public, robots_index, require_login_to_comment, enable_audio_player,
245 feed_view_default
246 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
247 `).run(
248 siteId, slug,
249 (f.title || slug).slice(0, 200),
250 (f.description || '').slice(0, 500),
251 (f.tagline || '').slice(0, 200),
252 ownerId,
253 f.language || 'nl',
254 f.palette || 'klonkt',
255 ThemeService.validateAccent(f.accent) || '#e8b04b',
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),
261 f.feed_view_default === 'timeline' ? 'timeline' : 'grid',
262 );
263
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.
266 grantSiteAdmin(siteId, ownerId);
267
268 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
269});
270
271// ==================== EDIT (form) ====================
272router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
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
281 let apAliases = '';
282 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
283
284 renderPage(req, res, 'pages/admin-site-edit', {
285 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
286 bodyClass: 'on-admin',
287 isNew: false,
288 site,
289 users: listOwnerCandidates(),
290 palettes: ThemeService.listPalettes(),
291 accents: ThemeService.listAccents(),
292 platforms: listPlatforms(),
293 parsedLinks,
294 apAliases,
295 success: req.query.success || null,
296 error: req.query.error || null,
297 });
298});
299
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
321// ==================== SAVE ====================
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);
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
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
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.
346 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
347
348 db.prepare(`
349 UPDATE sites SET
350 title = ?, description = ?, tagline = ?, language = ?,
351 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
352 profile_enabled = ?,
353 profile_links = ?,
354 ap_aliases = ?,
355 is_public = ?, robots_index = ?, require_login_to_comment = ?,
356 enable_audio_player = ?,
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',
367 f.palette || 'klonkt',
368 accent,
369 themeOverride,
370 f.profile_photo || null,
371 f.profile_enabled ? 1 : 0,
372 profileLinksJson,
373 apAliasesJson,
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
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).
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
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
408 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
409});
410
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.
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
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.