| [8179fdb] | 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 | position: 'schema:position',
|
|---|
| 53 | bitrate: 'schema:bitrate',
|
|---|
| 54 | size: 'schema:contentSize',
|
|---|
| 55 | // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
|
|---|
| 56 | // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
|
|---|
| 57 | // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
|
|---|
| 58 | votersCount: 'toot:votersCount',
|
|---|
| 59 | // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
|
|---|
| 60 | // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
|
|---|
| 61 | // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
|
|---|
| 62 | // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
|
|---|
| 63 | // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
|
|---|
| 64 | // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
|
|---|
| 65 | // antwoordt.
|
|---|
| 66 | category: { '@id': 'https://funkwhale.audio/ns#category' },
|
|---|
| 67 | // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
|
|---|
| 68 | // module (src/services/guardianship/).
|
|---|
| 69 | ...Guardianship.SHAER_CONTEXT,
|
|---|
| 70 | },
|
|---|
| 71 | ];
|
|---|
| 72 |
|
|---|
| 73 | /** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
|
|---|
| 74 | * bruikbare URL vinden. */
|
|---|
| 75 | export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
|
|---|
| 76 |
|
|---|
| 77 | export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
|
|---|
| 78 | export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
|
|---|
| 79 |
|
|---|
| 80 | /**
|
|---|
| 81 | * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
|
|---|
| 82 | * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
|
|---|
| 83 | * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
|
|---|
| 84 | * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
|
|---|
| 85 | * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
|
|---|
| 86 | */
|
|---|
| 87 | export function guessMediaType(u) {
|
|---|
| 88 | const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
|
|---|
| 89 | return ({
|
|---|
| 90 | jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|---|
| 91 | webp: 'image/webp', avif: 'image/avif',
|
|---|
| 92 | mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|---|
| 93 | })[(e || '').toLowerCase()] || 'image/jpeg';
|
|---|
| 94 | }
|
|---|
| [0677bac] | 95 |
|
|---|
| 96 | /**
|
|---|
| 97 | * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
|
|---|
| 98 | * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
|
|---|
| 99 | * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
|
|---|
| 100 | * terugval voor wat er handmatig is ingevuld.
|
|---|
| 101 | */
|
|---|
| 102 | export function normalizeTags(t) {
|
|---|
| 103 | if (Array.isArray(t)) return t;
|
|---|
| 104 | if (typeof t === 'string') {
|
|---|
| 105 | const s = t.trim(); if (!s) return [];
|
|---|
| 106 | if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
|
|---|
| 107 | return s.split(',').map((x) => x.trim()).filter(Boolean);
|
|---|
| 108 | }
|
|---|
| 109 | return [];
|
|---|
| 110 | }
|
|---|
| 111 |
|
|---|
| 112 | /**
|
|---|
| 113 | * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
|
|---|
| 114 | * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
|
|---|
| 115 | * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
|
|---|
| 116 | * blijven kleingeschreven ("livemusic").
|
|---|
| 117 | */
|
|---|
| 118 | export function tagParts(raw) {
|
|---|
| 119 | const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
|
|---|
| 120 | if (!words.length) return null;
|
|---|
| 121 | const slug = words.join('').toLowerCase();
|
|---|
| 122 | if (!slug) return null;
|
|---|
| 123 | const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
|
|---|
| 124 | return { label, slug };
|
|---|
| 125 | }
|
|---|
| [ea3e722] | 126 |
|
|---|
| 127 | /**
|
|---|
| 128 | * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
|
|---|
| 129 | * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
|
|---|
| 130 | * naam niet: #DoenweNiet blijft #DoenweNiet.
|
|---|
| 131 | */
|
|---|
| 132 | export function hashtagTags(base, content) {
|
|---|
| 133 | const tags = [], seen = new Set();
|
|---|
| 134 | const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
|
|---|
| 135 | let m;
|
|---|
| 136 | while ((m = re.exec(content || ''))) {
|
|---|
| 137 | const k = m[1].toLowerCase();
|
|---|
| 138 | if (seen.has(k)) continue; seen.add(k);
|
|---|
| 139 | tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
|
|---|
| 140 | }
|
|---|
| 141 | return tags;
|
|---|
| 142 | }
|
|---|
| 143 |
|
|---|
| 144 | /**
|
|---|
| 145 | * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
|
|---|
| 146 | *
|
|---|
| 147 | * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
|
|---|
| 148 | * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
|
|---|
| 149 | * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
|
|---|
| 150 | * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
|
|---|
| 151 | * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
|
|---|
| 152 | * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
|
|---|
| [5ae4d61] | 153 | *
|
|---|
| 154 | * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
|
|---|
| 155 | * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
|
|---|
| 156 | * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
|
|---|
| 157 | * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
|
|---|
| 158 | * tagveld, precies de vorm die hier juist niet moest winnen.
|
|---|
| [ea3e722] | 159 | */
|
|---|
| [5ae4d61] | 160 | export function hashtagTagsRuw(base, content) {
|
|---|
| 161 | const tags = [], seen = new Set();
|
|---|
| 162 | // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
|
|---|
| 163 | // tag, en die zou hier anders als hashtag de deur uit gaan.
|
|---|
| 164 | const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
|
|---|
| 165 | let m;
|
|---|
| 166 | while ((m = re.exec(content || ''))) {
|
|---|
| 167 | const k = m[2].toLowerCase();
|
|---|
| 168 | if (seen.has(k)) continue; seen.add(k);
|
|---|
| 169 | tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
|
|---|
| 170 | }
|
|---|
| 171 | return tags;
|
|---|
| 172 | }
|
|---|
| 173 |
|
|---|
| 174 | export function buildHashtagList(base, tagsField, content, opts = {}) {
|
|---|
| [ea3e722] | 175 | const out = [], seen = new Set();
|
|---|
| [5ae4d61] | 176 | const uitLijf = opts.ruw
|
|---|
| 177 | ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
|
|---|
| 178 | : hashtagTags(base, content);
|
|---|
| 179 | for (const h of uitLijf) {
|
|---|
| [ea3e722] | 180 | const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
|
|---|
| 181 | out.push(h);
|
|---|
| 182 | }
|
|---|
| 183 | for (const t of normalizeTags(tagsField)) {
|
|---|
| 184 | const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
|
|---|
| 185 | out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
|
|---|
| 186 | }
|
|---|
| 187 | return out;
|
|---|
| 188 | }
|
|---|
| [aed0092] | 189 |
|
|---|
| 190 | /**
|
|---|
| 191 | * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
|
|---|
| 192 | *
|
|---|
| 193 | * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
|
|---|
| 194 | * "first: This field is required" en "last: This field is required" -- de eerste
|
|---|
| 195 | * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
|
|---|
| 196 | * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
|
|---|
| 197 | * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
|
|---|
| 198 | * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
|
|---|
| 199 | * opnieuw vergeet.
|
|---|
| 200 | *
|
|---|
| 201 | * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
|
|---|
| 202 | * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
|
|---|
| 203 | * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
|
|---|
| 204 | * naar dezelfde.
|
|---|
| 205 | *
|
|---|
| 206 | * @param {string} id de collectie-uri, zonder query
|
|---|
| 207 | * @param {Array} items wat erin zit (mag leeg)
|
|---|
| 208 | * @param {object} opts
|
|---|
| 209 | * totalItems als de telling niet items.length is (followers geeft publiek
|
|---|
| 210 | * alleen een AANTAL en houdt de lijst dicht)
|
|---|
| 211 | * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
|
|---|
| 212 | * extra velden die op de wortel horen (attributedTo, shaer:*)
|
|---|
| 213 | */
|
|---|
| 214 | export function pagedCollection(id, items, { totalItems, page = false, extra = {} } = {}) {
|
|---|
| 215 | const lijst = items || [];
|
|---|
| 216 | const telling = totalItems === undefined ? lijst.length : totalItems;
|
|---|
| 217 | const eerste = `${id}?page=1`;
|
|---|
| 218 | if (page) {
|
|---|
| 219 | return {
|
|---|
| 220 | '@context': AP_CONTEXT,
|
|---|
| 221 | id: eerste,
|
|---|
| 222 | type: 'OrderedCollectionPage',
|
|---|
| 223 | partOf: id,
|
|---|
| 224 | totalItems: telling,
|
|---|
| 225 | orderedItems: lijst,
|
|---|
| 226 | };
|
|---|
| 227 | }
|
|---|
| 228 | return {
|
|---|
| 229 | '@context': AP_CONTEXT,
|
|---|
| 230 | id,
|
|---|
| 231 | type: 'OrderedCollection',
|
|---|
| 232 | ...extra,
|
|---|
| 233 | totalItems: telling,
|
|---|
| 234 | first: eerste,
|
|---|
| 235 | last: eerste,
|
|---|
| 236 | orderedItems: lijst,
|
|---|
| 237 | };
|
|---|
| 238 | }
|
|---|