| 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 | }
|
|---|
| 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 | }
|
|---|
| 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.
|
|---|
| 153 | */
|
|---|
| 154 | export function buildHashtagList(base, tagsField, content) {
|
|---|
| 155 | const out = [], seen = new Set();
|
|---|
| 156 | for (const h of hashtagTags(base, content)) {
|
|---|
| 157 | const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
|
|---|
| 158 | out.push(h);
|
|---|
| 159 | }
|
|---|
| 160 | for (const t of normalizeTags(tagsField)) {
|
|---|
| 161 | const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
|
|---|
| 162 | out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
|
|---|
| 163 | }
|
|---|
| 164 | return out;
|
|---|
| 165 | }
|
|---|