Changeset f2796d3 in Klonkt


Ignore:
Timestamp:
06/29/2026 10:19:54 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
3f9f71d
Parents:
fb6819d
Message:

fix(federation): AP conformance round — actor enrichment, following collection, webfinger, unfollow fix

Audited the AP surface against the W3C spec + fediverse pioneers (Mastodon/Hubzilla/
Friendica/WordPress-ActivityPub/PeerTube). Additive enrichment toward non-Mastodon
receivers + one real correctness fix; the proven Mastodon happy-path (actor core, Note,
Create, Delete, Update) is left untouched.

  • src/services/ActivityPubService.js — buildActor now emits following, published (site created date) and attachment PropertyValue rows from profile_links (rel=me, HTML-escaped) so profile metadata federates; new buildFollowing (count-only collection); unfollowActor now sends Undo(Follow) with the stored real follow id via the retry queue (the old ${me}#follow fallback never matched, so unfollow silently failed on the remote), and skips the network Undo for legacy rows with no stored follow id
  • src/routes/activitypub.js — WebFinger adds aliases + a profile-page link; new GET /ap/users/:slug/following (count only); NodeInfo users.total now counts public sites (AP actors) instead of the users/account-rows table

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

Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    rfb6819d rf2796d3  
    4646  res.type('application/jrd+json; charset=utf-8');
    4747  res.set('Cache-Control', 'public, max-age=300');
     48  const actorUri = AP.actorId(baseUrl(req), site.slug);
     49  const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
    4850  res.send(JSON.stringify({
    4951    subject: `acct:${site.slug}@${hostOf(req)}`,
    50     links: [{ rel: 'self', type: 'application/activity+json', href: AP.actorId(baseUrl(req), site.slug) }],
     52    aliases: [actorUri, profileUrl],
     53    links: [
     54      { rel: 'self', type: 'application/activity+json', href: actorUri },
     55      { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
     56    ],
    5157  }));
    5258});
     
    8389  const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
    8490  AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
     91});
     92
     93// ── Following (count only) ────────────────────────────────────────
     94router.get('/ap/users/:slug/following', (req, res) => {
     95  const site = publicSite(req.params.slug);
     96  if (!site) return res.status(404).end();
     97  let n = 0;
     98  try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
     99  AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
    85100});
    86101
     
    151166router.get('/nodeinfo/2.1', (req, res) => {
    152167  let users = 0; let posts = 0;
    153   try { users = db.prepare('SELECT COUNT(*) c FROM users').get().c; } catch { /* */ }
     168  // "users" = public AP actors (sites), not the admin/member account rows.
     169  try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
    154170  try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
    155171  res.type('application/json; charset=utf-8');
  • src/services/ActivityPubService.js

    rfb6819d rf2796d3  
    137137    outbox: `${id}/outbox`,
    138138    followers: `${id}/followers`,
     139    following: `${id}/following`,
    139140    featured: `${id}/featured`,
    140141    endpoints: { sharedInbox: `${base}/ap/inbox` },
     
    149150    actor.icon = { type: 'Image', url: u };
    150151  }
     152  // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
     153  if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
     154  // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
     155  // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
     156  try {
     157    const links = JSON.parse(site.profile_links || '[]');
     158    if (Array.isArray(links) && links.length) {
     159      const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     160      const rows = links
     161        .filter((l) => l && l.url && /^https?:/i.test(l.url))
     162        .map((l) => ({
     163          type: 'PropertyValue',
     164          name: esc(l.platform || 'Link'),
     165          value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
     166        }));
     167      if (rows.length) actor.attachment = rows;
     168    }
     169  } catch { /* skip malformed profile_links */ }
    151170  return actor;
    152171}
     
    340359}
    341360
     361// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
     362// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
     363export function buildFollowing(base, site, count) {
     364  const id = `${actorId(base, site.slug)}/following`;
     365  return {
     366    '@context': 'https://www.w3.org/ns/activitystreams',
     367    id,
     368    type: 'OrderedCollection',
     369    totalItems: count || 0,
     370    orderedItems: [], // count only
     371  };
     372}
     373
    342374// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
    343375// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
     
    14831515  const keys = getOrCreateKeys(site.slug);
    14841516  const row = fwStmts().one.get(site.slug, actorUri);
    1485   if (row && row.inbox) {
    1486     const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#unfollow-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id || `${me}#follow`, type: 'Follow', actor: me, object: actorUri } };
    1487     try { await deliver(row.inbox, undo, `${me}#main-key`, keys.private_pem); } catch { /* best-effort */ }
     1517  // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
     1518  // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
     1519  // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
     1520  // rather than send an unmatchable one. Deliver durably via the retry queue.
     1521  if (row && row.inbox && row.follow_id) {
     1522    const undo = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
     1523    deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
     1524  } else if (row && row.inbox) {
     1525    console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
    14881526  }
    14891527  fwStmts().del.run(site.slug, actorUri);
     
    16211659export default {
    16221660  getOrCreateKeys, apWants, sendAP, actorId, noteId,
    1623   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFeatured,
     1661  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    16241662  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    16251663  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
Note: See TracChangeset for help on using the changeset viewer.