Changeset f5c3870 in Klonkt


Ignore:
Timestamp:
06/24/2026 04:08:34 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
19a72df
Parents:
d0cab9d
Message:

feat(fediverse-client): block/defederate accounts & domains

Block an @handle or a whole domain (/blokkeren + 🚫 on timeline items): their
inbound activities are silently dropped (202) and their stored content is purged.
New ap_blocks table + isBlockedAny gate in the inbox. Top-bar bell now points to
the fediverse /meldingen (gated to site managers via canManageFedi) instead of the
dead native notifications.

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

Location:
src
Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rd0cab9d rf5c3870  
    379379    );
    380380    CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
     381    CREATE TABLE IF NOT EXISTS ap_blocks (
     382      id INTEGER PRIMARY KEY AUTOINCREMENT,
     383      slug TEXT NOT NULL,          -- our site that set the block
     384      target TEXT NOT NULL,        -- actor URI (actor block) or domain (domain block)
     385      kind TEXT NOT NULL,          -- 'actor' | 'domain'
     386      label TEXT,                  -- display (@handle or domain)
     387      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     388      UNIQUE(slug, target)
     389    );
     390    CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
    381391  `);
    382392}
  • src/middleware/render.js

    rd0cab9d rf5c3870  
    100100  const _role = _u ? _u.role : null;
    101101  const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
     102  // Who may use the fediverse client (timeline/notifications/blocking) β€” actual
     103  // site managers only (these routes are requireSiteManager; viewers are excluded).
     104  const canManageFedi = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
    102105
    103106  // Interface language: session choice (this session) β†’ logged-in user's own preference
     
    118121    userOwnsSite,
    119122    canSeeBeheer,
     123    canManageFedi,
    120124    isViewer: _isViewer,
    121125    canMutate: !_isViewer,
  • src/routes/posts.js

    rd0cab9d rf5c3870  
    8686  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
    8787  'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
    88   'authorize_interaction', 'fediverse', 'tijdlijn', 'meldingen',
     88  'authorize_interaction', 'fediverse', 'tijdlijn', 'meldingen', 'blokkeren',
    8989]);
    9090
     
    613613  const items = site ? ActivityPubService.getNotifications(site.slug, 80) : [];
    614614  renderPage(req, res, 'pages/fedi-notifications', { pageTitle: 'Meldingen', bodyClass: 'on-special', items });
     615});
     616
     617// Blocking / defederation (owner-only).
     618router.get('/blokkeren', requireSiteManager, (req, res) => {
     619  const site = res.locals.site;
     620  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
     621  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
     622});
     623
     624router.post('/blokkeren/add', requireSiteManager, async (req, res) => {
     625  const site = res.locals.site;
     626  let q = 'success=' + encodeURIComponent('Geblokkeerd');
     627  if (site) {
     628    try {
     629      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
     630      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
     631      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
     632    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
     633  }
     634  const ref = req.get('Referer') || '';
     635  res.redirect((ref.includes('/tijdlijn') ? '/tijdlijn?' : '/blokkeren?') + q);
     636});
     637
     638router.post('/blokkeren/remove', requireSiteManager, (req, res) => {
     639  const site = res.locals.site;
     640  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
     641  res.redirect('/blokkeren?success=' + encodeURIComponent('Deblokkeerd'));
    615642});
    616643
  • src/services/ActivityPubService.js

    rd0cab9d rf5c3870  
    364364  // forged replies/likes/follows/timeline posts). GET/discovery stays open.
    365365  const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     366  // Blocked actor/domain β†’ silently drop (202, don't reveal the block).
     367  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor); return 202; }
    366368  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject'];
    367369  if (GATED.includes(type)) {
     
    774776}
    775777
     778// ── Blocking / defederation ───────────────────────────────────────
     779let _insBl, _delBl, _listBl;
     780function blStmts() {
     781  if (!_insBl) {
     782    _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
     783    _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
     784    _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
     785  }
     786  return { ins: _insBl, del: _delBl, list: _listBl };
     787}
     788export function listBlocks(slug) { return blStmts().list.all(slug); }
     789
     790// True if an actor (or its whole domain) is blocked anywhere on this instance.
     791export function isBlockedAny(actorUri) {
     792  if (!actorUri) return false;
     793  let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
     794  try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
     795  catch { return false; }
     796}
     797
     798function purgeBlocked(kind, target) {
     799  try {
     800    if (kind === 'domain') {
     801      const like = `%//${target}/%`;
     802      db.prepare('DELETE FROM ap_interactions WHERE actor_uri LIKE ?').run(like);
     803      db.prepare('DELETE FROM ap_timeline WHERE author_uri LIKE ?').run(like);
     804      db.prepare('DELETE FROM ap_followers WHERE actor_uri LIKE ?').run(like);
     805    } else {
     806      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
     807      db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
     808      db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
     809    }
     810  } catch { /* best-effort */ }
     811}
     812
     813// Block an actor (@handle or actor URL) or a whole domain; purges their content.
     814export async function blockTarget(site, input) {
     815  const raw = String(input || '').trim();
     816  if (!site || !site.slug || !raw) return { error: 'empty' };
     817  let kind, target, label;
     818  if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
     819  else if (raw.includes('@')) {
     820    const actorUrl = await webfingerResolve(raw);
     821    if (!actorUrl) return { error: 'not_found' };
     822    kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
     823  } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
     824  blStmts().ins.run(site.slug, target, kind, label);
     825  purgeBlocked(kind, target);
     826  console.log('[AP] block', site.slug, kind, target);
     827  return { ok: true, label };
     828}
     829
     830export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
     831
    776832export default {
    777833  getOrCreateKeys, apWants, sendAP, actorId, noteId,
     
    781837  listOutbox, deliverOutboxDelete,
    782838  webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
    783   getNotifications,
     839  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    784840};
  • src/services/i18n.js

    rd0cab9d rf5c3870  
    2626    'nav.language': 'Taal',
    2727    'nav.notifications': 'Meldingen',
    28     'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op',
     28    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein β€” hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
    2929    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
    3030    'switch.agenda': 'Agenda',
     
    10291029    'nav.language': 'Language',
    10301030    'nav.notifications': 'Notifications',
    1031     'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to',
     1031    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain β€” their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
    10321032    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
    10331033    'switch.agenda': 'Agenda',
     
    20232023    'nav.language': 'Sprache',
    20242024    'nav.notifications': 'Benachrichtigungen',
    2025     'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefΓ€llt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf',
     2025    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefΓ€llt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain β€” ihre Antworten, Likes und BeitrΓ€ge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
    20262026    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefΓ€llt dein Beitrag',
    20272027    'switch.agenda': 'Termine',
  • src/views/pages/admin.ejs

    rd0cab9d rf5c3870  
    5959    <a href="/tijdlijn" class="btn">🌐 <%= t('tl.title') %></a>
    6060    <a href="/meldingen" class="btn">πŸ”” <%= t('notif.title') %></a>
     61    <a href="/blokkeren" class="btn">🚫 <%= t('blk.title') %></a>
    6162    <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a>
    6263    <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
  • src/views/pages/timeline.ejs

    rd0cab9d rf5c3870  
    4949              <form method="post" action="/tijdlijn/boost" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('fedi.boosts') %>">πŸ”</button></form>
    5050              <button type="button" class="tl-act fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>">↩</button>
     51              <form method="post" action="/blokkeren/add" class="tl-act-form" onsubmit="return confirm('<%= t('tl.block') %>?');"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('tl.block') %>">🚫</button></form>
    5152            </div>
    5253          </div>
  • src/views/partials/topnav.ejs

    rd0cab9d rf5c3870  
    9999      <% } %>
    100100
    101       <% if (user) { %>
    102         <a class="nav-btn nav-notif" href="/notifications" aria-label="<%= t('nav.notifications') %>" title="<%= t('nav.notifications') %>"
    103            hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/notifications" hx-indicator="#pcms-loading">
     101      <% if (user && typeof canManageFedi !== 'undefined' && canManageFedi) { %>
     102        <a class="nav-btn nav-notif" href="/meldingen" aria-label="<%= t('notif.title') %>" title="<%= t('notif.title') %>"
     103           hx-get="/meldingen?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/meldingen" hx-indicator="#pcms-loading">
    104104          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
    105           <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %><span class="notif-badge"><%= notifUnread > 9 ? '9+' : notifUnread %></span><% } %>
    106105        </a>
    107106      <% } %>
     
    139138                <span><%= t('nav.account') %></span>
    140139              </a>
    141               <a href="/notifications" role="menuitem" class="udi"
    142                  hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
    143                  hx-push-url="/notifications" hx-indicator="#pcms-loading">
     140              <% if (typeof canManageFedi !== 'undefined' && canManageFedi) { %>
     141              <a href="/meldingen" role="menuitem" class="udi"
     142                 hx-get="/meldingen?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
     143                 hx-push-url="/meldingen" hx-indicator="#pcms-loading">
    144144                <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
    145                 <span><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
     145                <span><%= t('notif.title') %></span>
    146146              </a>
     147              <% } %>
    147148              <a href="/favorieten" role="menuitem" class="udi"
    148149                 hx-get="/favorieten?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
Note: See TracChangeset for help on using the changeset viewer.