Changeset 0ca7e9a4 in Klonkt
- Timestamp:
- 07/31/2026 05:43:16 PM (6 weeks ago)
- Branches:
- main
- Children:
- bfa6fa1
- Parents:
- f434294
- Files:
-
- 1 added
- 6 edited
-
node_modules (added)
-
src/config/database.js (modified) (1 diff)
-
src/routes/admin-sites.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/admin-site-edit.ejs (modified) (1 diff)
-
test/move-actor.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
rf434294 r0ca7e9a4 147 147 // of its followers to this account — the claim must be visible on OUR side. 148 148 ensureColumn('sites', 'ap_aliases', 'TEXT'); 149 ensureColumn('sites', 'moved_to', 'TEXT'); // FEP-7628 slice 2: waarheen dit account vertrok 149 150 150 151 // Per-post noindex + type -
src/routes/admin-sites.js
rf434294 r0ca7e9a4 296 296 error: req.query.error || null, 297 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. 304 router.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)); 298 319 }); 299 320 -
src/services/ActivityPubService.js
rf434294 r0ca7e9a4 3843 3843 } 3844 3844 3845 /** 3846 * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt 3847 * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets 3848 * de deur uit gaat: 3849 * 1. Geen guardians: een warded account verhuizen zonder de guardianship 3850 * te hertargeten zou het vangnet van het kind stil breken; dat is 3851 * shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt 3852 * account de verhuizing. 3853 * 2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference 3854 * die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder 3855 * die claim is de Move overal dood bij aankomst. 3856 * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de 3857 * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de 3858 * actor (en het beleid van de oude site) is slice 3. 3859 * Deps injecteerbaar voor tests (geen netwerk in node:test). 3860 */ 3861 export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) { 3862 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 3863 if (!base || !site || !site.slug) return { error: 'config' }; 3864 try { 3865 const guardians = Guardianship.listGuardians(site.slug); 3866 if (guardians.length) { 3867 console.warn('[AP] move refused: guarded account (shaer-tge):', site.slug, '→', String(targetRaw || '')); 3868 return { error: 'guarded_account' }; 3869 } 3870 } catch { /* geen guardianship-tabellen = geen guardians */ } 3871 const s = String(targetRaw || '').trim(); 3872 let targetUri = null; 3873 if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s); 3874 else if (s.includes('@')) targetUri = await webfingerResolve(s); 3875 if (!targetUri) return { error: 'not_found' }; 3876 const me = actorId(base, site.slug); 3877 if (targetUri === me) return { error: 'self' }; 3878 const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri)); 3879 if (!target || !target.id || !target.inbox) return { error: 'unreachable' }; 3880 const aka = [].concat(target.alsoKnownAs || []) 3881 .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean); 3882 if (!aka.includes(me)) return { error: 'no_backreference' }; 3883 db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug); 3884 const keys = getOrCreateKeys(site.slug); 3885 const move = { 3886 '@context': AP_CONTEXT, 3887 id: `${me}#move-${Date.now()}-${rid()}`, 3888 type: 'Move', 3889 actor: me, 3890 object: me, 3891 target: target.id, 3892 to: [`${me}/followers`], 3893 }; 3894 const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))]; 3895 for (const inbox of inboxes) { 3896 await (deliverFn || deliverWithRetry)(site.slug, inbox, move, `${me}#main-key`, keys.private_pem); 3897 } 3898 console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)'); 3899 return { ok: true, target: target.id, inboxes: inboxes.length }; 3900 } 3901 3845 3902 // FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed 3846 3903 /** … … 4359 4416 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, 4360 4417 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4361 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,4418 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, 4362 4419 acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision, 4363 4420 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, -
src/services/i18n.js
rf434294 r0ca7e9a4 253 253 'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.', 254 254 'asite.aliases': 'Fediverse-aliassen', 255 'asite.move': 'Verhuizen (fediverse)', 256 'asite.move_hint': 'Kondig aan je volgers aan dat dit account ergens anders verder gaat. Het nieuwe profiel moet dit adres eerst als alias claimen; volgers verhuizen dan vanzelf mee. Een account met guardians kan nog niet verhuizen.', 257 'asite.move_confirm': 'Weet je het zeker? Je volgers krijgen te horen dat dit account is verhuisd.', 258 'asite.move_btn': 'Kondig verhuizing aan', 259 'asite.moved_to': 'Verhuisd naar', 255 260 '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.', 256 261 'asite.link_add': '+ Link toevoegen', … … 1191 1196 'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.', 1192 1197 'asite.aliases': 'Fediverse aliases', 1198 'asite.move': 'Move (fediverse)', 1199 'asite.move_hint': 'Announce to your followers that this account continues elsewhere. The new profile must claim this address as an alias first; followers then move along automatically. An account with guardians cannot move yet.', 1200 'asite.move_confirm': 'Are you sure? Your followers will be told this account has moved.', 1201 'asite.move_btn': 'Announce move', 1202 'asite.moved_to': 'Moved to', 1193 1203 '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.', 1194 1204 'asite.link_add': '+ Add link', … … 2128 2138 'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.', 2129 2139 'asite.aliases': 'Fediverse-Aliasse', 2140 'asite.move': 'Umzug (Fediverse)', 2141 'asite.move_hint': 'Kündige deinen Followern an, dass dieses Konto woanders weitergeht. Das neue Profil muss diese Adresse zuerst als Alias beanspruchen; Follower ziehen dann automatisch mit. Ein Konto mit Guardians kann noch nicht umziehen.', 2142 'asite.move_confirm': 'Bist du sicher? Deine Follower erfahren, dass dieses Konto umgezogen ist.', 2143 'asite.move_btn': 'Umzug ankündigen', 2144 'asite.moved_to': 'Umgezogen nach', 2130 2145 '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.', 2131 2146 'asite.link_add': '+ Link hinzufügen', -
src/views/pages/admin-site-edit.ejs
rf434294 r0ca7e9a4 270 270 <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button> 271 271 </form> 272 273 <% if (!isNew) { %> 274 <form method="post" action="/admin/sites/<%= site.slug %>/move" class="admin-form" 275 onsubmit="return confirm('<%= t('asite.move_confirm') %>');"> 276 <fieldset> 277 <legend><%= t('asite.move') %></legend> 278 <p class="form-hint"><%= t('asite.move_hint') %></p> 279 <% if (typeof site.moved_to === 'string' && site.moved_to) { %> 280 <p class="form-hint"><strong><%= t('asite.moved_to') %>:</strong> <%= site.moved_to %></p> 281 <% } %> 282 <label> 283 <input type="text" name="move_target" placeholder="@nieuw@andere-server.example" required> 284 </label> 285 <button type="submit" class="btn btn-danger"><%= t('asite.move_btn') %></button> 286 </fieldset> 287 </form> 288 <% } %> 272 289 </div> 273 290 -
test/move-actor.test.js
rf434294 r0ca7e9a4 124 124 assert.equal('alsoKnownAs' in buildActor('https://test.example', site), false); 125 125 }); 126 127 // ── Slice 2: the OUTGOING half ───────────────────────────────────── 128 // This Klonkt as the old home. A guarded account refuses (shaer-tge), a 129 // target without the alsoKnownAs back-reference refuses, and the happy path 130 // records moved_to and delivers ONE Move to every follower inbox. 131 const { moveAccount } = await import('../src/services/ActivityPubService.js'); 132 const ME_RADIO = 'https://test.example/ap/users/radio'; 133 const radioSite = () => db.prepare("SELECT * FROM sites WHERE slug = 'radio'").get(); 134 135 test('a guarded account refuses to move (shaer-tge)', async () => { 136 db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, status, offer_id) 137 VALUES ('blog', 'ward', 'https://oma.example/u/oma', 'accepted', 'o9')`).run(); 138 const blogSite = db.prepare("SELECT * FROM sites WHERE slug = 'blog'").get(); 139 const r = await moveAccount(blogSite, 'https://elders.example/users/nieuw', { 140 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://test.example/ap/users/blog'] }), 141 }); 142 assert.equal(r.error, 'guarded_account'); 143 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'blog'").get().moved_to, null, 'nothing recorded'); 144 }); 145 146 test('without the back-reference the move refuses', async () => { 147 const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', { 148 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: ['https://iemand-anders.example/x'] }), 149 }); 150 assert.equal(r.error, 'no_backreference'); 151 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, null); 152 }); 153 154 test('the happy path: moved_to recorded, one Move to every follower inbox', async () => { 155 db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES ('radio', 'https://a.example/u/a', 'https://a.example/inbox')").run(); 156 db.prepare("INSERT INTO ap_followers (slug, actor_uri, inbox, shared_inbox) VALUES ('radio', 'https://b.example/u/b', 'https://b.example/inbox', 'https://b.example/shared')").run(); 157 const delivered = []; 158 const r = await moveAccount(radioSite(), 'https://elders.example/users/nieuw', { 159 fetchActorFn: async () => ({ id: 'https://elders.example/users/nieuw', inbox: 'https://elders.example/inbox', alsoKnownAs: [ME_RADIO] }), 160 deliverFn: async (slug, inbox, activity) => { delivered.push({ inbox, activity }); }, 161 }); 162 assert.equal(r.ok, true); 163 assert.equal(r.target, 'https://elders.example/users/nieuw'); 164 assert.equal(db.prepare("SELECT moved_to FROM sites WHERE slug = 'radio'").get().moved_to, 'https://elders.example/users/nieuw'); 165 assert.equal(delivered.length, 2, 'both follower inboxes'); 166 for (const d of delivered) { 167 assert.equal(d.activity.type, 'Move'); 168 assert.equal(d.activity.object, ME_RADIO); 169 assert.equal(d.activity.target, 'https://elders.example/users/nieuw'); 170 } 171 assert.ok(delivered.some((d) => d.inbox === 'https://b.example/shared'), 'shared inbox preferred'); 172 });
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)