Ignore:
Timestamp:
06/24/2026 11:44:23 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
47a0d29
Parents:
eb852c5
Message:

feat(activitypub): inbound interactions — replies, likes, boosts (Phase 2)

Inbox now stores incoming Create(reply to our note), Like and Announce (boost),
plus Undo(Like/Announce) and Delete(reply). New ap_interactions table; the post
page shows a 'From the fediverse' section (counts + replies with avatar/handle,
sanitized). Actor name/icon resolved best-effort; reply HTML sanitized.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    reb852c5 rc16e0a5  
    1818import crypto from 'crypto';
    1919import db from '../config/database.js';
     20import HtmlSanitizerService from './HtmlSanitizerService.js';
    2021
    2122const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     
    190191export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
    191192
     193// ── inbound interactions store (replies / likes / boosts), lazy stmts ──
     194let _insI, _delLA, _delReply, _listI;
     195function iStmts() {
     196  if (!_insI) {
     197    _insI = db.prepare('INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     198    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
     199    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
     200    _listI = db.prepare('SELECT kind, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     201  }
     202  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI };
     203}
     204
     205const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
     206// Extract our local post id from a note URL, but only if it's ours (base match).
     207function postIdFromNoteUrl(url, base) {
     208  const s = String(url || '');
     209  if (base && !s.startsWith(base)) return null;
     210  const m = s.match(/\/ap\/notes\/([^/?#]+)/);
     211  return m ? decodeURIComponent(m[1]) : null;
     212}
     213function deriveHandle(actorUri) {
     214  try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
     215}
     216function actorInfo(doc, actorUri) {
     217  let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
     218  const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
     219  const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
     220  return {
     221    name: (doc && (doc.name || doc.preferredUsername)) || handle,
     222    handle,
     223    url: (doc && (doc.url || doc.id)) || actorUri,
     224    icon: icon || null,
     225  };
     226}
     227
     228// Stored, view-ready summary of a post's inbound fediverse activity.
     229export function getInteractions(postId) {
     230  const rows = iStmts().list.all(postId);
     231  return {
     232    replies: rows.filter((r) => r.kind === 'reply'),
     233    likeCount: rows.filter((r) => r.kind === 'like').length,
     234    announceCount: rows.filter((r) => r.kind === 'announce').length,
     235    total: rows.length,
     236  };
     237}
     238
    192239// ── HTTP Signatures + delivery ────────────────────────────────────
    193240const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
     
    263310    return 202;
    264311  }
    265   if (type === 'Undo' && act.object && act.object.type === 'Follow') {
     312  if (type === 'Undo' && act.object) {
    266313    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
    267     const obj = act.object.object;
    268     const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
    269     if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
     314    const ot = act.object.type;
     315    if (ot === 'Follow') {
     316      const obj = act.object.object;
     317      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
     318      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
     319      return 202;
     320    }
     321    if (ot === 'Like' || ot === 'Announce') {
     322      const tgt = act.object.object;
     323      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
     324      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
     325      return 202;
     326    }
    270327    return 202;
    271328  }
     329
     330  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     331  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
     332
     333  // Inbound reply: a Create whose object replies to one of our notes.
     334  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article')) {
     335    const o = act.object;
     336    const pid = postIdFromNoteUrl(o.inReplyTo, base);
     337    if (pid && actorUri && localPostExists(pid)) {
     338      const ai = actorInfo(await resolveActor(actorUri), actorUri);
     339      const html = HtmlSanitizerService.sanitize(o.content || '');
     340      iStmts().ins.run('reply', pid, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null);
     341      console.log('[AP] reply', actorUri, '→', pid);
     342    }
     343    return 202;
     344  }
     345  if (type === 'Like' || type === 'Announce') {
     346    const tgt = act.object;
     347    const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
     348    if (pid && actorUri && localPostExists(pid)) {
     349      const ai = actorInfo(await resolveActor(actorUri), actorUri);
     350      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null);
     351      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
     352    }
     353    return 202;
     354  }
     355  if (type === 'Delete') {
     356    // A remote reply was deleted upstream → drop it if we stored it.
     357    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
     358    if (oid) iStmts().delReply.run(oid);
     359    return 202;
     360  }
     361
    272362  console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
    273363  return 202;
     
    313403  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
    314404  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
     405  getInteractions,
    315406};
Note: See TracChangeset for help on using the changeset viewer.