Changeset ccaa530 in Klonkt
- Timestamp:
- 07/31/2026 01:34:46 PM (6 weeks ago)
- Branches:
- main
- Children:
- 5462bab
- Parents:
- 2dd1dc4
- Files:
-
- 1 added
- 5 edited
-
src/config/database.js (modified) (1 diff)
-
src/routes/admin-sites.js (modified) (9 diffs)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/admin-site-edit.ejs (modified) (1 diff)
-
test/move-actor.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r2dd1dc4 rccaa530 143 143 ensureColumn('sites', 'external_playback', 'INTEGER'); 144 144 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark' 145 // FEP-7628: former identities this actor claims (JSON array of actor URIs). 146 // Publishing them as alsoKnownAs is what lets the OLD server approve a Move 147 // of its followers to this account — the claim must be visible on OUR side. 148 ensureColumn('sites', 'ap_aliases', 'TEXT'); 145 149 146 150 // Per-post noindex + type -
src/routes/admin-sites.js
r2dd1dc4 rccaa530 26 26 import { toWebp } from '../services/ImageWebpService.js'; 27 27 import { mediaDir } from '../config/paths.js'; 28 import AP from '../services/ActivityPubService.js'; 28 29 29 30 … … 71 72 } 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 */ 82 async 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; 73 95 } 74 96 … … 256 278 } 257 279 280 let apAliases = ''; 281 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ } 282 258 283 renderPage(req, res, 'pages/admin-site-edit', { 259 284 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title }, … … 266 291 platforms: listPlatforms(), 267 292 parsedLinks, 293 apAliases, 268 294 success: req.query.success || null, 269 295 error: req.query.error || null, … … 272 298 273 299 // ==================== SAVE ==================== 274 router.post('/:slug/save', requireSiteManagerBySlug, (req, res) => {275 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);300 router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => { 301 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug); 276 302 if (!site) return res.redirect('/admin/sites?error=Not+found'); 277 303 … … 279 305 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline'; 280 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 } 281 317 282 318 // theme_override: only accept the three legal values. Empty string means … … 294 330 profile_enabled = ?, 295 331 profile_links = ?, 332 ap_aliases = ?, 296 333 is_public = ?, robots_index = ?, require_login_to_comment = ?, 297 334 enable_audio_player = ?, … … 312 349 f.profile_enabled ? 1 : 0, 313 350 profileLinksJson, 351 apAliasesJson, 314 352 f.is_public ? 1 : 0, 315 353 f.robots_index ? 1 : 0, … … 336 374 } 337 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 338 386 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen')); 339 387 }); -
src/services/ActivityPubService.js
r2dd1dc4 rccaa530 46 46 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', 47 47 discoverable: 'toot:discoverable', 48 // FEP-7628 (account moves): same term declaration Mastodon ships. 49 alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' }, 50 movedTo: { '@id': 'as:movedTo', '@type': '@id' }, 48 51 featured: { '@id': 'toot:featured', '@type': '@id' }, 49 52 PropertyValue: 'schema:PropertyValue', … … 221 224 // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2). 222 225 if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } } 226 // FEP-7628: former identities this account claims. The OLD server checks for 227 // exactly this back-reference before it will move followers here, so the 228 // list must be on the public actor, not tucked away in settings. 229 try { 230 const aka = JSON.parse(site.ap_aliases || '[]'); 231 if (Array.isArray(aka)) { 232 const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id); 233 if (clean.length) actor.alsoKnownAs = clean; 234 } 235 } catch { /* skip malformed ap_aliases */ } 223 236 // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as 224 237 // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers. … … 1437 1450 // Blocked actor/domain → silently drop (202, don't reveal the block). 1438 1451 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; } 1439 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer' ];1452 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move']; 1440 1453 if (GATED.includes(type)) { 1441 1454 if (!verified || !claimedActor || verified.id !== claimedActor) { … … 1513 1526 } catch { /* ignore */ } 1514 1527 return 202; 1528 } 1529 1530 // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose: 1531 // a Move often arrives seconds before the new actor's re-Follow wave, and the 1532 // swap below must not race our own outgoing Follow of the target. 1533 if (type === 'Move') { 1534 return handleMoveInbox(act, { verifiedActor: claimedActor }); 1515 1535 } 1516 1536 … … 3760 3780 } 3761 3781 3782 /** 3783 * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can 3784 * still change): an account our sites follow says it moved to a new home. 3785 * 3786 * Validity has two independent legs, and both must hold: 3787 * 1. The SIGNER is a party to the move: the old actor announcing its own move 3788 * (push mode) or the new actor doing it (pull mode). A third party 3789 * narrating someone else's move is refused — without this, any signed 3790 * stranger could re-point our follows. 3791 * 2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the 3792 * cross-side proof: the mover controls both ends. Without it, whoever 3793 * holds ONE end could hijack the other end's followers. 3794 * 3795 * Effect: every local site following the old actor unfollows it and follows 3796 * the new one, keeping its auto-boost choice. Deliberately NOT retargeted: 3797 * guardianship relations (FEP-633c) — a guardian is a security anchor, not a 3798 * feed subscription, and moving one is shaer-tge's gated decision, not a 3799 * side effect of an inbox event. We only log when a move touches one. 3800 * 3801 * Deps are injectable for tests (no network in node:test). 3802 */ 3803 export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) { 3804 const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id); 3805 const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id); 3806 if (!oldUri || !newUri || oldUri === newUri) return 400; 3807 if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) { 3808 console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri); 3809 return 401; 3810 } 3811 // Nobody here follows the old actor → nothing to move. This also makes 3812 // redelivery idempotent: after the first swap the rows are gone. 3813 let rows = []; 3814 try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ } 3815 if (!rows.length) return 202; 3816 // A blocked destination is declined outright: the old follow stays (it goes 3817 // stale on its own), and we will not open a door to a blocked house. 3818 if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; } 3819 const target = await (fetchActorFn || fetchActor)(newUri); 3820 const aka = [].concat((target && target.alsoKnownAs) || []) 3821 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean); 3822 if (!target || !target.id || !aka.includes(oldUri)) { 3823 console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri); 3824 return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server 3825 } 3826 for (const row of rows) { 3827 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug); 3828 if (!site) continue; 3829 try { 3830 await (unfollowFn || unfollowActor)(site, oldUri); 3831 const already = fwStmts().one.get(row.slug, newUri); 3832 if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost); 3833 console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri); 3834 } catch (e) { 3835 console.warn('[AP] move re-follow failed for', row.slug, e && e.message); 3836 } 3837 } 3838 try { 3839 const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted'); 3840 if (g.length) console.warn('[AP] Move touches a guardianship party — left untouched (shaer-tge):', oldUri, '→', g.map((r) => `${r.role}:${r.slug}`).join(', ')); 3841 } catch { /* table absent on fresh init */ } 3842 return 202; 3843 } 3844 3762 3845 // FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed 3763 3846 /** … … 4276 4359 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, 4277 4360 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4278 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,4361 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, 4279 4362 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision, 4280 4363 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, -
src/services/i18n.js
r2dd1dc4 rccaa530 252 252 'asite.links': 'Social / streaming-links', 253 253 'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.', 254 'asite.aliases': 'Fediverse-aliassen', 255 'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.', 254 256 'asite.link_add': '+ Link toevoegen', 255 257 'asite.feed_view': 'Feed-weergave', … … 1188 1190 'asite.links': 'Social / streaming links', 1189 1191 'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.', 1192 'asite.aliases': 'Fediverse aliases', 1193 'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.', 1190 1194 'asite.link_add': '+ Add link', 1191 1195 'asite.feed_view': 'Feed display', … … 2123 2127 'asite.links': 'Social- / Streaming-Links', 2124 2128 'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.', 2129 'asite.aliases': 'Fediverse-Aliasse', 2130 'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.', 2125 2131 'asite.link_add': '+ Link hinzufügen', 2126 2132 'asite.feed_view': 'Feed-Anzeige', -
src/views/pages/admin-site-edit.ejs
r2dd1dc4 rccaa530 211 211 212 212 <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button> 213 </fieldset> 214 215 <fieldset> 216 <legend><%= t('asite.aliases') %></legend> 217 <p class="form-hint"><%= t('asite.aliases_hint') %></p> 218 <label> 219 <textarea name="ap_aliases" rows="3" placeholder="@oud@mastodon.social https://andere-klonkt.example/ap/users/naam"><%= apAliases %></textarea> 220 </label> 213 221 </fieldset> 214 222
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)