Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
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 };
|
|
}
|
|
};
|
|
};
|
|
|