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

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

Een Library-skelet, want zonder bak blijft een track een naam zonder geluid

GEMETEN, en dat gaf dit zijn richting. open.audio heeft onze vier tracks
binnengehaald langs de AP-weg -- met ONZE track-id's als fid, en met een
artist_credit dat Funkwhale zelf uit onze attributedTo afleidde. Precies het
object waarvan ons gevraagd werd het te sturen; dat hoefde dus niet, hij leidt
het af. Maar uploads is leeg en is_playable false.

Het audiobestand is niet het probleem: /audio/stream geeft 200, audio/mpeg,
2722880 bytes, ook anoniem en met een vreemde User-Agent. Ze hebben het niet
opgehaald. Bij Funkwhale hangt een upload aan een LIBRARY, en die hadden we niet.

DE VORM IS DIE UIT HUN DOCS: type Library, id, name, followers, totalItems,
first, last, plus attributedTo en summary. Onze pagedCollection deed het meeste
al; wat erbij komt is de naam, de volgers en het type.

DE ENIGE TERM DIE WE UIT HUN VOCABULAIRE OVERNEMEN, en het verschil met
track/ArtistCredit is de reden dat dit wel mag: die twee vragen ENTITEITEN waar
wij tekst hebben -- een artiest en een album zijn bij ons kolommen, en er een id
voor verzinnen zou beloven wat we niet kunnen waarmaken. Een library is precies
wat er al staat: onze open tracks, met een echte telling en een echt id. Er valt
niets te verzinnen.

SKELET, letterlijk. Er is GEEN volg-afhandeling. Onze bibliotheek is openbaar --
alles erin heeft fedi_open -- dus er valt niets goed te keuren en de
volgerslijst is leeg en eerlijk. Komt er ooit een besloten variant, dan hoort
daar het Follow/Accept-werk bij en dat is een eigen stuk.

Een test bewaakt dat een GESLOTEN track er niet in komt. De bak is openbaar, en
dat kan alleen als er niets in zit dat het niet is -- anders overrulet de
openbaarheid van de bak de poort van de track.

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

  • Property mode set to 100644
File size: 15.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 */
268export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, extra = {} } = {}) {
269 const lijst = items || [];
270 const telling = totalItems === undefined ? lijst.length : totalItems;
271 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
272 // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
273 // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
274 // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
275 // en nooit een `next` aanbieden.
276 const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
277 const url = (n) => `${id}?page=${n}`;
278
279 if (page) {
280 const n = Math.max(1, Math.floor(Number(page)) || 1);
281 const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
282 return {
283 '@context': AP_CONTEXT,
284 id: url(n),
285 type: 'OrderedCollectionPage',
286 partOf: id,
287 totalItems: telling,
288 ...(n > 1 ? { prev: url(n - 1) } : {}),
289 ...(n < paginas ? { next: url(n + 1) } : {}),
290 orderedItems: deel,
291 };
292 }
293 return {
294 '@context': AP_CONTEXT,
295 id,
296 type: 'OrderedCollection',
297 ...extra,
298 totalItems: telling,
299 first: url(1),
300 last: url(paginas),
301 orderedItems: lijst,
302 };
303}
304
305/** Is dit een MBID? Een UUID, en niets anders. */
306export function isMbid(s) {
307 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());
308}
309
310/** De publieke pagina van een artiest, of null als het geen MBID is. */
311export function artiestUrl(mbid) {
312 return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
313}
Note: See TracBrowser for help on using the repository browser.