ssb_config pasa a derivar la cuenta de OASIS_ACCOUNT. Como ssb-config construye la
ruta desde el appname y gui.js saca el socket de config.path, cambiar ese literal
arrastra de forma coherente el almacen, la base de datos, los blobs, el secret y el
socket. Sin la variable se usa 'ssb', asi que las instalaciones existentes siguen en
~/.ssb y no notan nada.
El nombre se valida contra [a-zA-Z0-9][a-zA-Z0-9_-]{0,31} y cae a 'ssb' si no encaja.
No es de adorno: acaba en rutas de fichero y hay operaciones destructivas detras.
Comprobado que OASIS_ACCOUNT=../../etc no sale de sitio.
statePath separa tambien por cuenta cuando se usa OASIS_STATE_DIR. Sin eso, dos
identidades compartirian el estado que 0.9.5 empezo a guardar ahi, que es justo el
camino a partir el registro.
Y el arreglo que importa: doce sitios construian la ruta como os.homedir() + '/.ssb'
en vez de usar la de la cuenta. El peor era panicmode_model, que con varias
identidades habria borrado la equivocada; tambien exportmode y legacy_model, que
habrian exportado o restaurado la identidad de otra cuenta. Los de lectura
—main_models, stats_model y backend.js con gossip.json y ebt— habrian mirado siempre
la cuenta por defecto.
Falta la parte visible: el selector de identidad y el reinicio del proceso al
cambiar. Esta base es la que hacia falta para que eso no sea peligroso.
Verificado: con OASIS_ACCOUNT sin definir la ruta sigue siendo ~/.ssb; con 'trabajo'
pasa a ~/.trabajo; con un valor con .. o espacios se rechaza. Diez rutas OK con la
app en marcha, incluidas /peers y /stats, que son las que leen esos ficheros.
630 lines
26 KiB
JavaScript
630 lines
26 KiB
JavaScript
const pull = require('../server/node_modules/pull-stream');
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
const STORAGE_DIR = path.join(__dirname, "..", "configs");
|
|
const ADDR_FILE = path.join(STORAGE_DIR, "wallet_addresses.json");
|
|
|
|
function readAddrMap() {
|
|
try {
|
|
if (!fs.existsSync(ADDR_FILE)) return {};
|
|
const raw = fs.readFileSync(ADDR_FILE, 'utf8');
|
|
const obj = JSON.parse(raw || '{}');
|
|
return obj && typeof obj === 'object' ? obj : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const listPubsFromEbt = () => {
|
|
try {
|
|
const ebtDir = path.join(require('../server/ssb_config').path, 'ebt');
|
|
const files = fs.readdirSync(ebtDir);
|
|
return files.filter(f => f.endsWith('.ed25519'));
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const HIDDEN_ENVELOPE_TYPES = new Set([
|
|
'tribe-keys-distrib',
|
|
'tribe-invite-msg',
|
|
'tribe-invite-tombstone'
|
|
]);
|
|
|
|
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|
let ssb;
|
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
|
|
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 types = [
|
|
'bookmark','event','task','votes','report','feed','project','industry','industryBlueprint',
|
|
'image','torrent','audio','video','document','transfer','post','tribe',
|
|
'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
|
|
'pad','padEntry','gameScore','calendar','calendarDate','calendarNote','log',
|
|
'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
|
|
'courtsCase','courtsEvidence','courtsAnswer','courtsVerdict','courtsSettlement','courtsSettlementProposal','courtsSettlementAccepted','courtsNomination','courtsNominationVote'
|
|
];
|
|
|
|
const getFolderSize = (folderPath) => {
|
|
let files;
|
|
try { files = fs.readdirSync(folderPath); } catch (_) { return 0; }
|
|
let totalSize = 0;
|
|
for (const file of files) {
|
|
const filePath = `${folderPath}/${file}`;
|
|
try {
|
|
const st = fs.statSync(filePath);
|
|
totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
|
|
} catch (_) {}
|
|
}
|
|
return totalSize;
|
|
};
|
|
|
|
const formatSize = (sizeInBytes) => {
|
|
if (sizeInBytes < 1024) return `${sizeInBytes} B`;
|
|
const kb = 1024, mb = kb * 1024, gb = mb * 1024, tb = gb * 1024;
|
|
if (sizeInBytes < mb) return `${(sizeInBytes / kb).toFixed(2)} KB`;
|
|
if (sizeInBytes < gb) return `${(sizeInBytes / mb).toFixed(2)} MB`;
|
|
if (sizeInBytes < tb) return `${(sizeInBytes / gb).toFixed(2)} GB`;
|
|
return `${(sizeInBytes / tb).toFixed(2)} TB`;
|
|
};
|
|
|
|
const N = s => String(s || '').toUpperCase();
|
|
const sum = arr => arr.reduce((a, b) => a + b, 0);
|
|
const median = arr => {
|
|
if (!arr.length) return 0;
|
|
const a = [...arr].sort((x, y) => x - y);
|
|
const m = Math.floor(a.length / 2);
|
|
return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
|
|
};
|
|
|
|
const parseAuctionMax = auctions_poll => {
|
|
if (!Array.isArray(auctions_poll) || auctions_poll.length === 0) return 0;
|
|
const amounts = auctions_poll.map(s => {
|
|
const parts = String(s).split(':');
|
|
const amt = parseFloat(parts[1]);
|
|
return isNaN(amt) ? 0 : amt;
|
|
});
|
|
return amounts.length ? Math.max(...amounts) : 0;
|
|
};
|
|
|
|
const dayKey = ts => new Date(ts || 0).toISOString().slice(0, 10);
|
|
const lastNDays = (n) => {
|
|
const out = [];
|
|
const today = new Date();
|
|
today.setUTCHours(0,0,0,0);
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setUTCDate(today.getUTCDate() - i);
|
|
out.push(d.toISOString().slice(0, 10));
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const norm = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
const bestContentTs = (c, fallbackTs = 0) =>
|
|
Number(c?.updatedAt ? Date.parse(c.updatedAt) : 0) ||
|
|
Number(c?.createdAt ? Date.parse(c.createdAt) : 0) ||
|
|
Number(c?.timestamp || 0) ||
|
|
Number(fallbackTs || 0);
|
|
const dedupeTribesNodes = (nodes = []) => {
|
|
const pick = new Map();
|
|
for (const n of nodes) {
|
|
const c = n?.content || {};
|
|
const title = c.title || c.name || '';
|
|
const author = n?.author || '';
|
|
const key = `${norm(title)}::${author}`;
|
|
const ts = bestContentTs(c, n?.ts || 0);
|
|
const prev = pick.get(key);
|
|
if (!prev || ts > prev._ts) pick.set(key, { ...n, _ts: ts });
|
|
}
|
|
return Array.from(pick.values());
|
|
};
|
|
|
|
const inferType = (c = {}) => {
|
|
if (c.vote) return 'vote';
|
|
if (c.votes) return 'votes';
|
|
if (c.address && c.coin === 'ECO' && c.type === 'wallet') return 'bankWallet';
|
|
if (typeof c.amount !== 'undefined' && c.epochId && c.allocationId) return 'bankClaim';
|
|
if (typeof c.item_type !== 'undefined' && typeof c.status !== 'undefined') return 'market';
|
|
if (typeof c.goal !== 'undefined' && typeof c.progress !== 'undefined') return 'project';
|
|
if (typeof c.members !== 'undefined' && typeof c.isAnonymous !== 'undefined') return 'tribe';
|
|
if (typeof c.date !== 'undefined' && typeof c.location !== 'undefined') return 'event';
|
|
if (typeof c.priority !== 'undefined' && typeof c.status !== 'undefined' && c.title) return 'task';
|
|
if (typeof c.confirmations !== 'undefined' && typeof c.severity !== 'undefined') return 'report';
|
|
if (typeof c.job_type !== 'undefined' && typeof c.status !== 'undefined') return 'job';
|
|
if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'audio') return 'audio';
|
|
if (typeof c.url !== 'undefined' && typeof c.mimeType !== 'undefined' && c.type === 'video') return 'video';
|
|
if (typeof c.url !== 'undefined' && c.title && c.key) return 'document';
|
|
if (typeof c.text !== 'undefined' && typeof c.refeeds !== 'undefined') return 'feed';
|
|
if (typeof c.text !== 'undefined' && typeof c.contentWarning !== 'undefined') return 'post';
|
|
if (typeof c.contact !== 'undefined') return 'contact';
|
|
if (typeof c.about !== 'undefined') return 'about';
|
|
if (typeof c.concept !== 'undefined' && typeof c.amount !== 'undefined' && c.status) return 'transfer';
|
|
if (c.type === 'map') return 'map';
|
|
if (c.type === 'shop') return 'shop';
|
|
if (c.type === 'shopProduct') return 'shopProduct';
|
|
if (c.type === 'chat') return 'chat';
|
|
if (c.type === 'chatMessage') return 'chatMessage';
|
|
return '';
|
|
};
|
|
|
|
const normalizeActionType = (a) => {
|
|
const t = a.type || a.content?.type || inferType(a.content) || '';
|
|
return String(t).toLowerCase();
|
|
};
|
|
|
|
const priorityBump = (p) => {
|
|
const s = String(p || '').toUpperCase();
|
|
if (s === 'HIGH') return 3;
|
|
if (s === 'MEDIUM') return 1;
|
|
return 0;
|
|
};
|
|
|
|
const severityBump = (s) => {
|
|
const x = String(s || '').toUpperCase();
|
|
if (x === 'CRITICAL') return 6;
|
|
if (x === 'HIGH') return 4;
|
|
if (x === 'MEDIUM') return 2;
|
|
return 0;
|
|
};
|
|
|
|
const calculateOpinionScore = (content) => {
|
|
const cats = content?.opinions || {};
|
|
let s = 0;
|
|
for (const k in cats) {
|
|
if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
|
|
if (k === 'interesting' || k === 'inspiring') s += 5;
|
|
else if (k === 'boring' || k === 'spam' || k === 'propaganda') s -= 3;
|
|
else s += 1;
|
|
}
|
|
return s;
|
|
};
|
|
|
|
const scoreMarketItem = (c) => {
|
|
const st = String(c.status || '').toUpperCase();
|
|
let s = 5;
|
|
if (st === 'SOLD') s += 8;
|
|
else if (st === 'ACTIVE') s += 3;
|
|
const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
|
|
s += Math.min(10, bids);
|
|
return s;
|
|
};
|
|
|
|
const scoreProjectItem = (c) => {
|
|
const st = String(c.status || 'ACTIVE').toUpperCase();
|
|
const prog = Number(c.progress || 0);
|
|
let s = 8 + Math.min(10, prog / 10);
|
|
if (st === 'FUNDED') s += 10;
|
|
return s;
|
|
};
|
|
|
|
const computeKarmaFromMsgs = (msgs) => {
|
|
let score = 0;
|
|
for (const m of msgs) {
|
|
const c = m.value?.content || {};
|
|
const t = normalizeActionType({ type: c.type, content: c });
|
|
const rawType = String(c.type || '').toLowerCase();
|
|
if (t === 'post') score += 10;
|
|
else if (t === 'comment') score += 5;
|
|
else if (t === 'like') score += 2;
|
|
else if (t === 'image') score += 8;
|
|
else if (t === 'video') score += 12;
|
|
else if (t === 'audio') score += 8;
|
|
else if (t === 'document') score += 6;
|
|
else if (t === 'map') score += 6;
|
|
else if (t === 'bookmark') score += 2;
|
|
else if (t === 'feed') score += 6;
|
|
else if (t === 'forum') score += c.root ? 5 : 10;
|
|
else if (t === 'vote') score += 3 + calculateOpinionScore(c);
|
|
else if (t === 'votes') score += Math.min(10, Number(c.totalVotes || 0));
|
|
else if (t === 'market') score += scoreMarketItem(c);
|
|
else if (t === 'project') score += scoreProjectItem(c);
|
|
else if (t === 'tribe') score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
|
|
else if (t === 'event') score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
|
|
else if (t === 'task') score += 3 + priorityBump(c.priority);
|
|
else if (t === 'report') score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
|
|
else if (t === 'curriculum') score += 5;
|
|
else if (t === 'aiexchange') score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
|
|
else if (t === 'job') score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
|
|
else if (t === 'bankclaim') score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
|
|
else if (t === 'bankwallet') score += 2;
|
|
else if (t === 'transfer') score += 1;
|
|
else if (t === 'about') score += 1 + ((c.visibilityPrefs && c.visibilityPrefs.fediverseHandle) ? 8 : 0);
|
|
else if (t === 'contact') score += 1;
|
|
else if (t === 'pub') score += 1;
|
|
else if (t === 'parliamentcandidature' || rawType === 'parliamentcandidature') score += 12;
|
|
else if (t === 'parliamentterm' || rawType === 'parliamentterm') score += 25;
|
|
else if (t === 'parliamentproposal' || rawType === 'parliamentproposal') score += 8;
|
|
else if (t === 'parliamentlaw' || rawType === 'parliamentlaw') score += 16;
|
|
else if (t === 'parliamentrevocation' || rawType === 'parliamentrevocation') score += 10;
|
|
else if (t === 'courts_case' || t === 'courtscase' || rawType === 'courts_case') score += 4;
|
|
else if (t === 'courts_evidence' || t === 'courtsevidence' || rawType === 'courts_evidence') score += 3;
|
|
else if (t === 'courts_answer' || t === 'courtsanswer' || rawType === 'courts_answer') score += 4;
|
|
else if (t === 'courts_verdict' || t === 'courtsverdict' || rawType === 'courts_verdict') score += 10;
|
|
else if (t === 'courts_settlement' || t === 'courtssettlement' || rawType === 'courts_settlement') score += 8;
|
|
else if (t === 'courts_nomination' || t === 'courtsnomination' || rawType === 'courts_nomination') score += 6;
|
|
else if (t === 'courts_nom_vote' || t === 'courtsnomvote' || rawType === 'courts_nom_vote') score += 3;
|
|
else if (t === 'courts_public_pref' || t === 'courtspublicpref' || rawType === 'courts_public_pref') score += 1;
|
|
else if (t === 'courts_mediators' || t === 'courtsmediators' || rawType === 'courts_mediators') score += 6;
|
|
else if (t === 'courts_open_support' || t === 'courtsopensupport' || rawType === 'courts_open_support') score += 2;
|
|
else if (t === 'courts_verdict_vote' || t === 'courtsverdictvote' || rawType === 'courts_verdict_vote') score += 3;
|
|
else if (t === 'courts_judge_assign' || t === 'courtsjudgeassign' || rawType === 'courts_judge_assign') score += 5;
|
|
else if (t === 'larpjoinhouse') score += 15;
|
|
else if (t === 'larphousepost') score += 6;
|
|
else if (t === 'larptestattempt') score += 3;
|
|
else if (t === 'torrent') score += 6;
|
|
else if (t === 'shop' || t === 'shopproduct') score += 6;
|
|
else if (t === 'shop-purchase') score += 2;
|
|
else if (t === 'pad' || t === 'padentry') score += 3;
|
|
else if (t === 'calendar' || t === 'calendarnote' || t === 'calendardate') score += 3;
|
|
else if (t === 'chat') score += 1;
|
|
else if (t === 'gamescore') score += 2;
|
|
else if (t === 'pixelia') score += 2;
|
|
else if (rawType === 'industry') score += 8;
|
|
else if (rawType === 'industryblueprint') score += 6;
|
|
else if (rawType === 'industrybuild') score += 8;
|
|
else if (rawType === 'industrycontribution') score += 4;
|
|
else if (rawType === 'industryallocation') score += 10;
|
|
else if (rawType === 'industryvote') score += 2;
|
|
else if (rawType === 'industrymember') score += 2;
|
|
else if (rawType === 'industryopinion') score += 1;
|
|
}
|
|
return Math.max(0, Math.round(score));
|
|
};
|
|
|
|
const getStats = async (filter = 'ALL') => {
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
|
|
const messages = await new Promise((res, rej) => {
|
|
pull(
|
|
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
|
|
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
|
|
);
|
|
});
|
|
|
|
const allMsgs = messages.filter(m => {
|
|
const c = m.value && m.value.content;
|
|
if (!c) return false;
|
|
if (typeof c === 'string' && c.endsWith('.box')) return false;
|
|
if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) return false;
|
|
return true;
|
|
});
|
|
const tombTargets = buildValidatedTombstoneSet(allMsgs);
|
|
|
|
const scopedMsgs = filter === 'MINE' ? allMsgs.filter(m => m.value.author === userId) : allMsgs;
|
|
|
|
const byType = {};
|
|
const parentOf = {};
|
|
for (const t of types) {
|
|
byType[t] = new Map();
|
|
parentOf[t] = new Map();
|
|
}
|
|
|
|
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
|
const accessibleTribeIds = await buildAccessibleTribeIds();
|
|
for (const m of scopedMsgs) {
|
|
const k = m.key;
|
|
let c = m.value.content;
|
|
if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
|
|
const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
|
|
if (!r || !r.body) continue;
|
|
const inner = r.body;
|
|
if (inner.k === 'tribe') {
|
|
c = { ...inner, type: 'tribe' };
|
|
} else if (inner.k === 'tribe-content' && inner.contentType) {
|
|
c = { ...inner, type: inner.contentType };
|
|
} else {
|
|
continue;
|
|
}
|
|
} else if (c && c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
|
|
continue;
|
|
}
|
|
let theType = c.type;
|
|
if (!types.includes(theType)) continue;
|
|
byType[theType].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author });
|
|
if (c.replaces) parentOf[theType].set(k, c.replaces);
|
|
}
|
|
|
|
const findRoot = (t, id) => {
|
|
let cur = id;
|
|
const pMap = parentOf[t];
|
|
while (pMap.has(cur)) cur = pMap.get(cur);
|
|
return cur;
|
|
};
|
|
|
|
const tipOf = {};
|
|
for (const t of types) {
|
|
tipOf[t] = new Map();
|
|
const pMap = parentOf[t];
|
|
const fwd = new Map();
|
|
for (const [child, parent] of pMap.entries()) fwd.set(parent, child);
|
|
const allMap = byType[t];
|
|
const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
|
|
for (const root of roots) {
|
|
let tip = root;
|
|
while (fwd.has(tip)) tip = fwd.get(tip);
|
|
if (tombTargets.has(tip)) continue;
|
|
const node = allMap.get(tip) || allMap.get(root);
|
|
if (node) tipOf[t].set(root, node);
|
|
}
|
|
}
|
|
|
|
const tribeTipNodes = Array.from(tipOf['tribe'].values());
|
|
const tribeDedupNodes = dedupeTribesNodes(tribeTipNodes);
|
|
const tribeDedupContents = tribeDedupNodes.map(n => n.content);
|
|
|
|
const tribePublic = tribeDedupContents.filter(c => c.isAnonymous === false);
|
|
const tribePrivate = tribeDedupContents.filter(c => c.isAnonymous !== false);
|
|
const tribePublicNames = tribePublic.map(c => c.name || c.title || c.id).filter(Boolean);
|
|
const tribePublicCount = tribePublicNames.length;
|
|
const tribePrivateCount = tribePrivate.length;
|
|
|
|
const allTribesPublic = tribeDedupNodes
|
|
.filter(n => n.content?.isAnonymous === false)
|
|
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
|
|
|
|
const allTribes = allTribesPublic.map(t => t.name);
|
|
|
|
const memberTribesDetailed = tribeDedupNodes
|
|
.filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
|
|
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
|
|
|
|
const myPrivateTribesDetailed = tribeDedupNodes
|
|
.filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
|
|
.map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
|
|
|
|
const content = {};
|
|
const opinions = {};
|
|
for (const t of types) {
|
|
let vals;
|
|
if (t === 'tribe') {
|
|
vals = tribeDedupContents;
|
|
} else {
|
|
vals = Array.from(tipOf[t].values()).map(v => v.content);
|
|
if (t === 'forum') vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
|
|
}
|
|
content[t] = vals.length || 0;
|
|
opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
|
|
}
|
|
|
|
if (filter === 'MINE') {
|
|
const myMsgs = allMsgs.filter(m => m.value.author === userId);
|
|
content['karmaScore'] = computeKarmaFromMsgs(myMsgs);
|
|
} else {
|
|
const msgsByAuthor = new Map();
|
|
for (const m of allMsgs) {
|
|
const a = m.value.author;
|
|
if (!msgsByAuthor.has(a)) msgsByAuthor.set(a, []);
|
|
msgsByAuthor.get(a).push(m);
|
|
}
|
|
let sumKarma = 0;
|
|
for (const authorMsgs of msgsByAuthor.values()) {
|
|
sumKarma += computeKarmaFromMsgs(authorMsgs);
|
|
}
|
|
content['karmaScore'] = sumKarma;
|
|
}
|
|
|
|
const inhabitants = new Set(allMsgs.map(m => m.value.author)).size;
|
|
|
|
const secretStat = fs.statSync((require('../server/ssb_config').path + '/secret'));
|
|
const createdAt = secretStat.birthtime.toLocaleString();
|
|
|
|
const folderSize = getFolderSize((require('../server/ssb_config').path + ''));
|
|
const flumeSize = getFolderSize((require('../server/ssb_config').path + '/flume'));
|
|
const blobsSize = getFolderSize((require('../server/ssb_config').path + '/blobs'));
|
|
|
|
const allTs = scopedMsgs.map(m => m.value.timestamp || 0).filter(Boolean);
|
|
const lastTs = allTs.length ? Math.max(...allTs) : 0;
|
|
|
|
const mapDay = new Map();
|
|
for (const m of scopedMsgs) {
|
|
const dk = dayKey(m.value.timestamp || 0);
|
|
mapDay.set(dk, (mapDay.get(dk) || 0) + 1);
|
|
}
|
|
const days7 = lastNDays(7).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
|
|
const days30 = lastNDays(30).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
|
|
const daily7Total = sum(days7.map(o => o.count));
|
|
const daily30Total = sum(days30.map(o => o.count));
|
|
|
|
const jobsVals = Array.from(tipOf['job'].values()).map(v => v.content);
|
|
const jobOpen = jobsVals.filter(j => N(j.status) === 'OPEN').length;
|
|
const jobClosed = jobsVals.filter(j => N(j.status) === 'CLOSED').length;
|
|
const jobSalaries = jobsVals.map(j => parseFloat(j.salary)).filter(n => isFinite(n));
|
|
const jobVacantsOpen = jobsVals.filter(j => N(j.status) === 'OPEN').map(j => parseInt(j.vacants || 0, 10) || 0);
|
|
const jobSubsTotal = jobsVals.map(j => Array.isArray(j.subscribers) ? j.subscribers.length : 0);
|
|
|
|
const marketVals = Array.from(tipOf['market'].values()).map(v => v.content);
|
|
const mkForSale = marketVals.filter(m => N(m.status) === 'FOR SALE').length;
|
|
const mkReserved = marketVals.filter(m => N(m.status) === 'RESERVED').length;
|
|
const mkClosed = marketVals.filter(m => N(m.status) === 'CLOSED').length;
|
|
const mkSold = marketVals.filter(m => N(m.status) === 'SOLD').length;
|
|
let revenueECO = 0;
|
|
const soldPrices = [];
|
|
for (const m of marketVals) {
|
|
if (N(m.status) !== 'SOLD') continue;
|
|
let price = 0;
|
|
if (String(m.item_type || '').toLowerCase() === 'auction') {
|
|
price = parseAuctionMax(m.auctions_poll);
|
|
} else {
|
|
price = parseFloat(m.price || 0) || 0;
|
|
}
|
|
soldPrices.push(price);
|
|
revenueECO += price;
|
|
}
|
|
|
|
const projectVals = Array.from(tipOf['project'].values()).map(v => v.content);
|
|
const prActive = projectVals.filter(p => N(p.status) === 'ACTIVE').length;
|
|
const prCompleted = projectVals.filter(p => N(p.status) === 'COMPLETED').length;
|
|
const prPaused = projectVals.filter(p => N(p.status) === 'PAUSED').length;
|
|
const prCancelled = projectVals.filter(p => N(p.status) === 'CANCELLED').length;
|
|
const prGoals = projectVals.map(p => parseFloat(p.goal || 0) || 0);
|
|
const prPledged = projectVals.map(p => parseFloat(p.pledged || 0) || 0);
|
|
const prProgress = projectVals.map(p => parseFloat(p.progress || 0) || 0);
|
|
const activeFundingRates = projectVals
|
|
.filter(p => N(p.status) === 'ACTIVE' && parseFloat(p.goal || 0) > 0)
|
|
.map(p => (parseFloat(p.pledged || 0) / parseFloat(p.goal || 1)) * 100);
|
|
|
|
const projectsKPIs = {
|
|
total: projectVals.length,
|
|
active: prActive,
|
|
completed: prCompleted,
|
|
paused: prPaused,
|
|
cancelled: prCancelled,
|
|
ecoGoalTotal: sum(prGoals),
|
|
ecoPledgedTotal: sum(prPledged),
|
|
successRate: projectVals.length ? (prCompleted / projectVals.length) * 100 : 0,
|
|
avgProgress: prProgress.length ? (sum(prProgress) / prProgress.length) : 0,
|
|
medianProgress: median(prProgress),
|
|
activeFundingAvg: activeFundingRates.length ? (sum(activeFundingRates) / activeFundingRates.length) : 0
|
|
};
|
|
|
|
const topAuthorsMap = new Map();
|
|
for (const m of scopedMsgs) {
|
|
const a = m.value.author;
|
|
topAuthorsMap.set(a, (topAuthorsMap.get(a) || 0) + 1);
|
|
}
|
|
const topAuthors = Array.from(topAuthorsMap.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([id, count]) => ({ id, count }));
|
|
|
|
const tagCount = new Map();
|
|
for (const t of types) {
|
|
for (const v of Array.from(tipOf[t].values())) {
|
|
const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : [];
|
|
for (const tg of tags) {
|
|
const key = String(tg || '').trim();
|
|
if (!key) continue;
|
|
tagCount.set(key, (tagCount.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
const topTags = Array.from(tagCount.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([tag, count]) => ({ tag, count }));
|
|
|
|
const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0);
|
|
const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']);
|
|
const topTypes = types
|
|
.filter(t => !topTypeBlacklist.has(t))
|
|
.map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 }))
|
|
.filter(o => o.count > 0)
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 10);
|
|
|
|
const myMsgsAll = allMsgs.filter(m => m.value.author === userId);
|
|
const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
|
|
const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0;
|
|
const validatedTombstoneCount = tombTargets.size;
|
|
const tombstoneRatio = allMsgs.length ? (validatedTombstoneCount / allMsgs.length) * 100 : 0;
|
|
|
|
const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b);
|
|
const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
|
|
const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0;
|
|
|
|
const addrMap = readAddrMap();
|
|
const myAddress = addrMap[userId] || null;
|
|
const banking = {
|
|
ecoWalletConfigured: !!myAddress,
|
|
myAddress,
|
|
myAddressCount: myAddress ? 1 : 0,
|
|
totalAddresses: Object.keys(addrMap).length
|
|
};
|
|
const pubsCount = listPubsFromEbt().length;
|
|
|
|
const stats = {
|
|
id: userId,
|
|
createdAt,
|
|
inhabitants,
|
|
content,
|
|
opinions,
|
|
memberTribes: memberTribesDetailed.map(t => t.name),
|
|
memberTribesDetailed,
|
|
myPrivateTribesDetailed,
|
|
allTribes,
|
|
allTribesPublic,
|
|
tribePublicNames,
|
|
tribePublicCount,
|
|
tribePrivateCount,
|
|
userTombstoneCount: scopedMsgs.filter(m => m.value.content.type === 'tombstone' && m.value.author === userId).length,
|
|
networkTombstoneCount: validatedTombstoneCount,
|
|
folderSize: formatSize(folderSize),
|
|
statsBlockchainSize: formatSize(flumeSize),
|
|
statsBlobsSize: formatSize(blobsSize),
|
|
pubsCount,
|
|
activity: {
|
|
lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
|
|
daily7: days7,
|
|
daily30Total,
|
|
daily7Total
|
|
},
|
|
jobsKPIs: {
|
|
total: jobsVals.length,
|
|
open: jobOpen,
|
|
closed: jobClosed,
|
|
avgSalary: jobSalaries.length ? (sum(jobSalaries) / jobSalaries.length) : 0,
|
|
medianSalary: median(jobSalaries),
|
|
openVacants: sum(jobVacantsOpen),
|
|
subscribersTotal: sum(jobSubsTotal)
|
|
},
|
|
marketKPIs: {
|
|
total: marketVals.length,
|
|
forSale: mkForSale,
|
|
reserved: mkReserved,
|
|
closed: mkClosed,
|
|
sold: mkSold,
|
|
revenueECO,
|
|
avgSoldPrice: soldPrices.length ? (sum(soldPrices) / soldPrices.length) : 0
|
|
},
|
|
projectsKPIs,
|
|
usersKPIs: {
|
|
totalInhabitants: inhabitants,
|
|
topAuthors
|
|
},
|
|
tombstoneKPIs: {
|
|
networkTombstoneCount: validatedTombstoneCount,
|
|
ratio: tombstoneRatio
|
|
},
|
|
networkKPIs: {
|
|
totalMsgs: allMsgs.length,
|
|
myMsgs: myMsgsAll.length,
|
|
myShare,
|
|
avgMsgsPerInhabitant,
|
|
networkSpanDays,
|
|
networkMsgsPerDay
|
|
},
|
|
topTags,
|
|
topTypes,
|
|
totalTypeCount,
|
|
banking
|
|
};
|
|
|
|
return stats;
|
|
};
|
|
|
|
return { getStats };
|
|
};
|
|
|