| 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 |
|
|---|
| 22 | import * as Guardianship from './guardianship/index.js';
|
|---|
| 23 |
|
|---|
| 24 | export 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.
|
|---|
| 29 | export 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. */
|
|---|
| 93 | export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
|
|---|
| 94 |
|
|---|
| 95 | export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
|
|---|
| 96 | export 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 | */
|
|---|
| 105 | export 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 | */
|
|---|
| 120 | export 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 | */
|
|---|
| 136 | export 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 | */
|
|---|
| 150 | export 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 | */
|
|---|
| 178 | export 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 |
|
|---|
| 192 | export 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. */
|
|---|
| 233 | export 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 | */
|
|---|
| 252 | export 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. */
|
|---|
| 290 | export 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. */
|
|---|
| 295 | export function artiestUrl(mbid) {
|
|---|
| 296 | return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
|
|---|
| 297 | }
|
|---|