| 1 | /**
|
|---|
| 2 | * The instance blocklist (ap_blocks): actors and whole domains a site has
|
|---|
| 3 | * blocked. Lives NEXT TO the guardianship module, not inside it, because it
|
|---|
| 4 | * is shared: Klonkt's own Block tab uses it, and Shaer's "in Orbit" reads it
|
|---|
| 5 | * as the source of truth (AP §5.6 blocked collection, owner-only).
|
|---|
| 6 | *
|
|---|
| 7 | * Extracted from ActivityPubService (guardianship refactor); behavior is
|
|---|
| 8 | * unchanged. ActivityPubService re-exports these under the old names so
|
|---|
| 9 | * existing callers keep working.
|
|---|
| 10 | */
|
|---|
| 11 | import db from '../config/database.js';
|
|---|
| 12 |
|
|---|
| 13 | let _insBl, _delBl, _listBl;
|
|---|
| 14 | function blStmts() {
|
|---|
| 15 | if (!_insBl) {
|
|---|
| 16 | _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
|
|---|
| 17 | _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
|
|---|
| 18 | _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
|
|---|
| 19 | }
|
|---|
| 20 | return { ins: _insBl, del: _delBl, list: _listBl };
|
|---|
| 21 | }
|
|---|
| 22 |
|
|---|
| 23 | export function listBlocks(slug) { return blStmts().list.all(slug); }
|
|---|
| 24 |
|
|---|
| 25 | // True if an actor (or its whole domain) is blocked anywhere on this instance.
|
|---|
| 26 | export function isBlockedAny(actorUri) {
|
|---|
| 27 | if (!actorUri) return false;
|
|---|
| 28 | let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
|
|---|
| 29 | try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
|
|---|
| 30 | catch { return false; }
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | function purgeBlocked(kind, target) {
|
|---|
| 34 | try {
|
|---|
| 35 | if (kind === 'domain') {
|
|---|
| 36 | // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
|
|---|
| 37 | // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
|
|---|
| 38 | const purge = (table, col) => {
|
|---|
| 39 | let rows = [];
|
|---|
| 40 | try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
|
|---|
| 41 | const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
|
|---|
| 42 | for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
|
|---|
| 43 | };
|
|---|
| 44 | purge('ap_interactions', 'actor_uri');
|
|---|
| 45 | purge('ap_timeline', 'author_uri');
|
|---|
| 46 | purge('ap_followers', 'actor_uri');
|
|---|
| 47 | } else {
|
|---|
| 48 | db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
|
|---|
| 49 | db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
|
|---|
| 50 | db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
|
|---|
| 51 | }
|
|---|
| 52 | } catch { /* best-effort */ }
|
|---|
| 53 | }
|
|---|
| 54 |
|
|---|
| 55 | // Block an actor (@handle or actor URL) or a whole domain; purges their content.
|
|---|
| 56 | // `resolveHandle` (async handle → actor URL) is injected by the caller so this
|
|---|
| 57 | // service needs nothing from ActivityPubService (no circular import).
|
|---|
| 58 | export async function blockTarget(site, input, resolveHandle) {
|
|---|
| 59 | const raw = String(input || '').trim();
|
|---|
| 60 | if (!site || !site.slug || !raw) return { error: 'empty' };
|
|---|
| 61 | let kind, target, label;
|
|---|
| 62 | if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
|
|---|
| 63 | else if (raw.includes('@')) {
|
|---|
| 64 | const actorUrl = resolveHandle ? await resolveHandle(raw) : null;
|
|---|
| 65 | if (!actorUrl) return { error: 'not_found' };
|
|---|
| 66 | kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
|
|---|
| 67 | } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
|
|---|
| 68 | blStmts().ins.run(site.slug, target, kind, label);
|
|---|
| 69 | purgeBlocked(kind, target);
|
|---|
| 70 | console.log('[AP] block', site.slug, kind, target);
|
|---|
| 71 | return { ok: true, label };
|
|---|
| 72 | }
|
|---|
| 73 |
|
|---|
| 74 | export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
|
|---|
| 75 |
|
|---|
| 76 | export default { listBlocks, isBlockedAny, blockTarget, unblock };
|
|---|