source: Klonkt/src/services/ap-core.js@ fbfd7a1

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

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

  • Property mode set to 100644
File size: 14.0 KB
Line 
1/**
2 * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
3 *
4 * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
5 * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
6 * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
7 * putten -- anders importeert de submap uit ActivityPubService en importeert
8 * die weer terug, en dat is een kring.
9 *
10 * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
11 * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
12 *
13 * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
14 * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
15 *
16 * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
17 * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
18 * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
19 * en zijn eigen buren en importeert nooit terug.
20 */
21
22import * as Guardianship from './guardianship/index.js';
23
24export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
26// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
27// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
28// This is the same context shape Mastodon publishes, so Mastodon sees no change.
29export const AP_CONTEXT = [
30 'https://www.w3.org/ns/activitystreams',
31 'https://w3id.org/security/v1',
32 {
33 toot: 'http://joinmastodon.org/ns#',
34 schema: 'http://schema.org#',
35 sensitive: 'as:sensitive',
36 Hashtag: 'as:Hashtag',
37 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
38 discoverable: 'toot:discoverable',
39 // FEP-7628 (account moves): same term declaration Mastodon ships.
40 alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
41 movedTo: { '@id': 'as:movedTo', '@type': '@id' },
42 featured: { '@id': 'toot:featured', '@type': '@id' },
43 PropertyValue: 'schema:PropertyValue',
44 value: 'schema:value',
45 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
46 // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
47 // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
48 // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
49 // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
50 // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
51 license: { '@id': 'schema:license', '@type': '@id' },
52 // "Dit ding is ook bekend onder die URI" -- voor de MusicBrainz-koppeling
53 // van een artiest (shaer-mbz). Bewust NIET alsoKnownAs: dat is in AS2
54 // gereserveerd voor vroegere IDENTITEITEN van dezelfde actor, en een
55 // verhuizing leunt erop (FEP-7628). Een verwijzing naar een register is
56 // iets anders dan een oud account van jezelf, en die twee door elkaar halen
57 // zou een verhuizing kunnen laten mislukken.
58 sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
59 position: 'schema:position',
60 bitrate: 'schema:bitrate',
61 size: 'schema:contentSize',
62 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
63 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
64 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
65 votersCount: 'toot:votersCount',
66 // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
67 // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
68 // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
69 // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
70 // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
71 // `migration`, want daar leest een consument op.
72 migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
73 moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
74 migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
75 migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
76 migratedAt: 'https://w3id.org/fep/1580/migratedAt',
77 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
78 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
79 // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
80 // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
81 // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
82 // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
83 // antwoordt.
84 category: { '@id': 'https://funkwhale.audio/ns#category' },
85 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
86 // module (src/services/guardianship/).
87 ...Guardianship.SHAER_CONTEXT,
88 },
89];
90
91/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
92 * bruikbare URL vinden. */
93export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
94
95export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
96export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
97
98/**
99 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
100 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
101 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
102 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
103 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
104 */
105export function guessMediaType(u) {
106 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
107 return ({
108 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
109 webp: 'image/webp', avif: 'image/avif',
110 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
111 })[(e || '').toLowerCase()] || 'image/jpeg';
112}
113
114/**
115 * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
116 * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
117 * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
118 * terugval voor wat er handmatig is ingevuld.
119 */
120export function normalizeTags(t) {
121 if (Array.isArray(t)) return t;
122 if (typeof t === 'string') {
123 const s = t.trim(); if (!s) return [];
124 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
125 return s.split(',').map((x) => x.trim()).filter(Boolean);
126 }
127 return [];
128}
129
130/**
131 * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
132 * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
133 * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
134 * blijven kleingeschreven ("livemusic").
135 */
136export function tagParts(raw) {
137 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
138 if (!words.length) return null;
139 const slug = words.join('').toLowerCase();
140 if (!slug) return null;
141 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
142 return { label, slug };
143}
144
145/**
146 * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
147 * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
148 * naam niet: #DoenweNiet blijft #DoenweNiet.
149 */
150export function hashtagTags(base, content) {
151 const tags = [], seen = new Set();
152 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
153 let m;
154 while ((m = re.exec(content || ''))) {
155 const k = m[1].toLowerCase();
156 if (seen.has(k)) continue; seen.add(k);
157 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
158 }
159 return tags;
160}
161
162/**
163 * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
164 *
165 * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
166 * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
167 * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
168 * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
169 * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
170 * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
171 *
172 * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
173 * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
174 * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
175 * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
176 * tagveld, precies de vorm die hier juist niet moest winnen.
177 */
178export function hashtagTagsRuw(base, content) {
179 const tags = [], seen = new Set();
180 // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
181 // tag, en die zou hier anders als hashtag de deur uit gaan.
182 const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
183 let m;
184 while ((m = re.exec(content || ''))) {
185 const k = m[2].toLowerCase();
186 if (seen.has(k)) continue; seen.add(k);
187 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
188 }
189 return tags;
190}
191
192export function buildHashtagList(base, tagsField, content, opts = {}) {
193 const out = [], seen = new Set();
194 const uitLijf = opts.ruw
195 ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
196 : hashtagTags(base, content);
197 for (const h of uitLijf) {
198 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
199 out.push(h);
200 }
201 for (const t of normalizeTags(tagsField)) {
202 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
203 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
204 }
205 return out;
206}
207
208/**
209 * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
210 *
211 * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
212 * "first: This field is required" en "last: This field is required" -- de eerste
213 * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
214 * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
215 * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
216 * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
217 * opnieuw vergeet.
218 *
219 * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
220 * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
221 * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
222 * naar dezelfde.
223 *
224 * @param {string} id de collectie-uri, zonder query
225 * @param {Array} items wat erin zit (mag leeg)
226 * @param {object} opts
227 * totalItems als de telling niet items.length is (followers geeft publiek
228 * alleen een AANTAL en houdt de lijst dicht)
229 * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
230 * extra velden die op de wortel horen (attributedTo, shaer:*)
231 */
232/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
233export const PAGINA_GROOTTE = 20;
234
235/**
236 * Een collectie, met ECHTE paginering (shaer-sk4).
237 *
238 * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
239 * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
240 * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
241 * pagina's identiek bleven; dit is waarom.
242 *
243 * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
244 * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
245 * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
246 * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
247 *
248 * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
249 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
250 * antwoord zijn dat over zichzelf liegt.
251 */
252export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, extra = {} } = {}) {
253 const lijst = items || [];
254 const telling = totalItems === undefined ? lijst.length : totalItems;
255 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
256 // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
257 // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
258 // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
259 // en nooit een `next` aanbieden.
260 const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
261 const url = (n) => `${id}?page=${n}`;
262
263 if (page) {
264 const n = Math.max(1, Math.floor(Number(page)) || 1);
265 const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
266 return {
267 '@context': AP_CONTEXT,
268 id: url(n),
269 type: 'OrderedCollectionPage',
270 partOf: id,
271 totalItems: telling,
272 ...(n > 1 ? { prev: url(n - 1) } : {}),
273 ...(n < paginas ? { next: url(n + 1) } : {}),
274 orderedItems: deel,
275 };
276 }
277 return {
278 '@context': AP_CONTEXT,
279 id,
280 type: 'OrderedCollection',
281 ...extra,
282 totalItems: telling,
283 first: url(1),
284 last: url(paginas),
285 orderedItems: lijst,
286 };
287}
288
289/** Is dit een MBID? Een UUID, en niets anders. */
290export function isMbid(s) {
291 return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || '').trim());
292}
293
294/** De publieke pagina van een artiest, of null als het geen MBID is. */
295export function artiestUrl(mbid) {
296 return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
297}
Note: See TracBrowser for help on using the repository browser.