Ignore:
Timestamp:
07/17/2026 12:38:54 AM (8 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
92a2c46
Parents:
3778ddb
git-author:
Robin <roboburr@…> (07/17/2026 12:38:34 AM)
git-committer:
Robin <roboburr@…> (07/17/2026 12:38:54 AM)
Message:

Feature: owner moderation of incoming replies (remove + tombstone + report)

The site owner could not remove an incoming reply, and thread-filling would
re-fetch a locally deleted one (crawlThread seeds its dedup set from
ap_interactions). New ap_rejected_objects table: rejectInteraction() (tenancy-
scoped to the owner's site) deletes the row and tombstones the object URI;
handleInbox and crawlThread skip tombstoned URIs, so a removed reply never
returns via re-delivery or thread-filling. A report action feeds sendReport
from the locally stored object/actor URIs, so flagging also works for private
notes that authorize_interaction cannot fetch (401/404). Thread nodes get
owner-only report + remove buttons (confirm via the global data-confirm
handler); i18n NL/EN/DE. Covered by test/reply-moderation.test.js (59 tests
green); full flow verified in a browser: buttons render for the logged-in
owner, remove deletes the reply and writes the tombstone. Beads: klonkt-demo-qul.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r3778ddb r67c1f24  
    596596// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
    597597let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
     598// ── moderation tombstones (ap_rejected_objects) ───────────────────
     599// A reply the owner removed stays removed: its object URI is tombstoned and
     600// checked at ingest AND by the thread-crawler (else thread-filling would
     601// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
     602// private notes that authorize_interaction can't fetch (401/404).
     603let _insRj, _hasRj;
     604function rjStmts() {
     605  if (!_insRj) {
     606    _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
     607    _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
     608  }
     609  return { ins: _insRj, has: _hasRj };
     610}
     611export function isRejectedObject(uri) {
     612  if (!uri) return false;
     613  try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
     614}
     615// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
     616// interaction's post must belong to the caller's site.
     617export function rejectInteraction(site, interactionId, reason) {
     618  if (!site || !site.slug) return { error: 'forbidden' };
     619  const row = iStmts().getI.get(interactionId);
     620  if (!row) return { error: 'not_found' };
     621  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
     622    .get(row.post_id, site.slug);
     623  if (!owns) return { error: 'forbidden' };
     624  if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
     625  db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
     626  console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
     627  return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
     628}
     629// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
     630// from the local copy (works for private notes; no remote fetch needed to target).
     631export function interactionReportTarget(site, interactionId) {
     632  if (!site || !site.slug) return null;
     633  const row = iStmts().getI.get(interactionId);
     634  if (!row) return null;
     635  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
     636    .get(row.post_id, site.slug);
     637  if (!owns) return null;
     638  return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
     639}
     640
    598641// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
    599642// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
     
    11651208      const ai = actorInfo(await resolveActor(actorUri), actorUri);
    11661209      const html = HtmlSanitizerService.sanitize(o.content || '');
     1210      if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
    11671211      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, noteVisibility(o));
    11681212      console.log('[AP] reply', actorUri, '→', tgt.post_id);
     
    21312175  const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
    21322176  if (!seeds.length) return; // nothing remote to expand
     2177  // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
     2178  // crawler never re-adds them via thread-filling (they're gone from the seeds
     2179  // already because rejectInteraction deleted their ap_interactions row).
     2180  try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
     2181  catch { /* table always exists after boot migration */ }
    21332182
    21342183  let fetches = 0;
     
    21512200        const child = await budget.get(cu);
    21522201        if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
     2202        if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
    21532203        const actorUri = actorUriOf(child.attributedTo);
    21542204        if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
     
    25442594  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    25452595  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    2546   noteVisibility,
     2596  noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
    25472597};
Note: See TracChangeset for help on using the changeset viewer.