const pull = require('../server/node_modules/pull-stream'); const config = require('../server/ssb_config'); const { getConfig } = require('../configs/config-manager.js'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readContentWindow, requestScope, logTip } = require('./typed_log'); const { versionGroups, versionEntry } = require('../backend/dedupe'); const logLimit = getConfig().ssbLogStream?.limit || 1000; module.exports = ({ cooler, tribeCrypto, tribesModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const nameByFeedId = new Map(); const HIDDEN_ENVELOPE_TYPES = new Set([ 'tribe-keys-distrib', 'tribe-invite-msg', 'tribe-invite-tombstone' ]); const isHiddenBoxedContent = (rawContent) => typeof rawContent === 'string' && /\.box\d*$/.test(rawContent); const buildAccessibleTribeIds = async () => { const set = new Set(); if (!tribesModel) return set; try { const list = await tribesModel.listAll(); for (const t of list) { if (!t || !t.id) continue; set.add(t.id); try { const chain = await tribesModel.getChainIds(t.id); for (const cid of chain) set.add(cid); } catch (_) {} } } catch (_) {} return set; }; const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has))); const isReplacedBlock = (vg, tombstoned, block) => { const c = block.content; const rootDeleted = !c.replaces && c?.type === 'forum' && c.root && tombstoned.has(c.root); return vg.tipOf(block.id) !== block.id || vg.isForeignCopy(block.id) || tombstoned.has(block.id) || !!rootDeleted; }; const safeDecode = (s) => { try { return decodeURIComponent(String(s || '')); } catch { return String(s || ''); } }; const parseTs = (s) => { const raw = String(s || '').trim(); if (!raw) return null; const ts = new Date(raw).getTime(); return Number.isFinite(ts) ? ts : null; }; const matchBlockId = (blockId, q) => { const a = String(blockId || ''); const b = String(q || ''); if (!a || !b) return true; const al = a.toLowerCase(); const bl = b.toLowerCase(); if (al.includes(bl)) return true; const ad = safeDecode(a).toLowerCase(); const bd = safeDecode(b).toLowerCase(); return ad.includes(bd) || ad.includes(bl) || al.includes(bd); }; const matchAuthorOrName = (authorId, authorName, query) => { const q0 = String(query || '').trim().toLowerCase(); if (!q0) return true; const qNoAt = q0.replace(/^@/, ''); const aid = String(authorId || '').toLowerCase(); if (aid.includes(q0)) return true; if (qNoAt && aid.includes('@' + qNoAt)) return true; const nm0 = String(authorName || '').trim().toLowerCase(); const nmNoAt = nm0.replace(/^@/, ''); if (!nmNoAt) return false; if (nm0.includes(q0)) return true; if (qNoAt && nmNoAt.includes(qNoAt)) return true; return false; }; const buildNameIndexFromAbout = async (ssbClient, minLimit = 5000) => { const nameByFeedId = new Map(); if (!ssbClient?.query?.read) return nameByFeedId; const limit = Math.max(minLimit, logLimit); const source = await ssbClient.query.read({ query: [{ $filter: { value: { content: { type: 'about' } } } }], reverse: true, limit }); const aboutMsgs = await new Promise((resolve, reject) => { pull( source, pull.take(limit), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs || []))) ); }); for (const msg of aboutMsgs) { const c = msg?.value?.content; if (!c || c.type !== 'about') continue; const aboutId = String(c.about || msg?.value?.author || '').trim(); const nm = typeof c.name === 'string' ? c.name.trim() : ''; if (!aboutId || !nm) continue; if (!nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm); } return nameByFeedId; }; let windowCache = null; const readWindow = async (ssbClient) => { const tip = await logTip(ssbClient).catch(() => null); if (tip && windowCache && windowCache.tip === tip) return windowCache.msgs; const msgs = await readContentWindow(ssbClient, logLimit); if (tip) windowCache = { tip, msgs }; return msgs; }; async function buildBlocks(showLogs, me) { const ssbClient = await openSsb(); const results = await readWindow(ssbClient); const tombstoned = buildValidatedTombstoneSet(results); const idToBlock = new Map(); const versions = []; const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; const accessibleTribeIds = await buildAccessibleTribeIds(); for (const msg of results) { const k = msg.key; let c = msg.value?.content; const author = msg.value?.author; if (isHiddenBoxedContent(c) || msg.value?.private === true) continue; if (showLogs && typeof c === 'string' && author === me) { try { const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 }); c = dec?.value?.content; } catch { c = null; } } if (!c?.type) continue; if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; if (!r || !r.body) continue; const inner = r.body; const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg'); c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner }; } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { continue; } if (c.type === 'about') { const aboutId = String(c.about || author || '').trim(); const nm = typeof c.name === 'string' ? c.name.trim() : ''; if (aboutId && nm && !nameByFeedId.has(aboutId)) nameByFeedId.set(aboutId, nm); } if (c.type === 'tombstone' && c.target) { idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); continue; } versions.push(versionEntry({ key: k, value: { ...msg.value, content: c } })); idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); } const vg = versionGroups(versions); const blockData = Array.from(idToBlock.values()).map(block => ({ ...block, isTombstoned: tombstoned.has(block.id), isReplaced: isReplacedBlock(vg, tombstoned, block) })); return blockData; } return { async listBlockchain(filter = 'all', userId, search = {}) { const showLogs = (filter === 'logs' || filter === 'LOGS'); const me = userId || config.keys.id; const store = requestScope.getStore(); const memoKey = `blockchainBase:${showLogs}:${me}`; if (store && !store[memoKey]) store[memoKey] = buildBlocks(showLogs, me); const blockData = await (store ? store[memoKey] : buildBlocks(showLogs, me)); let filtered = blockData.slice(); if (filter === 'RECENT' || filter === 'recent') { const now = Date.now(); filtered = filtered.filter(b => b && now - b.ts <= 24 * 60 * 60 * 1000); } if (filter === 'MINE' || filter === 'mine') { const me = userId || config.keys.id; filtered = filtered.filter(b => b && b.author === me); } if (filter === 'LOGS' || filter === 'logs') { filtered = filtered.filter(b => b && b.type === 'log' && b.author === me); } if (filter === 'PARLIAMENT' || filter === 'parliament') { const pset = new Set(['parliamentTerm','parliamentProposal','parliamentLaw','parliamentCandidature','parliamentRevocation']); filtered = filtered.filter(b => b && pset.has(b.type)); } if (filter === 'COURTS' || filter === 'courts') { const cset = new Set(['courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote']); filtered = filtered.filter(b => b && cset.has(b.type)); } const s = search || {}; const authorQ = String(s.author || '').trim(); const idQ = String(s.id || '').trim(); const fromTs = parseTs(s.from); const toTs = parseTs(s.to); let aboutIndex = null; const needsNameSearch = !!authorQ && !authorQ.toLowerCase().includes('.ed25519'); if (needsNameSearch) { aboutIndex = await buildNameIndexFromAbout(await openSsb(), 10000); for (const [fid, nm] of aboutIndex.entries()) { if (!nameByFeedId.has(fid)) nameByFeedId.set(fid, nm); } } filtered = filtered.filter(b => { if (!b) return false; if (fromTs != null && b.ts < fromTs) return false; if (toTs != null && b.ts > toTs) return false; if (authorQ) { const nm = nameByFeedId.get(b.author) || ''; if (!matchAuthorOrName(b.author, nm, authorQ)) return false; } if (idQ && !matchBlockId(b.id, idQ)) return false; return true; }); return filtered.filter(Boolean); }, async getBlockById(id, userId) { const ssbClient = await openSsb(); const results = await readWindow(ssbClient); const me = userId || config.keys.id; const tombstoned = buildValidatedTombstoneSet(results); const idToBlock = new Map(); const versions = []; const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null; const accessibleTribeIds = await buildAccessibleTribeIds(); for (const msg of results) { const k = msg.key; let c = msg.value?.content; const author = msg.value?.author; if (isHiddenBoxedContent(c) || msg.value?.private === true) continue; if (typeof c === 'string' && author === me) { try { const dec = ssbClient.private.unbox({ key: k, value: msg.value, timestamp: msg.timestamp || msg.value?.timestamp || 0 }); c = dec?.value?.content; } catch { c = null; } } if (!c?.type) continue; if (HIDDEN_ENVELOPE_TYPES.has(c.type)) continue; if (tribeCrypto && tribeCrypto.isTribeMsg(c)) { const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null; if (!r || !r.body) continue; const inner = r.body; const innerType = inner.k === 'tribe' ? 'tribe' : (inner.k === 'tribe-content' ? `tribe-content:${inner.contentType || ''}` : inner.k || 'tribe-msg'); c = { type: innerType, _decrypted: true, _rootId: r.rootId, ...inner }; } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) { continue; } if (c.type === 'tombstone' && c.target) { idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); continue; } versions.push(versionEntry({ key: k, value: { ...msg.value, content: c } })); idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') }); } const block = idToBlock.get(id); if (!block) return null; if (block.type === 'document') { const valid = await hasBlob(ssbClient, block.content.url); if (!valid) return null; } const isTombstoned = tombstoned.has(block.id); const isReplaced = isReplacedBlock(versionGroups(versions), tombstoned, block); return { ...block, isTombstoned, isReplaced }; } }; };