Changeset 914eb9f in Klonkt for src/services


Ignore:
Timestamp:
06/24/2026 03:19:11 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
d988fa0
Parents:
0dba092
Message:

feat(fediverse-client): follow accounts + home timeline

Klonkt can now follow fediverse accounts (WebFinger -> signed Follow; Accept/Reject
handled) and shows their posts in a home timeline at /tijdlijn (owner-only).
New ap_following + ap_timeline tables; inbox stores top-level posts from followed
accounts + removes them on upstream Delete. Beheer link added.

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

Location:
src/services
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r0dba092 r914eb9f  
    406406      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);
    407407      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      }
    408420    }
    409421    return 202;
     
    420432  }
    421433  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.
    423435    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 */ } }
    425449    return 202;
    426450  }
     
    616640}
    617641
     642// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
     643// Resolve an @user@domain handle to its actor URL via WebFinger.
     644export 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
     659let _insFw, _delFw, _listFw, _accFw, _oneFw;
     660function 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}
     670export function listFollowing(slug) { return fwStmts().list.all(slug); }
     671
     672let _insTl, _listTl, _delTl;
     673function 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}
     681export function getTimeline(slug, limit) { return tlStmts().list.all(slug, limit || 50); }
     682
     683// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
     684export 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
     703export 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
    618716export default {
    619717  getOrCreateKeys, apWants, sendAP, actorId, noteId,
     
    622720  getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    623721  listOutbox, deliverOutboxDelete,
     722  webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline,
    624723};
  • src/services/i18n.js

    r0dba092 r914eb9f  
    109109    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    110110    '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 →',
    112112    'comments.to_start': 'om de conversatie te starten.',
    113113    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    11051105    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    11061106    '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 →',
    11081108    'comments.to_start': 'to start the conversation.',
    11091109    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    20992099    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    21002100    '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 →',
    21022102    'comments.to_start': 'um das Gespräch zu starten.',
    21032103    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
Note: See TracChangeset for help on using the changeset viewer.