Changeset 914eb9f in Klonkt
- Timestamp:
- 06/24/2026 03:19:11 PM (3 months ago)
- Branches:
- main
- Children:
- d988fa0
- Parents:
- 0dba092
- Location:
- src
- Files:
-
- 1 added
- 5 edited
-
config/database.js (modified) (1 diff)
-
routes/posts.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (4 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/admin.ejs (modified) (1 diff)
-
views/pages/timeline.ejs (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r0dba092 r914eb9f 356 356 `); 357 357 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs) 358 359 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts. 360 db.exec(` 361 CREATE TABLE IF NOT EXISTS ap_following ( 362 id INTEGER PRIMARY KEY AUTOINCREMENT, 363 slug TEXT NOT NULL, -- our site that follows 364 actor_uri TEXT NOT NULL, -- the followed account's actor id 365 handle TEXT, name TEXT, icon TEXT, url TEXT, 366 inbox TEXT, -- their inbox (for Create delivery / Undo) 367 follow_id TEXT, -- the Follow activity id we sent (Accept matching) 368 status TEXT DEFAULT 'pending', -- pending | accepted 369 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 370 UNIQUE(slug, actor_uri) 371 ); 372 CREATE TABLE IF NOT EXISTS ap_timeline ( 373 id TEXT NOT NULL, -- the remote note's AP id 374 slug TEXT NOT NULL, -- whose home timeline (our site) 375 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT, 376 content TEXT, url TEXT, published TEXT, media_json TEXT, 377 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 378 UNIQUE(slug, id) 379 ); 380 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published); 381 `); 358 382 } 359 383 -
src/routes/posts.js
r0dba092 r914eb9f 86 86 'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml', 87 87 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets', 88 'authorize_interaction', 'fediverse', 88 'authorize_interaction', 'fediverse', 'tijdlijn', 89 89 ]); 90 90 … … 561 561 } 562 562 res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`); 563 }); 564 565 // ==================== FEDIVERSE CLIENT: home timeline + following ==================== 566 router.get('/tijdlijn', requireSiteManager, (req, res) => { 567 const site = res.locals.site; 568 const following = site ? ActivityPubService.listFollowing(site.slug) : []; 569 const timeline = site ? ActivityPubService.getTimeline(site.slug, 60) : []; 570 renderPage(req, res, 'pages/timeline', { 571 pageTitle: 'Tijdlijn', bodyClass: 'on-special', 572 following, timeline, 573 success: req.query.success || null, error: req.query.error || null, 574 }); 575 }); 576 577 router.post('/tijdlijn/follow', requireSiteManager, async (req, res) => { 578 const site = res.locals.site; 579 const handle = (req.body.handle || '').toString(); 580 let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd'); 581 if (site && handle.trim()) { 582 try { 583 const r = await ActivityPubService.followActor(site, handle); 584 if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt')); 585 else q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle)); 586 } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); } 587 } 588 res.redirect('/tijdlijn?' + q); 589 }); 590 591 router.post('/tijdlijn/unfollow', requireSiteManager, async (req, res) => { 592 const site = res.locals.site; 593 const actorUri = (req.body.actor_uri || '').toString(); 594 if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } } 595 res.redirect('/tijdlijn?success=' + encodeURIComponent('Ontvolgd')); 563 596 }); 564 597 -
src/services/ActivityPubService.js
r0dba092 r914eb9f 406 406 iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri); 407 407 console.log('[AP] reply', actorUri, '→', tgt.post_id); 408 return 202; 409 } 410 // Home timeline (client): a top-level post from an account we follow. 411 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) { 412 let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ } 413 if (subs.length) { 414 const ai = actorInfo(await resolveActor(actorUri), actorUri); 415 const html = HtmlSanitizerService.sanitize(o.content || ''); 416 const media = JSON.stringify((Array.isArray(o.attachment) ? o.attachment : []).filter((a) => a && a.url).map((a) => ({ url: a.url, type: a.mediaType || '' }))); 417 for (const s of subs) tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media); 418 console.log('[AP] timeline +', actorUri, 'x' + subs.length); 419 } 408 420 } 409 421 return 202; … … 420 432 } 421 433 if (type === 'Delete') { 422 // A remote reply was deleted upstream → drop it if we stored it.434 // A remote note was deleted upstream → drop it from replies AND the timeline. 423 435 const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id); 424 if (oid) iStmts().delReply.run(oid); 436 if (oid) { iStmts().delReply.run(oid); try { tlStmts().del.run(oid); } catch { /* ignore */ } } 437 return 202; 438 } 439 // Accept/Reject of a Follow WE sent (client side). 440 if (type === 'Accept' && act.object) { 441 const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id); 442 if (fid) { try { fwStmts().acc.run(fid); } catch { /* ignore */ } } 443 console.log('[AP] follow accepted', actorUri); 444 return 202; 445 } 446 if (type === 'Reject' && act.object) { 447 const who = actorUri; 448 if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } } 425 449 return 202; 426 450 } … … 616 640 } 617 641 642 // ── Fediverse CLIENT: follow accounts + home timeline ───────────── 643 // Resolve an @user@domain handle to its actor URL via WebFinger. 644 export async function webfingerResolve(handle) { 645 const h = String(handle || '').trim().replace(/^@/, ''); 646 const parts = h.split('@'); 647 if (parts.length !== 2 || !parts[0] || !parts[1]) return null; 648 const acct = `${parts[0]}@${parts[1]}`; 649 try { 650 const r = await fetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`, 651 { headers: { Accept: 'application/jrd+json, application/json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) }); 652 if (!r.ok) return null; 653 const jrd = await r.json(); 654 const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || '')); 655 return link ? link.href : null; 656 } catch { return null; } 657 } 658 659 let _insFw, _delFw, _listFw, _accFw, _oneFw; 660 function fwStmts() { 661 if (!_insFw) { 662 _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, created_at) VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)'); 663 _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?'); 664 _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC'); 665 _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?"); 666 _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?'); 667 } 668 return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, one: _oneFw }; 669 } 670 export function listFollowing(slug) { return fwStmts().list.all(slug); } 671 672 let _insTl, _listTl, _delTl; 673 function tlStmts() { 674 if (!_insTl) { 675 _insTl = db.prepare('INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)'); 676 _listTl = db.prepare('SELECT * FROM ap_timeline WHERE slug = ? ORDER BY COALESCE(published, created_at) DESC LIMIT ?'); 677 _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?'); 678 } 679 return { ins: _insTl, list: _listTl, del: _delTl }; 680 } 681 export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); } 682 683 // Follow a fediverse account by @handle (WebFinger → actor → signed Follow). 684 export async function followActor(site, handle) { 685 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 686 if (!base || !site || !site.slug) return { error: 'config' }; 687 const actorUrl = await webfingerResolve(handle); 688 if (!actorUrl) return { error: 'not_found' }; 689 const actor = await fetchActor(actorUrl).catch(() => null); 690 if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' }; 691 const ai = actorInfo(actor, actor.id); 692 const me = actorId(base, site.slug); 693 const keys = getOrCreateKeys(site.slug); 694 const followId = `${me}#follow-${Date.now()}`; 695 fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending'); 696 const follow = { '@context': 'https://www.w3.org/ns/activitystreams', id: followId, type: 'Follow', actor: me, object: actor.id }; 697 try { await deliver(actor.inbox, follow, `${me}#main-key`, keys.private_pem); } 698 catch (e) { console.warn('[AP] follow deliver failed:', e.message); } 699 console.log('[AP] follow', site.slug, '→', actor.id); 700 return { ok: true, name: ai.name, handle: ai.handle }; 701 } 702 703 export async function unfollowActor(site, actorUri) { 704 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 705 const me = actorId(base, site.slug); 706 const keys = getOrCreateKeys(site.slug); 707 const row = fwStmts().one.get(site.slug, actorUri); 708 if (row && row.inbox) { 709 const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } }; 710 try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ } 711 } 712 fwStmts().del.run(site.slug, actorUri); 713 return { ok: true }; 714 } 715 618 716 export default { 619 717 getOrCreateKeys, apWants, sendAP, actorId, noteId, … … 622 720 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 623 721 listOutbox, deliverOutboxDelete, 722 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, 624 723 }; -
src/services/i18n.js
r0dba092 r914eb9f 109 109 'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse', 110 110 'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij', 111 'fedi.remote_title': 'Reageer via de fediverse', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres (bv. @jij@mastodon.social):', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.remote_ph': '@naam@server.social', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is onderweg naar de fediverse en verschijnt zo bij de ontvanger.', 'fedi.remote_back': '← Terug naar je site', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 111 'fedi.remote_title': 'Reageer via de fediverse', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres (bv. @jij@mastodon.social):', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.remote_ph': '@naam@server.social', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is onderweg naar de fediverse en verschijnt zo bij de ontvanger.', 'fedi.remote_back': '← Terug naar je site', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'tl.title': 'Tijdlijn', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.pending': 'in afwachting', 'tl.feed': 'Berichten', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 112 112 'comments.to_start': 'om de conversatie te starten.', 113 113 'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren', … … 1105 1105 'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse', 1106 1106 'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You', 1107 'fedi.remote_title': 'Reply via the fediverse', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address (e.g. @you@mastodon.social):', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.remote_ph': '@name@server.social', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply is on its way to the fediverse and will appear for the recipient shortly.', 'fedi.remote_back': '← Back to your site', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 1107 'fedi.remote_title': 'Reply via the fediverse', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address (e.g. @you@mastodon.social):', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.remote_ph': '@name@server.social', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply is on its way to the fediverse and will appear for the recipient shortly.', 'fedi.remote_back': '← Back to your site', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'tl.title': 'Timeline', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.pending': 'pending', 'tl.feed': 'Posts', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 1108 1108 'comments.to_start': 'to start the conversation.', 1109 1109 'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel', … … 2099 2099 'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse', 2100 2100 'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du', 2101 'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse (z.B. @du@mastodon.social):', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.remote_ph': '@name@server.social', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort ist auf dem Weg ins Fediverse und erscheint gleich beim Empfänger.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 2101 'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse (z.B. @du@mastodon.social):', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.remote_ph': '@name@server.social', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort ist auf dem Weg ins Fediverse und erscheint gleich beim Empfänger.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'tl.title': 'Timeline', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.pending': 'ausstehend', 'tl.feed': 'Beiträge', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 2102 2102 'comments.to_start': 'um das Gespräch zu starten.', 2103 2103 'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen', -
src/views/pages/admin.ejs
r0dba092 r914eb9f 57 57 <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a> 58 58 <% } %> 59 <a href="/tijdlijn" class="btn">🌐 <%= t('tl.title') %></a> 59 60 <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a> 60 61 <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)