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

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

Library: audience meesturen en de ongeordende collectievorm gebruiken

De 500 op /ap/users/dev/library kwam van open.audio, niet van ons, en de
oorzaak staat in hun bron (federation/serializers.py op develop):

LibrarySerializer.create() regel 1147

"privacy_level": privacy[validated_dataaudience]

audience staat als required=False in de serializer, dus als wij het
weglaten zit de sleutel niet in validated_data en is dat een KeyError --
een 500 op elke poging onze bibliotheek op te halen. Wij stuurden hem
niet.

Het veld is bovendien gewoon waar: alles in de bak is fedi_open. En bij
hen is het precies het verschil tussen privacy_level 'everyone' en 'me',
oftewel of onze nummers daar afspeelbaar zijn.

Daarnaast de vorm. Hun CollectionPageSerializer (regel 1157) accepteert
alleen CollectionPage met items; wij stuurden OrderedCollectionPage
met orderedItems, en dan komt er van de inhoud niets binnen. Dat is geen
dialect maar de andere helft van AS2: een platenkast heeft geen volgorde
die iets betekent, en hun LibrarySerializer accepteert als type ook alleen
as:Collection of fw:Library. De outbox is wél geordend en blijft zoals hij
was.

De eisen staan nu met bronverwijzing in test/ap-library.test.js, want ze
stonden nergens bij ons opgeschreven -- en het volgende dat gebeurt is
anders dat iemand dit 'opruimt' naar de gewone vorm.

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

  • Property mode set to 100644
File size: 16.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 // ── De ENIGE term die we uit Funkwhale's eigen vocabulaire overnemen ──
60 //
61 // Waarom deze wel en track/ArtistCredit niet (shaer-0nh): die twee vragen
62 // ENTITEITEN waar wij tekst hebben -- een artiest en een album zijn bij ons
63 // kolommen, en er een id voor verzinnen zou beloven wat we niet kunnen
64 // waarmaken. Een Library is iets anders: het is precies wat we AL hebben,
65 // een verzameling van onze eigen open tracks met een echte telling.
66 //
67 // En hij doet iets. Gemeten op 13-8: open.audio heeft onze vier tracks
68 // binnengehaald via hun AP-id, met artist_credit dat het zelf uit onze
69 // attributedTo afleidde -- maar uploads is LEEG en is_playable false. Bij
70 // Funkwhale hangt een upload aan een library; zonder library is er geen bak
71 // om het bestand in te hangen, en blijft een track een naam zonder geluid.
72 fw: 'https://funkwhale.audio/ns#',
73 Library: 'fw:Library',
74 library: { '@id': 'fw:library', '@type': '@id' },
75 position: 'schema:position',
76 bitrate: 'schema:bitrate',
77 size: 'schema:contentSize',
78 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
79 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
80 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
81 votersCount: 'toot:votersCount',
82 // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
83 // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
84 // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
85 // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
86 // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
87 // `migration`, want daar leest een consument op.
88 migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
89 moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
90 migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
91 migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
92 migratedAt: 'https://w3id.org/fep/1580/migratedAt',
93 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
94 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
95 // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
96 // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
97 // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
98 // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
99 // antwoordt.
100 category: { '@id': 'https://funkwhale.audio/ns#category' },
101 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
102 // module (src/services/guardianship/).
103 ...Guardianship.SHAER_CONTEXT,
104 },
105];
106
107/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
108 * bruikbare URL vinden. */
109export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
110
111export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
112export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
113
114/**
115 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
116 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
117 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
118 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
119 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
120 */
121export function guessMediaType(u) {
122 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
123 return ({
124 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
125 webp: 'image/webp', avif: 'image/avif',
126 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
127 })[(e || '').toLowerCase()] || 'image/jpeg';
128}
129
130/**
131 * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
132 * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
133 * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
134 * terugval voor wat er handmatig is ingevuld.
135 */
136export function normalizeTags(t) {
137 if (Array.isArray(t)) return t;
138 if (typeof t === 'string') {
139 const s = t.trim(); if (!s) return [];
140 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
141 return s.split(',').map((x) => x.trim()).filter(Boolean);
142 }
143 return [];
144}
145
146/**
147 * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
148 * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
149 * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
150 * blijven kleingeschreven ("livemusic").
151 */
152export function tagParts(raw) {
153 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
154 if (!words.length) return null;
155 const slug = words.join('').toLowerCase();
156 if (!slug) return null;
157 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
158 return { label, slug };
159}
160
161/**
162 * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
163 * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
164 * naam niet: #DoenweNiet blijft #DoenweNiet.
165 */
166export function hashtagTags(base, content) {
167 const tags = [], seen = new Set();
168 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
169 let m;
170 while ((m = re.exec(content || ''))) {
171 const k = m[1].toLowerCase();
172 if (seen.has(k)) continue; seen.add(k);
173 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
174 }
175 return tags;
176}
177
178/**
179 * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
180 *
181 * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
182 * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
183 * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
184 * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
185 * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
186 * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
187 *
188 * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
189 * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
190 * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
191 * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
192 * tagveld, precies de vorm die hier juist niet moest winnen.
193 */
194export function hashtagTagsRuw(base, content) {
195 const tags = [], seen = new Set();
196 // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
197 // tag, en die zou hier anders als hashtag de deur uit gaan.
198 const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
199 let m;
200 while ((m = re.exec(content || ''))) {
201 const k = m[2].toLowerCase();
202 if (seen.has(k)) continue; seen.add(k);
203 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
204 }
205 return tags;
206}
207
208export function buildHashtagList(base, tagsField, content, opts = {}) {
209 const out = [], seen = new Set();
210 const uitLijf = opts.ruw
211 ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
212 : hashtagTags(base, content);
213 for (const h of uitLijf) {
214 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
215 out.push(h);
216 }
217 for (const t of normalizeTags(tagsField)) {
218 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
219 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
220 }
221 return out;
222}
223
224/**
225 * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
226 *
227 * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
228 * "first: This field is required" en "last: This field is required" -- de eerste
229 * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
230 * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
231 * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
232 * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
233 * opnieuw vergeet.
234 *
235 * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
236 * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
237 * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
238 * naar dezelfde.
239 *
240 * @param {string} id de collectie-uri, zonder query
241 * @param {Array} items wat erin zit (mag leeg)
242 * @param {object} opts
243 * totalItems als de telling niet items.length is (followers geeft publiek
244 * alleen een AANTAL en houdt de lijst dicht)
245 * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
246 * extra velden die op de wortel horen (attributedTo, shaer:*)
247 */
248/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
249export const PAGINA_GROOTTE = 20;
250
251/**
252 * Een collectie, met ECHTE paginering (shaer-sk4).
253 *
254 * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
255 * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
256 * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
257 * pagina's identiek bleven; dit is waarom.
258 *
259 * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
260 * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
261 * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
262 * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
263 *
264 * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
265 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
266 * antwoord zijn dat over zichzelf liegt.
267 *
268 * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
269 * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
270 * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
271 * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
272 * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
273 * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
274 * blijft dus zoals hij was.
275 *
276 * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
277 * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
278 * kan zo terug naar het begin zonder eerst de wortel op te halen.
279 */
280export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
281 const lijst = items || [];
282 const telling = totalItems === undefined ? lijst.length : totalItems;
283 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
284 // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
285 // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
286 // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
287 // en nooit een `next` aanbieden.
288 const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
289 const url = (n) => `${id}?page=${n}`;
290
291 if (page) {
292 const n = Math.max(1, Math.floor(Number(page)) || 1);
293 const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
294 return {
295 '@context': AP_CONTEXT,
296 id: url(n),
297 type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
298 partOf: id,
299 totalItems: telling,
300 ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
301 ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
302 ...(n > 1 ? { prev: url(n - 1) } : {}),
303 ...(n < paginas ? { next: url(n + 1) } : {}),
304 ...(ongeordend ? { items: deel } : { orderedItems: deel }),
305 };
306 }
307 return {
308 '@context': AP_CONTEXT,
309 id,
310 type: ongeordend ? 'Collection' : 'OrderedCollection',
311 ...extra,
312 totalItems: telling,
313 first: url(1),
314 last: url(paginas),
315 ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
316 };
317}
318
319/** Is dit een MBID? Een UUID, en niets anders. */
320export function isMbid(s) {
321 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());
322}
323
324/** De publieke pagina van een artiest, of null als het geen MBID is. */
325export function artiestUrl(mbid) {
326 return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
327}
Note: See TracBrowser for help on using the repository browser.