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

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

Stap 3: de koppeling gaat de draad op als schema:sameAs (shaer-mbz)

Alleen als de artiest ZELF gekozen heeft. Staat er niets, dan staat er niets --
een lege of geraden verwijzing is erger dan geen.

WAAROM sameAs EN NIET alsoKnownAs, en dat is geen smaakkwestie. alsoKnownAs is
in AS2 gereserveerd voor vroegere IDENTITEITEN van dezelfde actor, en FEP-7628
leunt erop bij een verhuizing: het oude adres controleert of het nieuwe hem daar
noemt voordat het volgers meegeeft. Een MBID daartussen zetten zou een
verhuizing kunnen laten mislukken. schema:sameAs zegt precies het goede: dit
ding is ook bekend onder die URI.

Geen nieuwe namespace: schema stond al in onze context, naast license, position
en bitrate. Dit is er een termregel bij.

EEN KRINGLOOP VERMEDEN. MusicBrainzService leent safeFetch uit
ActivityPubService, dus die er andersom uit laten importeren zou een cirkel
maken. isMbid en artiestUrl zijn pure vormcontroles zonder afhankelijkheden en
wonen nu in ap-core -- dezelfde plek en dezelfde reden als de tagparsers van
gisteren. MusicBrainzService exporteert ze her, zodat een aanroeper er niet over
hoeft na te denken.

DE ACTOR IS DE LAATSTE DEUR. Het scherm zeeft al op een echte MBID, maar
artiestUrl doet het nog een keer: wat hier langskomt gaat naar iedereen, en een
half adres is erger dan geen. Een test zet met opzet rommel in de kolom en eist
dat er niets naar buiten gaat.

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

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