Oasis 0.8.7 mobile (nodejs-project baseline)

This commit is contained in:
s1to 2026-07-09 09:56:30 +02:00
commit e563b86ede
226 changed files with 141968 additions and 0 deletions

View file

@ -0,0 +1,686 @@
const pull = require('../server/node_modules/pull-stream');
const ssbRef = require('../server/node_modules/ssb-ref');
const { getConfig } = require('../configs/config-manager.js');
const { buildVoteTally } = require('../backend/vote_tally');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeFeedId = (v) => {
if (typeof v === 'string' && ssbRef.isFeed(v)) return v;
if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link;
return null;
};
const isContentSane = (c) => {
if (!c || typeof c !== 'object') return false;
if (c.type === 'about') {
if (c.about === undefined) return true;
if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true;
return false;
}
if (c.type === 'pub') {
const addr = c.address;
if (!addr || typeof addr !== 'object') return false;
return typeof addr.key === 'string' && ssbRef.isFeed(addr.key);
}
return true;
};
const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i };
const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i };
function inferType(c = {}) {
if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
if (c.type === 'bankClaim') return 'bankClaim';
if (c.type === 'karmaScore') return 'karmaScore';
if (c.type === 'courts_case') return 'courtsCase';
if (c.type === 'courts_evidence') return 'courtsEvidence';
if (c.type === 'courts_answer') return 'courtsAnswer';
if (c.type === 'courts_verdict') return 'courtsVerdict';
if (c.type === 'courts_settlement') return 'courtsSettlement';
if (c.type === 'courts_nomination') return 'courtsNomination';
if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
if (c.type === 'courts_public_pref') return 'courtsPublicPref';
if (c.type === 'courts_mediators') return 'courtsMediators';
if (c.type === 'map') return 'map';
if (c.type === 'mapMarker') return 'mapMarker';
if (c.type === 'chat') return 'chat';
if (c.type === 'chatMessage') return 'chatMessage';
if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
const br = Array.isArray(c.branch) ? c.branch : [];
if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
}
return c.type || '';
}
const HIDDEN_ENVELOPE_TYPES = new Set([
'tribe-keys-distrib',
'tribe-keys',
'tribe-invite-msg',
'tribe-invite-tombstone',
'aiExchangeVote',
'event-invite',
'forum-invite',
'larpJoinHouse',
'larpLeaveLarp',
'larpTestAttempt',
'larpHousePost',
'larpHouseInvite',
'larpHouseInviteRedeem',
'larpHouseTribeAnchor',
'larpHouseTribeAnchorTombstone',
'larpAutoInvite',
'karmaScore',
'pubAvailability',
'calendarReminderSent',
'taskReminderSent',
'ubiClaimResult',
'ubiAllocation',
'courts-key'
]);
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
let _feedCache = null;
let _feedCacheInflight = null;
const FEED_CACHE_MS = 15 * 1000;
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 tryDecryptPublicInviteKey = (invites) => {
if (!tribeCrypto || !Array.isArray(invites)) return null;
for (const inv of invites) {
if (!inv || typeof inv !== 'object' || inv.public !== true) continue;
if (typeof inv.code !== 'string') continue;
if (typeof inv.ek === 'string') {
try {
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
if (k) return k;
} catch (_) {}
}
if (typeof inv.ekChain === 'string') {
try {
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
} catch (_) {}
}
}
return null;
};
const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const stripHtml = (s) => normNL(s)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p\s*>/gi, '\n\n')
.replace(/<[^>]*>/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const excerpt = (s, max = 900) => {
const t = stripHtml(s);
if (!t) return '';
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
return {
invalidateCache() {
_feedCache = null;
_feedCacheInflight = null;
},
async listFeed(filter = 'all') {
const cacheKey = filter || 'all';
const now = Date.now();
if (!_feedCache) _feedCache = new Map();
const entry = _feedCache.get(cacheKey);
if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value;
if (!_feedCacheInflight) _feedCacheInflight = new Map();
if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey);
const promise = (async () => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const results = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
if (typeof ssbClient.createUserStream === 'function') {
const ownResults = await new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: userId, reverse: true }),
pull.collect((err, msgs) => resolve(err ? [] : msgs))
);
});
const seenKeys = new Set(results.map(m => m && m.key));
for (const m of ownResults) {
if (m && m.key && !seenKeys.has(m.key)) results.push(m);
}
}
const tombstoned = buildValidatedTombstoneSet(results);
const parentOf = new Map();
const idToAction = new Map();
const rawById = new Map();
const voteTally = buildVoteTally(results);
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds();
for (const msg of results) {
const k = msg.key;
const v = msg.value;
let c = v?.content;
if (!c) continue;
if (typeof c === 'string' && c.endsWith('.box')) continue;
if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
if (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue;
if (c.type === 'contact') continue;
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' || inner.op !== 'create') continue;
c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId };
} else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
continue;
}
if (!c.type) continue;
if (c.type === 'tombstone') continue;
if (c.encryptedPayload && tribeCrypto && !c.tribeId) {
const pubKey = tryDecryptPublicInviteKey(c.invites);
if (pubKey) {
try {
const dec = tribeCrypto.decryptContent(c, [[pubKey]]);
if (dec && !dec._undecryptable) c = dec;
} catch (_) {}
}
}
if (c.type === 'pad' && c.encrypted === true && !c.tribeId && padsModel && typeof padsModel.decryptContentPublicSync === 'function') {
try {
const dec = padsModel.decryptContentPublicSync(c);
if (dec) c = { ...c, title: dec.title, deadline: dec.deadline, tags: dec.tags, encrypted: false };
} catch (_) {}
}
if (!isContentSane(c)) continue;
const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
rawById.set(k, msg);
if (c.replaces) parentOf.set(k, c.replaces);
}
const forgedParentOf = new Map();
const forgedFeedContent = new Map();
for (const [child, parent] of Array.from(parentOf.entries())) {
const ca = idToAction.get(child);
const pa = idToAction.get(parent);
if (!pa) { parentOf.delete(child); continue; }
if (!ca || String(ca.author) !== String(pa.author)) {
forgedParentOf.set(child, parent);
if (ca && ca.type === 'feed') forgedFeedContent.set(child, ca.content || {});
parentOf.delete(child);
idToAction.delete(child);
rawById.delete(child);
}
}
const replacedIds = new Set(parentOf.values());
const spreadVoteState = new Map();
const aiHelpfulCounts = new Map();
const aiHelpfulSeen = new Map();
for (const msg of results) {
const v = msg.value;
const c = v && v.content;
if (!c || c.type !== 'aiExchangeVote') continue;
const target = String(c.target || '');
const author = v.author;
if (!target || !author) continue;
const key = `${target}${author}`;
const prev = aiHelpfulSeen.get(key);
const curTs = v.timestamp || 0;
if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false });
}
for (const { target, helpful } of aiHelpfulSeen.values()) {
if (!helpful) continue;
aiHelpfulCounts.set(target, (aiHelpfulCounts.get(target) || 0) + 1);
}
for (const a of idToAction.values()) {
const c = a.content || {};
if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue;
const link = c.vote.link;
const br = Array.isArray(c.branch) ? c.branch : [];
if (!br.includes(link)) continue;
if (tombstoned.has(a.id)) continue;
if (replacedIds.has(a.id)) continue;
if (tombstoned.has(link)) continue;
const author = a.author;
if (!author) continue;
const value = Number(c.vote.value);
const key = `${link}:${author}`;
const prev = spreadVoteState.get(key);
const curTs = a.ts || 0;
if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) {
spreadVoteState.set(key, { ts: curTs, id: a.id, value, link });
}
}
const spreadCountByTarget = new Map();
for (const v of spreadVoteState.values()) {
if (Number(v.value) !== 1) continue;
spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1);
}
const fetchedTargetCache = new Map();
for (const a of idToAction.values()) {
if (a.type !== 'spread') continue;
const c = a.content || {};
const link = c.vote?.link || '';
const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0;
let targetMsg = link ? rawById.get(link) : null;
if (!targetMsg && link) {
if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link);
else {
const got = await getMsg(ssbClient, link);
if (got) {
const wrapped = { key: link, value: got };
fetchedTargetCache.set(link, wrapped);
targetMsg = wrapped;
} else {
fetchedTargetCache.set(link, null);
targetMsg = null;
}
}
}
const targetContent = targetMsg?.value?.content || null;
const title =
(typeof targetContent?.title === 'string' && targetContent.title.trim())
? targetContent.title.trim()
: (typeof targetContent?.name === 'string' && targetContent.name.trim())
? targetContent.name.trim()
: '';
const rawText =
(typeof targetContent?.text === 'string' && targetContent.text.trim())
? targetContent.text
: (typeof targetContent?.description === 'string' && targetContent.description.trim())
? targetContent.description
: '';
const text = rawText ? excerpt(rawText, 700) : '';
const cw =
(typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim())
? targetContent.contentWarning
: '';
a.content = {
...c,
spreadTargetId: link,
spreadTotalSpreads: totalSpreads,
spreadOriginalAuthor: targetMsg?.value?.author || '',
spreadTitle: title,
spreadContentWarning: cw,
spreadText: text
};
}
const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };
const groups = new Map();
for (const [id, action] of idToAction.entries()) {
const root = rootOf(id);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(action);
}
const idToTipId = new Map();
for (const [root, arr] of groups.entries()) {
if (!arr.length) continue;
const type = arr[0].type;
if (type !== 'project') {
const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
for (const a of arr) idToTipId.set(a.id, tip.id);
if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') {
const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
const sorted = arr
.filter(a => a.type === 'task' && a.content && typeof a.content === 'object')
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
let prev = null;
for (const ev of sorted) {
const cur = uniq(ev.content.assignees);
if (prev) {
const prevSet = new Set(prev);
const curSet = new Set(cur);
const added = cur.filter(x => !prevSet.has(x));
const removed = prev.filter(x => !curSet.has(x));
if (added.length || removed.length) {
const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`;
idToAction.set(overlayId, {
id: overlayId,
author: ev.author,
ts: ev.ts,
type: 'taskAssignment',
content: {
taskId: tip.id,
title: tip.content.title || ev.content.title || '',
added,
removed,
isPublic: tip.content.isPublic
}
});
idToTipId.set(overlayId, overlayId);
}
}
prev = cur;
}
}
continue;
}
let tip = arr[0];
let bestScore = SCORE_PROJECT(tip.content.status);
for (const a of arr) {
const s = SCORE_PROJECT(a.content.status);
if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s }
}
for (const a of arr) idToTipId.set(a.id, tip.id);
const baseTitle = (tip.content && tip.content.title) || '';
const overlays = arr
.filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge))
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
for (const ev of overlays) {
if (tombstoned.has(ev.id)) continue;
let kind = null;
let amount = null;
if (ev.content.followersOp === 'follow') kind = 'follow';
else if (ev.content.followersOp === 'unfollow') kind = 'unfollow';
if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') {
const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0);
if (amt > 0) { kind = kind || 'pledge'; amount = amt }
}
if (!kind) continue;
const augmented = {
...ev,
type: 'project',
content: {
...ev.content,
title: baseTitle,
projectId: tip.id,
activity: { kind, amount },
activityActor: ev.author
}
};
idToAction.set(ev.id, augmented);
idToTipId.set(ev.id, ev.id);
}
}
const feedOpinionsByRoot = new Map();
for (const msg of results) {
const v = msg && msg.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
const isOpinion = c.type === 'feedOpinion' && typeof c.target === 'string';
const isVoteAction = c.type === 'feed-action' && c.action === 'vote' && (typeof c.root === 'string' || typeof c.target === 'string');
if (!isOpinion && !isVoteAction) continue;
const target = isOpinion ? c.target : String(c.root || c.target);
let cur = target;
let g = 0;
while (g++ < 1000) {
if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; }
if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; }
break;
}
const r = cur;
if (!feedOpinionsByRoot.has(r)) feedOpinionsByRoot.set(r, []);
feedOpinionsByRoot.get(r).push({ author: v.author, category: String(c.category || '') });
}
const forgedFeedAggByRoot = new Map();
for (const [child, fc] of forgedFeedContent.entries()) {
let cur = child;
let g = 0;
while (g++ < 1000) {
if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; }
if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; }
break;
}
if (!forgedFeedAggByRoot.has(cur)) forgedFeedAggByRoot.set(cur, []);
forgedFeedAggByRoot.get(cur).push(fc);
}
const latest = [];
for (const a of idToAction.values()) {
if (tombstoned.has(a.id)) continue;
if (a.type === 'tribe' && parentOf.has(a.id)) continue;
const c = a.content || {};
if (c.root && tombstoned.has(c.root)) continue;
if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
if (c.key && tombstoned.has(c.key)) continue;
if (c.branch && tombstoned.has(c.branch)) continue;
if (c.target && tombstoned.has(c.target)) continue;
if (a.type === 'document') {
const url = c.url;
const ok = await hasBlob(ssbClient, url);
if (!ok) continue;
}
if (a.type === 'forum' && c.root) {
const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
const rootAction = idToAction.get(rootId);
a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
a.content.rootKey = rootId || a.content.rootKey || '';
}
const actionRoot = rootOf(a.id);
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
if (a.type === 'feed') {
const base = a.content || {};
const opinions = { ...(base.opinions || {}) };
const voters = Array.isArray(base.opinions_inhabitants) ? base.opinions_inhabitants.slice() : [];
const voterSet = new Set(voters);
for (const fc of (forgedFeedAggByRoot.get(actionRoot) || [])) {
for (const [cat, n] of Object.entries(fc.opinions || {})) {
opinions[cat] = (Number(opinions[cat]) || 0) + (Number(n) || 0);
}
for (const vt of (Array.isArray(fc.opinions_inhabitants) ? fc.opinions_inhabitants : [])) {
if (voterSet.has(vt)) continue;
voterSet.add(vt);
voters.push(vt);
}
}
for (const op of (feedOpinionsByRoot.get(actionRoot) || [])) {
if (!op.author || voterSet.has(op.author)) continue;
voterSet.add(op.author);
voters.push(op.author);
if (op.category) opinions[op.category] = (Number(opinions[op.category]) || 0) + 1;
}
content = { ...content, opinions, opinions_inhabitants: voters };
}
latest.push({ ...a, content, ...extra, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
}
let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
const perAuthorUnique = new Set();
const byKey = new Map();
const norm = s => String(s || '').trim().toLowerCase();
for (const a of deduped) {
const c = a.content || {};
const effTs =
(c.updatedAt && Date.parse(c.updatedAt)) ||
(c.createdAt && Date.parse(c.createdAt)) ||
(a.ts || 0);
if (mediaTypes.has(a.type)) {
const u = c.url || c.title || `${a.type}:${a.id}`;
const key = `${a.type}:${u}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else if (perAuthorUnique.has(a.type)) {
const key = `${a.type}:${a.author}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else if (a.type === 'about') {
const target = c.about || a.author;
if (String(target) !== String(a.author)) {
byKey.set(`id:${a.id}`, { ...a, __effTs: effTs });
continue;
}
const key = `about:${target}`;
const prev = byKey.get(key);
const prevContent = prev && (prev.content || {});
const prevHasImage = !!(prevContent && prevContent.image);
const newHasImage = !!c.image;
if (!prev) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
} else if (!prevHasImage && newHasImage) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
} else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
}
} else if (a.type === 'tribe') {
const t = norm(c.title);
if (t) {
const key = `tribe:${t}::${a.author}`;
const prev = byKey.get(key);
if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
} else {
const key = `id:${a.id}`;
byKey.set(key, { ...a, __effTs: effTs });
}
} else {
const key = `id:${a.id}`;
byKey.set(key, { ...a, __effTs: effTs });
}
}
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
if (c.tribeId) return false;
if (a.type === 'tribe') {
const isInitial = !c.replaces;
if (!isInitial) return false;
if (c.isAnonymous === true && !c._decrypted) return false;
const st = String(c.status || '').toUpperCase();
if ((st === 'PRIVATE' || st === 'INVITE-ONLY') && !(Array.isArray(c.members) && c.members.includes(userId))) return false;
}
return true;
};
const itemVisible = (a) => {
if (hiddenTypes.has(a.type)) return false;
const c = a.content || {};
if (c.encryptedPayload) return false;
if (a.type === 'pad' && c.status !== 'OPEN') return false;
if (a.type === 'chat' && c.status !== 'OPEN') return false;
if (a.type === 'calendar' && c.status !== 'OPEN') return false;
if (a.type === 'event' && String(c.isPublic || '').toLowerCase() === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
return true;
};
const isVisible = (a) => {
if (!itemVisible(a)) return false;
if (a.type === 'post' || a.type === 'opinion') {
const c = a.content || {};
const ref = (typeof c.root === 'string' && c.root)
|| (typeof c.branch === 'string' && c.branch)
|| (typeof c.target === 'string' && c.target)
|| null;
if (ref) {
const parent = idToAction.get(ref);
if (parent && !itemVisible(parent)) return false;
}
}
return true;
};
let out;
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
else if (filter === 'parliament')
out = deduped.filter(a =>
['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
);
else if (filter === 'courts')
out = deduped.filter(a => {
const t = String(a.type || '').toLowerCase();
return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return out;
})();
_feedCacheInflight.set(cacheKey, promise);
try {
const value = await promise;
_feedCache.set(cacheKey, { value, ts: Date.now() });
return value;
} finally {
_feedCacheInflight.delete(cacheKey);
}
}
};
};

340
src/models/agenda_model.js Normal file
View file

@ -0,0 +1,340 @@
const fs = require('fs');
const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
function readAgendaConfig() {
if (!fs.existsSync(agendaConfigPath)) {
fs.writeFileSync(agendaConfigPath, JSON.stringify({ discardedItems: [] }));
}
return JSON.parse(fs.readFileSync(agendaConfigPath));
}
function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
const fetchItems = (targetType) =>
new Promise((resolve, reject) => {
openSsb().then((ssbClient) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tomb = buildValidatedTombstoneSet(msgs);
const nodes = new Map();
const parent = new Map();
const child = new Map();
for (const m of msgs) {
const k = m.key;
const v = m.value;
const c = v?.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type !== targetType) continue;
if (c.encryptedPayload) continue;
nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c });
if (c.replaces) parent.set(k, c.replaces);
}
for (const [k, p] of Array.from(parent.entries())) {
const cn = nodes.get(k);
const pn = nodes.get(p);
if (!pn) { parent.delete(k); continue; }
if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); nodes.delete(k); }
}
for (const [k, p] of parent.entries()) child.set(p, k);
const rootOf = (id) => { let cur = id; while (parent.has(cur) && nodes.has(parent.get(cur))) cur = parent.get(cur); return cur; };
const groups = new Map();
for (const id of nodes.keys()) {
const r = rootOf(id);
if (!groups.has(r)) groups.set(r, new Set());
groups.get(r).add(id);
}
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
const out = [];
for (const [root, ids] of groups.entries()) {
const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
if (!items.length) continue;
let tipId = Array.from(ids).find(id => !child.has(id));
let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
if (targetType === 'market') {
let chosen = items[0];
for (const n of items) {
const a = sIdx(n.content.status);
const b = sIdx(chosen.content.status);
if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
}
const c = chosen.content;
let status = c.status;
if (c.deadline) {
const dl = moment(c.deadline);
if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
}
if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
out.push({
...c,
status,
author: chosen.author,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
continue;
}
if (targetType === 'job') {
const latest = items.sort((a, b) => b.ts - a.ts)[0];
const withSubsNode = items
.filter(n => Array.isArray(n.content.subscribers))
.sort((a, b) => b.ts - a.ts)[0];
const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
const latestWithStatus = items
.filter(n => typeof n.content.status !== 'undefined')
.sort((a, b) => b.ts - a.ts)[0];
const resolvedStatus = latestWithStatus
? latestWithStatus.content.status
: latest.content.status;
const c = { ...latest.content, status: resolvedStatus, subscribers };
out.push({
...c,
author: latest.author,
id: latest.key,
tipId: latest.key,
createdAt: c.createdAt || latest.ts
});
continue;
}
out.push({
...tip.content,
author: tip.author,
id: tip.key,
tipId: tip.key,
createdAt: tip.content.createdAt || tip.ts
});
}
resolve(out);
})
);
}).catch(reject);
});
return {
async listAgenda(filter = 'all') {
const agendaConfig = readAgendaConfig();
const discardedItems = agendaConfig.discardedItems || [];
const ssbClient = await openSsb();
const userId = ssbClient.id;
const normalize = (arr) => arr.map(it => ({
...it,
key: it.id || it.key,
createdAt: it.createdAt || new Date().toISOString(),
tipId: it.tipId || it.id || it.key
}));
const eventsViaModel = eventsModel && typeof eventsModel.listAll === 'function'
? eventsModel.listAll(null, 'all').then(normalize).catch(() => [])
: fetchItems('event');
const calendarsViaModel = calendarsModel && typeof calendarsModel.listAll === 'function'
? calendarsModel.listAll({ filter: 'all', viewerId: userId }).then(normalize).catch(() => [])
: fetchItems('calendar');
const tasksViaModel = tasksModel && typeof tasksModel.listAll === 'function'
? tasksModel.listAll().then(normalize).catch(() => [])
: fetchItems('task');
const marketViaModel = marketModel && typeof marketModel.listAllItems === 'function'
? marketModel.listAllItems('all').then(normalize).catch(() => [])
: fetchItems('market');
const jobsViaModel = jobsModel && typeof jobsModel.listJobs === 'function'
? jobsModel.listJobs('ALL', userId).then(normalize).catch(() => [])
: fetchItems('job');
const projectsViaModel = projectsModel && typeof projectsModel.listProjects === 'function'
? projectsModel.listProjects('all').then(normalize).catch(() => [])
: fetchItems('project');
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll] = await Promise.all([
tasksViaModel,
eventsViaModel,
fetchItems('transfer'),
fetchItems('tribe'),
marketViaModel,
fetchItems('report'),
jobsViaModel,
projectsViaModel,
calendarsViaModel
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' }));
const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' }));
const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title }));
const marketItems = marketAll.filter(c =>
c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId))
).map(m => ({ ...m, type: 'market' }));
const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
const myCalendars = calendarsAll
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
const calendarDates = [];
if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') {
for (const cal of myCalendars) {
try {
const dates = await calendarsModel.getDatesForCalendar(cal.id || cal.key);
for (const d of (dates || [])) {
calendarDates.push({
type: 'calendarDate',
id: d.key || d.id,
title: d.label || cal.title || 'Calendar date',
calendarTitle: cal.title || '',
calendarId: cal.id || cal.key,
date: d.date || d.createdAt,
label: d.label || '',
author: cal.author,
createdAt: d.createdAt || cal.createdAt,
status: 'OPEN'
});
}
} catch (_) {}
}
}
let combined = [
...tasks,
...events,
...transfers,
...tribes,
...marketItems,
...reports,
...jobs,
...projects,
...calendars,
...calendarDates
];
let filtered;
if (filter === 'discarded') {
filtered = combined.filter(i => discardedItems.includes(i.id));
} else {
filtered = combined.filter(i => !discardedItems.includes(i.id));
if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
else if (filter === 'today') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
filtered = filtered.filter(i => {
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
return d >= startOfDay.getTime() && d <= endOfDay.getTime();
});
}
else if (filter === 'upcoming') {
const now = Date.now();
filtered = filtered.filter(i => {
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
return d > now;
});
}
else if (filter === 'overdue') {
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
filtered = filtered.filter(i => {
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
const open = String(i.status || 'OPEN').toUpperCase() === 'OPEN';
return d > 0 && d < startOfDay.getTime() && open;
});
}
}
filtered.sort((a, b) => {
const dateA = a.date || a.startTime || a.deadline || a.createdAt || 0;
const dateB = b.date || b.startTime || b.deadline || b.createdAt || 0;
return new Date(dateA) - new Date(dateB);
});
const mainItems = combined.filter(i => !discardedItems.includes(i.id));
const discarded = combined.filter(i => discardedItems.includes(i.id));
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
const now = Date.now();
const itemTs = (i) => new Date(i.date || i.startTime || i.deadline || 0).getTime();
return {
items: filtered,
counts: {
all: mainItems.length,
open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length,
closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length,
tasks: mainItems.filter(i => i.type === 'task').length,
events: mainItems.filter(i => i.type === 'event').length,
transfers: mainItems.filter(i => i.type === 'transfer').length,
tribes: mainItems.filter(i => i.type === 'tribe').length,
market: mainItems.filter(i => i.type === 'market').length,
reports: mainItems.filter(i => i.type === 'report').length,
jobs: mainItems.filter(i => i.type === 'job').length,
projects: mainItems.filter(i => i.type === 'project').length,
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
upcoming: mainItems.filter(i => itemTs(i) > now).length,
overdue: mainItems.filter(i => { const d = itemTs(i); return d > 0 && d < startOfDay.getTime() && String(i.status || 'OPEN').toUpperCase() === 'OPEN'; }).length,
discarded: discarded.length
}
};
},
async discardItem(itemId) {
const agendaConfig = readAgendaConfig();
if (!agendaConfig.discardedItems.includes(itemId)) {
agendaConfig.discardedItems.push(itemId);
writeAgendaConfig(agendaConfig);
}
},
async restoreItem(itemId) {
const agendaConfig = readAgendaConfig();
agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId);
writeAgendaConfig(agendaConfig);
}
};
};

387
src/models/audios_model.js Normal file
View file

@ -0,0 +1,387 @@
const pull = require("../server/node_modules/pull-stream");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone") continue;
if (c.type === "audioOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "audio") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildAudio = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
const opinions = agg ? agg.opinions : (c.opinions || {});
const composition = Array.isArray(c.bcsComposition) ? c.bcsComposition : null;
const tagsArr = safeArr(c.tags);
const isBcs = (composition && composition.length > 0) || tagsArr.some(t => String(t).toLowerCase() === 'bcs');
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: tagsArr,
author: c.author,
title: c.title || "",
description: c.description || "",
mapUrl: c.mapUrl || "",
opinions,
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
bcsComposition: composition,
isBcs,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "audio",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Audio not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "audio",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
mapUrl: mapUrl || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async createBcsAudio(blobId, title, description, composition) {
const ssbClient = await openSsb();
const now = new Date().toISOString();
const cleanComposition = safeArr(composition)
.map(n => ({
t: String(n.type || n.t || ''),
n: String(n.name || n.n || ''),
d: Number(n.durMs || n.d || 0),
id: typeof n.id === 'string' ? n.id : (typeof n.key === 'string' ? n.key : null)
}))
.filter(n => n.t && n.n);
const baseContent = {
type: "audio",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags: ["bcs"],
title: title || "",
description: description || "",
mapUrl: "",
bcsComposition: [],
opinions: {},
opinions_inhabitants: []
};
const SSB_MAX_BYTES = 8000;
const wrapEnvelope = (content) => ({
previous: "%0000000000000000000000000000000000000000000=.sha256",
sequence: 999999,
author: ssbClient.id,
timestamp: Date.now(),
hash: "sha256",
content,
signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000==.sig.ed25519"
});
const fitted = [];
for (const item of cleanComposition) {
fitted.push(item);
const probe = wrapEnvelope({ ...baseContent, bcsComposition: fitted });
if (Buffer.byteLength(JSON.stringify(probe, null, 2), 'utf8') > SSB_MAX_BYTES) {
fitted.pop();
break;
}
}
const content = { ...baseContent, bcsComposition: fitted };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const aggAudio = await this.getAudioById(id, userId);
if (aggAudio && Object.keys(aggAudio.opinions || {}).some(k => (aggAudio.opinions[k] || 0) > 0)) throw new Error("Cannot edit audio after it has received opinions.");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
});
},
async deleteAudioById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildAudio(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (filter === "bcs") {
list = list.filter((a) => a.isBcs === true);
}
if (q) {
list = list.filter((a) => {
const title = String(a.title || "").toLowerCase();
const desc = String(a.description || "").toLowerCase();
const tags = safeArr(a.tags).join(" ").toLowerCase();
const author = String(a.author || "").toLowerCase();
return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getAudioById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found");
return buildAudio(agg.contentNode, rootId, viewer, agg);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const audio = await this.getAudioById(id, userId);
if (!audio) throw new Error("Audio not found");
if (safeArr(audio.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "audioOpinion", target: audio.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};

1394
src/models/banking_model.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,397 @@
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 logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const HIDDEN_ENVELOPE_TYPES = new Set([
'tribe-keys-distrib',
'tribe-invite-msg',
'tribe-invite-tombstone'
]);
const isHiddenBoxedContent = (rawContent) =>
typeof rawContent === 'string' && rawContent.endsWith('.box');
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 isClosedSold = s =>
String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED';
const projectRank = (status) => {
const S = String(status || '').toUpperCase();
if (S === 'COMPLETED') return 3;
if (S === 'ACTIVE') return 2;
if (S === 'PAUSED') return 1;
if (S === 'CANCELLED') return 0;
return -1;
};
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;
};
return {
async listBlockchain(filter = 'all', userId, search = {}) {
const ssbClient = await openSsb();
const results = await new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
const tombstoned = buildValidatedTombstoneSet(results);
const idToBlock = new Map();
const referencedAsReplaces = new Set();
const nameByFeedId = new Map();
const showLogs = (filter === 'logs' || filter === 'LOGS');
const me = userId || config.keys.id;
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)) 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;
}
if (c.replaces) referencedAsReplaces.add(c.replaces);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
}
const tipBlocks = [];
for (const [id, block] of idToBlock.entries()) {
if (!referencedAsReplaces.has(id) && block.content.replaces) tipBlocks.push(block);
}
for (const [id, block] of idToBlock.entries()) {
if (!block.content.replaces && !referencedAsReplaces.has(id)) tipBlocks.push(block);
}
const groups = {};
for (const block of tipBlocks) {
const ancestor = block.content.replaces || block.id;
if (!groups[ancestor]) groups[ancestor] = [];
groups[ancestor].push(block);
}
const liveTipIds = new Set();
for (const groupBlocks of Object.values(groups)) {
let best = groupBlocks[0];
for (const block of groupBlocks) {
if (block.type === 'market') {
if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
best = block;
} else if ((block.content.status === best.content.status) && block.ts > best.ts) {
best = block;
}
} else if (block.type === 'project') {
const br = projectRank(best.content.status);
const cr = projectRank(block.content.status);
if (cr > br || (cr === br && block.ts > best.ts)) best = block;
} else if (block.type === 'job' || block.type === 'forum') {
if (block.ts > best.ts) best = block;
} else {
if (block.ts > best.ts) best = block;
}
}
liveTipIds.add(best.id);
}
const blockData = Array.from(idToBlock.values()).map(block => {
const c = block.content;
const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
return {
...block,
isTombstoned: tombstoned.has(block.id),
isReplaced: c.replaces
? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
: referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted
};
});
let filtered = blockData;
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(ssbClient, 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 new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
const me = userId || config.keys.id;
const tombstoned = buildValidatedTombstoneSet(results);
const idToBlock = new Map();
const referencedAsReplaces = new Set();
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)) 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;
}
if (c.replaces) referencedAsReplaces.add(c.replaces);
idToBlock.set(k, { id: k, author, ts: msg.value.timestamp, type: c.type, content: c, size: Buffer.byteLength(JSON.stringify(msg.value), 'utf8') });
}
const tipBlocks = [];
for (const [bid, block] of idToBlock.entries()) {
if (!referencedAsReplaces.has(bid) && block.content.replaces) tipBlocks.push(block);
}
for (const [bid, block] of idToBlock.entries()) {
if (!block.content.replaces && !referencedAsReplaces.has(bid)) tipBlocks.push(block);
}
const groups = {};
for (const block of tipBlocks) {
const ancestor = block.content.replaces || block.id;
if (!groups[ancestor]) groups[ancestor] = [];
groups[ancestor].push(block);
}
const liveTipIds = new Set();
for (const groupBlocks of Object.values(groups)) {
let best = groupBlocks[0];
for (const block of groupBlocks) {
if (block.type === 'market') {
if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
best = block;
} else if ((block.content.status === best.content.status) && block.ts > best.ts) {
best = block;
}
} else if (block.type === 'project') {
const br = projectRank(best.content.status);
const cr = projectRank(block.content.status);
if (cr > br || (cr === br && block.ts > best.ts)) best = block;
} else if (block.type === 'job' || block.type === 'forum') {
if (block.ts > best.ts) best = block;
} else {
if (block.ts > best.ts) best = block;
}
}
liveTipIds.add(best.id);
}
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 c = block.content;
const rootDeleted = c?.type === 'forum' && c.root && tombstoned.has(c.root);
const isTombstoned = tombstoned.has(block.id);
const isReplaced = c.replaces
? (!liveTipIds.has(block.id) || tombstoned.has(block.id))
: referencedAsReplaces.has(block.id) || tombstoned.has(block.id) || rootDeleted;
return { ...block, isTombstoned, isReplaced };
}
};
};

View file

@ -0,0 +1,350 @@
const pull = require("../server/node_modules/pull-stream");
const moment = require("../server/node_modules/moment");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return [];
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const coerceLastVisit = (lastVisit) => {
const now = moment();
if (!lastVisit) return now.toISOString();
const m = moment(lastVisit, moment.ISO_8601, true);
if (!m.isValid()) return now.toISOString();
if (m.isAfter(now)) return now.toISOString();
return m.toISOString();
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'bookmarkOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "bookmark") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, t] of Array.from(parent.entries())) {
const cn = nodes.get(k);
const pn = nodes.get(t);
if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue; }
if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k); }
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildBookmark = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const opinions = agg ? agg.opinions : (c.opinions || {});
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
return {
id: node.key,
rootId,
url: c.url || "",
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
lastVisit: c.lastVisit || null,
tags: safeArr(c.tags),
category: c.category || "",
description: c.description || "",
opinions,
opinions_inhabitants: voters,
author: node.author,
hasVoted: viewerId ? voters.includes(viewerId) : false,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "bookmark",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createBookmark(url, tagsRaw, description, category, lastVisit) {
const ssbClient = await openSsb();
const now = new Date().toISOString();
const u = safeText(url);
if (!u) throw new Error("URL is required");
const content = {
type: "bookmark",
author: ssbClient.id,
url: u,
tags: normalizeTags(tagsRaw),
description: description || "",
category: category || "",
createdAt: now,
updatedAt: now,
lastVisit: coerceLastVisit(lastVisit),
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateBookmarkById(id, updatedData) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found");
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
const aggBm = await this.getBookmarkById(id, userId);
if (aggBm && Object.keys(aggBm.opinions || {}).some(k => (aggBm.opinions[k] || 0) > 0)) throw new Error("Cannot edit bookmark after it has received opinions.");
const url = safeText(updatedData.url || oldMsg.content.url);
if (!url) throw new Error("URL is required");
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url,
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
});
},
async deleteBookmarkById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = safeText(opts.q || "").toLowerCase();
const sort = safeText(opts.sort || "recent");
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildBookmark(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((b) => String(b.author) === String(viewerId));
else if (filter === "recent") list = list.filter((b) => new Date(b.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((b) => {
const url = String(b.url || "").toLowerCase();
const cat = String(b.category || "").toLowerCase();
const desc = String(b.description || "").toLowerCase();
const tags = safeArr(b.tags).join(" ").toLowerCase();
const author = String(b.author || "").toLowerCase();
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getBookmarkById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Bookmark not found");
return buildBookmark(agg.contentNode, rootId, viewer, agg);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const bm = await this.getBookmarkById(id, userId);
if (!bm) throw new Error("Bookmark not found");
if (safeArr(bm.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "bookmarkOpinion", target: bm.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};

File diff suppressed because it is too large Load diff

875
src/models/chats_model.js Normal file
View file

@ -0,0 +1,875 @@
const pull = require("../server/node_modules/pull-stream")
const crypto = require("crypto")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { collabContent, openInviteOf } = require('../backend/collab_content')
const chatCollab = collabContent({ membersField: 'members', undecField: 'undecryptable', contentFields: ['title', 'description', 'image', 'category', 'status'], listFields: ['tags', 'invites'] })
const logLimit = getConfig().ssbLogStream?.limit || 1000
const safeArr = (v) => (Array.isArray(v) ? v : [])
const safeText = (v) => String(v || "").trim()
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const INVITE_CODE_BYTES = 16
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const ownCrypto = chatCrypto || tribeCrypto
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
const lookupKeys = (rid) => {
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
if (a.length) return a
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
}
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
const rotateChatKey = async (rootId, remainingMembers) => {
if (!ownCrypto || !tribeCrypto || !rootId) return
const existing = lookupKey(rootId)
if (!existing) return
const newKey = ownCrypto.generateTribeKey()
const newGen = ownCrypto.addNewKey(rootId, newKey)
if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
const ssbClient = await openSsb()
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
for (const m of remainingMembers) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
})
}
}
const ingestOwnTribeKeys = async () => {
if (!ownCrypto) return
try {
const ssbClient = await openSsb()
const ssbKeys = require("../server/node_modules/ssb-keys")
const config = require("../server/ssb_config")
const msgs = await readAll(ssbClient)
for (const m of msgs) {
const c = m.value && m.value.content
if (!c || c.type !== "tribe-keys") continue
const memberKeys = c.memberKeys
if (!memberKeys || typeof memberKeys !== "object") continue
const boxed = memberKeys[ssbClient.id]
if (!boxed) continue
try {
const unboxed = ssbKeys.unbox(boxed, config.keys)
const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
} catch (_) {}
}
} catch (_) {}
}
const getTribeKeysFor = async (tribeId) => {
if (!tribeCrypto || !tribesModel || !tribeId) return []
try {
const rootId = await tribesModel.getRootId(tribeId)
return tribeCrypto.getKeys(rootId) || []
} catch (_) { return [] }
}
const getTribeFirstKeyFor = async (tribeId) => {
const ks = await getTribeKeysFor(tribeId)
return ks.length ? ks[0] : null
}
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
)
const buildIndex = (messages) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const msgNodes = new Map()
const authorByKey = new Map()
const tombRequests = []
const memberMsgs = []
for (const m of messages) {
const k = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
if (c.type === "chatMember" && c.target && c.member) {
memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, code: typeof c.code === "string" ? c.code : "", author: v.author, ts: v.timestamp || m.timestamp || 0 })
continue
}
if (c.type === "chat") {
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
authorByKey.set(k, v.author)
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
} else if (c.type === "chatMessage") {
msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
authorByKey.set(k, v.author)
}
}
const strictParent = new Map()
const strictChild = new Map()
for (const [c1, p1] of parent.entries()) {
const childNode = nodes.get(c1)
const parentNode = nodes.get(p1)
if (childNode && parentNode && childNode.author === parentNode.author) {
strictParent.set(c1, p1)
strictChild.set(p1, c1)
}
}
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target)
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
}
const rootOf = (id) => { let cur = id; while (strictParent.has(cur)) cur = strictParent.get(cur); return cur }
const tipOf = (id) => { let cur = id; while (strictChild.has(cur)) cur = strictChild.get(cur); return cur }
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
const memberByRoot = new Map()
const consumedByRoot = new Map()
for (const mm of memberMsgs) {
if (!nodes.has(mm.target)) continue
const r = rootOf(mm.target)
if (!memberByRoot.has(r)) memberByRoot.set(r, new Map())
const perMember = memberByRoot.get(r)
const prev = perMember.get(mm.member)
if (!prev || mm.ts >= prev.ts) perMember.set(mm.member, { on: mm.on, ts: mm.ts })
if (mm.on && mm.code) {
if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set())
consumedByRoot.get(r).add(mm.code)
}
}
const isCodeConsumed = (rootId, code) => !!(code && consumedByRoot.has(rootId) && consumedByRoot.get(rootId).has(code))
const rawRootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur }
return { tomb, nodes, parent: strictParent, child: strictChild, rawParent: parent, rawChild: child, rawRootOf, rootOf, tipOf, tipByRoot, msgNodes, memberByRoot, consumedByRoot, isCodeConsumed }
}
const resolveKeyChainSets = (chatRootId) => {
if (!tribeCrypto) return []
const keys = lookupKeys(chatRootId)
return keys.map(k => [k])
}
const tryDecryptPublicInviteKey = (invites) => {
if (!tribeCrypto || !Array.isArray(invites)) return null
for (const inv of invites) {
if (!inv || typeof inv !== "object" || inv.public !== true) continue
if (typeof inv.code !== "string") continue
if (typeof inv.ek === "string") {
try {
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt)
if (k) return k
} catch (_) {}
}
if (typeof inv.ekChain === "string") {
try {
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt)
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key
} catch (_) {}
}
}
return null
}
const decryptChatContent = (rawC, rootId) => {
let c = rawC
let undecryptable = false
if (tribeCrypto && c.encryptedPayload) {
const keyChainSets = resolveKeyChainSets(rootId)
c = tribeCrypto.decryptContent(c, keyChainSets)
undecryptable = !!c._undecryptable
if (undecryptable) {
const pubKey = tryDecryptPublicInviteKey(rawC.invites)
if (pubKey) {
const retry = tribeCrypto.decryptContent(rawC, [[pubKey]])
if (retry && !retry._undecryptable) {
c = retry
undecryptable = false
}
}
}
}
return { c, undecryptable }
}
const aggregateMembers = (ownerContent, ownerAuthor, memberToggles) => {
const set = new Set()
if (ownerContent) for (const m of safeArr(ownerContent.members)) set.add(m)
if (ownerAuthor) set.add(ownerAuthor)
if (memberToggles) {
for (const [member, st] of memberToggles.entries()) {
if (member === ownerAuthor) continue
if (st.on) set.add(member)
else set.delete(member)
}
}
return [...set]
}
const buildChat = (node, rootId, idx) => {
const rawC = node.c || {}
if (rawC.type !== "chat") return null
const { c, undecryptable } = decryptChatContent(rawC, rootId)
let ownerContent = c
let ownerAuthor = c.author || node.author
if (idx) {
const ownerNode = idx.nodes.get(rootId)
if (ownerNode) {
const decOwner = decryptChatContent(ownerNode.c || {}, rootId)
ownerContent = decOwner.c
ownerAuthor = decOwner.c.author || ownerNode.author
}
}
const memberToggles = idx && idx.memberByRoot ? idx.memberByRoot.get(rootId) : null
const members = aggregateMembers(ownerContent, ownerAuthor, memberToggles)
const invites = safeArr(c.invites)
const hasPublicInvite = invites.some(inv => typeof inv === "object" && inv && inv.public === true)
const inferredStatus = c.status || (undecryptable ? (hasPublicInvite ? "OPEN" : "INVITE-ONLY") : "OPEN")
return {
key: node.key,
rootId,
title: c.title || "",
description: c.description || "",
image: c.image || null,
category: c.category || "",
status: inferredStatus,
tags: safeArr(c.tags),
members,
invites,
author: c.author || node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
encrypted: !!c.encrypted,
tribeId: c.tribeId || null,
undecryptable
}
}
const buildMessage = (node, chatRootId, tribeKeys = []) => {
const c = node.c || {}
if (c.type !== "chatMessage") return null
let text = c.text || ""
if (tribeCrypto && c.encryptedText) {
const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)]
for (const keyHex of candidateKeys) {
try {
text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
break
} catch (_) {}
}
}
return {
key: node.key,
chatId: c.chatId || "",
text,
image: c.image || null,
author: c.author || node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString()
}
}
const publishTombstone = async (ssbClient, tipId) =>
new Promise((resolve, reject) => {
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
})
const ensureMemberKeys = async (ssbClient, messages, chats) => {
if (!tribeCrypto) return
const distributed = new Map()
for (const m of messages) {
const c = m.value && m.value.content
if (!c || c.type !== "tribe-keys" || !c.tribeId) continue
const mk = c.memberKeys
if (!mk || typeof mk !== "object") continue
if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set())
for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id)
}
const ssbKeys = require("../server/node_modules/ssb-keys")
for (const chat of safeArr(chats)) {
if (!chat || chat.undecryptable) continue
const rootId = chat.rootId
if (!rootId) continue
const key = lookupKey(rootId)
if (!key) continue
const have = distributed.get(rootId) || new Set()
const missing = safeArr(chat.members).filter(m => m && m !== ssbClient.id && !have.has(m))
if (!missing.length) continue
const memberKeys = {}
for (const m of missing) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
}
if (!Object.keys(memberKeys).length) continue
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve())
})
}
}
const publishMemberToggle = async (ssbClient, rootId, member, on, code) =>
new Promise((resolve, reject) => {
const content = { type: "chatMember", target: rootId, member, on: !!on, createdAt: new Date().toISOString() }
if (typeof code === "string" && code) content.code = code
ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))
})
const collectChats = (idx) => {
const out = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "chat") continue
const chat = buildChat(node, rootId, idx)
if (chat) out.push(chat)
}
return out
}
return {
type: "chat",
async resolveRootId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) throw new Error("Not found")
let root = tip
while (idx.parent.has(root)) root = idx.parent.get(root)
return root
},
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) throw new Error("Not found")
return tip
},
async createChat(title, description, image, category, status, tagsRaw, tribeId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const blobId = image ? String(image).trim() || null : null
const tags = normalizeTags(tagsRaw)
const st = VALID_STATUS.includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
const now = new Date().toISOString()
let content = {
type: "chat",
title: safeText(title),
description: safeText(description),
image: blobId,
category: safeText(category),
status: st,
tags,
members: [userId],
invites: [],
author: userId,
createdAt: now,
updatedAt: now,
...(tribeId ? { tribeId } : {})
}
if (!tribeCrypto) {
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
if (tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
} catch (_) {}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
if (st === "OPEN") {
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
const chatKey = ownCrypto.generateTribeKey()
content = tribeCrypto.encryptContent(content, [chatKey], true)
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
ownCrypto.setKey(result.key, chatKey, 1)
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
})
} catch (_) {}
return result
},
async updateChatById(id, data) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
const item = await new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
})
const c = item.content
const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
if (rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let rootId = tipId
while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
const chat = buildChat(node, rootId, idx)
if (!chat) throw new Error("Invalid chat")
const ownerNode = idx.nodes.get(rootId)
const ownerContent = ownerNode ? decryptChatContent(ownerNode.c || {}, rootId).c : {}
const persistedMembers = safeArr(ownerContent.members)
let updated = {
type: "chat",
replaces: tipId,
title: data.title !== undefined ? safeText(data.title) : chat.title,
description: data.description !== undefined ? safeText(data.description) : chat.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
category: data.category !== undefined ? safeText(data.category) : chat.category,
status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
members: persistedMembers,
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString(),
...(chat.tribeId ? { tribeId: chat.tribeId } : {})
}
if (tribeCrypto) {
if (chat.tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
} catch (_) {}
} else {
const chatKey = lookupKey(rootId)
if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
}
}
return new Promise((resolve, reject) => {
ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
})
},
async deleteChatById(id) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Chat not found"))
if (item.content.author && item.content.author !== userId) return reject(new Error("Not the author"))
ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
})
})
},
async closeChatById(id) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) throw new Error("Not found")
let root = tip
while (idx.parent.has(root)) root = idx.parent.get(root)
const node = idx.nodes.get(tip)
if (!node) throw new Error("Not found")
const chat = buildChat(node, root, idx)
if (!chat) throw new Error("Invalid chat")
if (chat.author !== userId) throw new Error("Not the author")
const updated = {
type: "chat",
replaces: tip,
title: chat.title,
description: chat.description,
image: chat.image,
category: chat.category,
status: "CLOSED",
tags: chat.tags,
members: chat.members,
invites: chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString()
}
await publishTombstone(ssbClient, tip)
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))
})
},
async getChatById(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tip = id
while (idx.child.has(tip)) tip = idx.child.get(tip)
if (idx.tomb.has(tip)) return null
const node = idx.nodes.get(tip)
if (!node || node.c.type !== "chat") return null
let root = tip
while (idx.parent.has(root)) root = idx.parent.get(root)
const chat = buildChat(node, root, idx)
if (!chat) return null
return chatCollab.fold(chat, collectChats(idx))
},
async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
const ssbClient = await openSsb()
const uid = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const now = Date.now()
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
if (filter === "mine") list = list.filter(c => c.author === uid)
else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
else if (filter === "closed") list = list.filter(c => c.status === "CLOSED")
if (q) {
const qq = q.toLowerCase()
list = list.filter(c => {
const t = String(c.title || "").toLowerCase()
const d = String(c.description || "").toLowerCase()
const cat = String(c.category || "").toLowerCase()
const tags = safeArr(c.tags).join(" ").toLowerCase()
return t.includes(qq) || d.includes(qq) || cat.includes(qq) || tags.includes(qq)
})
}
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
try { await ensureMemberKeys(ssbClient, messages, list) } catch (_) {}
return list
},
async generateInvite(chatId, opts = {}) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
if (chat.author !== userId) throw new Error("Only the author can generate invites")
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
let invite = code
const pubFlag = opts.public ? { public: true } : {}
if (tribeCrypto) {
const inviteSalt = tribeCrypto.generateInviteSalt()
const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
if (ekChain) {
invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId), ...pubFlag }
} else {
const chatKey = lookupKey(chat.rootId)
if (chatKey) {
const ek = tribeCrypto.encryptForInvite(chatKey, code, inviteSalt)
invite = { code, ek, salt: inviteSalt, gen: lookupGen(chat.rootId), ...pubFlag }
}
}
}
if (opts.public && typeof invite !== "object") invite = { code, public: true }
const invites = [...chat.invites, invite]
await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
return code
},
async getOpenInvite(chatId) {
return openInviteOf(await this.getChatById(chatId).catch(() => null))
},
async generateOpenInvite(chatId) {
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
const existing = (Array.isArray(chat.invites) ? chat.invites : []).find(inv => typeof inv === "object" && inv.public === true)
if (existing) throw new Error("An open invitation already exists")
return this.generateInvite(chatId, { public: true })
},
async removeOpenInvite(chatId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
if (chat.author !== userId) throw new Error("Only the author can remove invites")
const invites = (Array.isArray(chat.invites) ? chat.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true))
await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
},
async joinByInvite(code) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let matchedChat = null
let matchedInvite = null
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "chat") continue
const chat = buildChat(node, rootId, idx)
if (!chat || !chat.invites.length) continue
for (const inv of chat.invites) {
if (typeof inv === "string" && inv === code) {
matchedChat = chat; matchedInvite = inv; break
}
if (typeof inv === "object" && inv.code === code) {
matchedChat = chat; matchedInvite = inv; break
}
}
if (matchedChat) break
}
if (!matchedChat) throw new Error("Invalid or expired invite code")
if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
const isPublic = typeof matchedInvite === "object" && matchedInvite.public === true
if (!isPublic && idx.isCodeConsumed(matchedChat.rootId, code)) throw new Error("Invite already used")
let chatKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
}
}
chatKey = chain[0].key
}
} else if (matchedInvite.ek) {
chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
}
}
await publishMemberToggle(ssbClient, matchedChat.rootId, userId, true, isPublic ? undefined : code)
if (tribeCrypto && chatKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
if (matchedChat.author && matchedChat.author !== userId) {
try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: lookupGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matchedChat.key
},
async joinChat(chatId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
if (chat.status === "CLOSED") throw new Error("Chat is closed")
if (chat.members.includes(userId)) return chat.key
if (tribeCrypto && Array.isArray(chat.invites)) {
const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
if (pub) return await this.joinByInvite(pub.code)
}
await publishMemberToggle(ssbClient, chat.rootId, userId, true)
return chat.key
},
async leaveChat(chatId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
if (chat.author === userId) throw new Error("Author cannot leave their own chat")
const members = chat.members.filter(m => m !== userId)
await publishMemberToggle(ssbClient, chat.rootId, userId, false)
try { await rotateChatKey(chat.rootId, members) } catch (_) {}
},
async ingestKeys() { await ingestOwnTribeKeys() },
async pruneOrphanKeys() {
if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
try {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const live = new Set()
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "chat") live.add(m.key)
}
const tomb = buildValidatedTombstoneSet(messages)
const all = ownCrypto.getAllRootIds()
let removed = 0
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
}
}
return removed
} catch (_) { return 0 }
},
async sendMessage(chatId, text, image = null) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const chat = await this.getChatById(chatId)
if (!chat) throw new Error("Chat not found")
if (chat.status === "CLOSED") throw new Error("Chat is closed")
if (!chat.members.includes(userId)) {
if (chat.status === "OPEN") await this.joinChat(chatId)
else throw new Error("Not a participant")
}
const messages = await readAll(ssbClient)
const oneHourAgo = Date.now() - 60 * 60 * 1000
const recentCount = messages.filter(m => {
const c = m.value?.content
return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
}).length
if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
const now = new Date().toISOString()
let content = {
type: "chatMessage",
chatId: chat.rootId,
author: userId,
createdAt: now
}
if (image) content.image = image
const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId)
if (chatIsEncrypted && tribeCrypto) {
let encKey = null
if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
if (!encKey) encKey = lookupKey(chat.rootId)
if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`)
content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
if (chat.tribeId) content.tribeId = chat.tribeId
} else {
content.text = safeText(text)
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async listMessages(chatRootId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let tribeId = null
const tipId = idx.tipByRoot.get(chatRootId) || chatRootId
const chatNode = idx.nodes.get(tipId) || idx.nodes.get(chatRootId)
if (chatNode?.c?.tribeId) tribeId = chatNode.c.tribeId
const tribeKeys = tribeId ? await getTribeKeysFor(tribeId) : []
const wantRoot = idx.rawRootOf(chatRootId)
const result = []
for (const [k, node] of idx.msgNodes.entries()) {
const cid = node.c.chatId
if (cid !== chatRootId && idx.rawRootOf(cid) !== wantRoot) continue
const msg = buildMessage(node, chatRootId, tribeKeys)
if (msg) result.push(msg)
}
result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
return result
},
async getParticipants(chatRootId) {
const chat = await this.getChatById(chatRootId)
if (!chat) return []
return chat.members
}
}
}

View file

@ -0,0 +1,52 @@
const crypto = require('crypto');
function generateKeyFromPassword(password, salt) {
return crypto.scryptSync(password, salt, 32);
}
function encryptText(text, password) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = generateKeyFromPassword(password, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encryptedText = cipher.update(text, 'utf-8', 'hex');
encryptedText += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
const ivHex = iv.toString('hex');
const saltHex = salt.toString('hex');
return { encryptedText, iv: ivHex, salt: saltHex, authTag };
}
function decryptText(encryptedText, password, ivHex, saltHex, authTagHex) {
const salt = Buffer.from(saltHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const key = generateKeyFromPassword(password, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decryptedText = decipher.update(encryptedText, 'hex', 'utf-8');
decryptedText += decipher.final('utf-8');
return decryptedText;
}
function extractComponents(encryptedText) {
const iv = encryptedText.slice(0, 24);
const salt = encryptedText.slice(24, 56);
const authTag = encryptedText.slice(56, 88);
const encrypted = encryptedText.slice(88);
return { iv, salt, authTag, encrypted };
}
module.exports = {
encryptData: (text, password) => {
const { encryptedText, iv, salt, authTag } = encryptText(text, password);
return { encryptedText: iv + salt + authTag + encryptedText, iv, salt, authTag };
},
decryptData: (encryptedText, password) => {
const { iv, salt, authTag, encrypted } = extractComponents(encryptedText);
const decryptedText = decryptText(encrypted, password, iv, salt, authTag);
return decryptedText;
},
generateKey: (length = 32) => crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length),
extractComponents
};

964
src/models/courts_model.js Normal file
View file

@ -0,0 +1,964 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const CASE_ANSWER_DAYS = 7;
const CASE_EVIDENCE_DAYS = 14;
const CASE_DECISION_DAYS = 21;
const POPULAR_DAYS = 14;
const FEED_ID_RE = /^@.+\.ed25519$/;
const CASE_FIELDS = ['title', 'accuser', 'respondentId', 'mediatorsAccuser', 'mediatorsRespondent'];
const MEDIATORS_FIELDS = ['mediators'];
const EVIDENCE_FIELDS = ['text', 'link', 'imageUrl'];
const ANSWER_FIELDS = ['stance', 'text'];
const VERDICT_FIELDS = ['result', 'orders'];
const SETTLEMENT_FIELDS = ['terms'];
module.exports = ({ cooler, services = {}, tribeCrypto }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
const nowISO = () => new Date().toISOString();
const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
const encryptFields = (content, keyHex, fields) => {
const payload = {};
for (const f of fields) {
if (content[f] !== undefined) payload[f] = content[f];
}
const enc = tribeCrypto.encryptWithKey(JSON.stringify(payload), keyHex);
const result = { ...content };
for (const f of fields) delete result[f];
result.encryptedPayload = enc;
return result;
};
const decryptFields = (content, keyHex) => {
if (!content || !content.encryptedPayload) return content;
try {
const plain = tribeCrypto.decryptWithKey(content.encryptedPayload, keyHex);
const payload = JSON.parse(plain);
const result = { ...content };
delete result.encryptedPayload;
return { ...result, ...payload };
} catch (e) {
return { ...content, encrypted: true };
}
};
const getCaseKey = (obj) => {
if (!tribeCrypto || !obj) return null;
return tribeCrypto.getKey(obj.rootCaseId || obj.id);
};
const distributeKey = async (caseKey, caseRootId, recipientId) => {
if (!tribeCrypto || !recipientId) return;
const ssbClient = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const boxed = tribeCrypto.boxKeyForMember(caseKey, recipientId, ssbKeys);
const content = { type: 'courts-key', caseRootId, for: recipientId, memberKey: boxed };
await new Promise((res, rej) => ssbClient.publish(content, (e) => e ? rej(e) : res()));
};
async function readLog() {
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, arr) => (err ? reject(err) : resolve(arr)))
);
});
}
async function listByType(type) {
const msgs = await readLog();
const tomb = buildValidatedTombstoneSet(msgs);
const rep = new Map();
const map = new Map();
for (const m of msgs) {
const k = m.key || m.id;
const c = m.value?.content || m.content;
if (!c) continue;
if (c.type === type) {
if (c.replaces) rep.set(c.replaces, k);
map.set(k, { id: k, ...c });
}
}
for (const oldId of rep.keys()) map.delete(oldId);
for (const tId of tomb) map.delete(tId);
return [...map.values()];
}
async function getCurrentUserId() {
await openSsb();
return userId;
}
async function resolveRespondent(candidateInput) {
const s = String(candidateInput || '').trim();
if (!s) return null;
if (FEED_ID_RE.test(s)) {
return { type: 'inhabitant', id: s };
}
if (services.tribes && services.tribes.getTribeById) {
try {
const t = await services.tribes.getTribeById(s);
if (t && t.id) return { type: 'tribe', id: t.id };
} catch {}
}
return null;
}
function computeDeadlines(openedAt) {
const answerBy = moment(openedAt).add(CASE_ANSWER_DAYS, 'days').toISOString();
const evidenceBy = moment(openedAt).add(CASE_EVIDENCE_DAYS, 'days').toISOString();
const decisionBy = moment(openedAt).add(CASE_DECISION_DAYS, 'days').toISOString();
return { answerBy, evidenceBy, decisionBy };
}
async function openCase({ titleBase, respondentInput, method }) {
const ssbClient = await openSsb();
const rawTitle = String(titleBase || '').trim();
if (!rawTitle) throw new Error('Title is required.');
const resp = await resolveRespondent(respondentInput);
if (!resp) throw new Error('Accused / Respondent not found.');
const m = String(method || '').trim().toUpperCase();
const ALLOWED = new Set(['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY']);
if (!ALLOWED.has(m)) throw new Error('Invalid resolution method.');
let dictatorId = null;
if (m === 'DICTATOR') {
if (!(services.parliament && services.parliament.getGovernmentCard)) throw new Error('DICTATOR method requires DICTATORSHIP government.');
try {
var gov = await services.parliament.getGovernmentCard();
} catch (e) {
throw new Error('Unable to verify government method for DICTATOR.');
}
const gm = String(gov && gov.method ? gov.method : '').toUpperCase();
if (gm !== 'DICTATORSHIP') throw new Error('DICTATOR method requires DICTATORSHIP government.');
dictatorId = gov && gov.powerType === 'inhabitant' ? gov.powerId : null;
if (!dictatorId) throw new Error('No ruling dictator to assign.');
}
const openedAt = nowISO();
const prefix = moment(openedAt).format('MM/YYYY') + '_';
const title = prefix + rawTitle;
const { answerBy, evidenceBy, decisionBy } = computeDeadlines(openedAt);
const content = {
type: 'courtsCase',
title,
accuser: userId,
respondentType: resp.type,
respondentId: resp.id,
method: m,
status: 'OPEN',
openedAt,
answerBy,
evidenceBy,
decisionBy,
mediatorsAccuser: [],
mediatorsRespondent: [],
...(dictatorId ? { dictatorId } : {}),
createdAt: openedAt
};
if (tribeCrypto) {
const stub = { ...content };
for (const f of CASE_FIELDS) delete stub[f];
const initialMsg = await new Promise((res, rej) =>
ssbClient.publish(stub, (err, msg) => (err ? rej(err) : res(msg)))
);
const caseRootId = initialMsg.key;
const caseKey = tribeCrypto.generateTribeKey();
tribeCrypto.setKey(caseRootId, caseKey, 1);
const encrypted = encryptFields({ ...content, rootCaseId: caseRootId }, caseKey, CASE_FIELDS);
const update = { ...encrypted, replaces: caseRootId, updatedAt: openedAt };
const finalMsg = await new Promise((res, rej) =>
ssbClient.publish(update, (err, msg) => (err ? rej(err) : res(msg)))
);
await distributeKey(caseKey, caseRootId, resp.id);
if (dictatorId) await distributeKey(caseKey, caseRootId, dictatorId);
return finalMsg;
}
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function loadAllCases() {
const msgs = await readLog();
const idx = collectCaseNodes(msgs);
const out = [];
for (const [k, n] of idx.nodes) {
if (n.c.replaces) continue;
const base = strictCaseBase(msgs, k, idx);
if (base) out.push(await assembleCase(msgs, base));
}
return out;
}
async function listCases(filter = 'open') {
const decrypted = await loadAllCases();
const sorted = decrypted.sort((a, b) => {
const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
return tb - ta;
});
if (filter === 'open') {
return sorted.filter((c) => {
const s = String(c.status || '').toUpperCase();
return s !== 'DECIDED' && s !== 'CLOSED' && s !== 'SOLVED' && s !== 'UNSOLVED' && s !== 'DISCARDED';
});
}
if (filter === 'history') {
return sorted.filter((c) => {
const s = String(c.status || '').toUpperCase();
return s === 'DECIDED' || s === 'CLOSED' || s === 'SOLVED' || s === 'UNSOLVED' || s === 'DISCARDED';
});
}
return sorted;
}
async function listCasesForUser(uid) {
const all = await loadAllCases();
const id = String(uid || userId || '');
const supportsMap = await countSupportsByCase();
const settlementsMap = await countSettlementsByCase();
const rows = [];
for (const c of all) {
const isAccuser = String(c.accuser || '') === id;
const isRespondent = String(c.respondentId || '') === id;
const ma = ensureArray(c.mediatorsAccuser || []);
const mr = ensureArray(c.mediatorsRespondent || []);
const isMediator = ma.includes(id) || mr.includes(id);
const isJudge = String(c.judgeId || '') === id;
const isDictator = String(c.dictatorId || '') === id;
const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
if (!mine) continue;
let myPublicPreference = null;
if (isAccuser && typeof c.publicPrefAccuser === 'boolean') {
myPublicPreference = c.publicPrefAccuser;
} else if (isRespondent && typeof c.publicPrefRespondent === 'boolean') {
myPublicPreference = c.publicPrefRespondent;
}
const rootId = c.rootCaseId || c.id;
const tally = c.voteId ? await voteTally(c.voteId) : null;
rows.push({
...c,
respondent: c.respondentId || c.respondent,
isAccuser,
isRespondent,
isMediator,
isJudge,
isDictator,
mine,
myPublicPreference,
supportCount: supportsMap.get(String(rootId)) || supportsMap.get(String(c.id)) || 0,
hasSettlement: (settlementsMap.get(String(rootId)) || settlementsMap.get(String(c.id)) || 0) > 0,
yes: tally ? tally.yes : 0,
no: tally ? tally.no : 0,
total: tally ? tally.total : 0,
needed: tally ? tally.needed : 0
});
}
rows.sort((a, b) => {
const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
return tb - ta;
});
return rows;
}
function collectCaseNodes(msgs) {
const nodes = new Map();
for (const m of msgs) {
const c = m.value?.content || m.content;
if (!c || c.type !== 'courtsCase') continue;
nodes.set(m.key || m.id, { key: m.key || m.id, author: m.value?.author, c });
}
const strictNext = new Map(), strictPrev = new Map();
for (const [k, n] of nodes) {
const t = n.c.replaces;
if (t) { const o = nodes.get(t); if (o && o.author === n.author) { strictNext.set(t, k); strictPrev.set(k, t); } }
}
return { nodes, strictNext, strictPrev };
}
function strictCaseBase(msgs, id, idxIn) {
const idx = idxIn || collectCaseNodes(msgs);
const { nodes, strictNext, strictPrev } = idx;
const tomb = buildValidatedTombstoneSet(msgs);
let startKey = nodes.has(id) ? id : null;
if (!startKey) {
for (const [k, n] of nodes) { if (String(n.c.rootCaseId || '') === id) { startKey = k; break; } }
}
if (!startKey) return null;
let root = startKey, g = 0; while (strictPrev.has(root) && g++ < 100000) root = strictPrev.get(root);
let tip = root; g = 0; while (strictNext.has(tip) && g++ < 100000) tip = strictNext.get(tip);
if (tomb.has(tip) || tomb.has(root)) return null;
const node = nodes.get(tip);
if (!node) return null;
let c = { id: tip, ...node.c };
if (tribeCrypto) { const key = getCaseKey(c); if (key) c = decryptFields(c, key); }
if (!c.rootCaseId) c.rootCaseId = root;
return c;
}
async function assembleCase(msgs, base) {
const accuser = String(base.accuser || '');
const respondentId = String(base.respondentId || '');
const rootCaseId = String(base.rootCaseId || base.id || '');
const idStr = String(base.id || '');
const method = String(base.method || '').toUpperCase();
const dictatorId = String(base.dictatorId || '');
const caseKey = getCaseKey(base);
const isParty = (a) => a === accuser || a === respondentId;
const matchC = (c) => { const cid = String(c.caseId || ''); return cid === rootCaseId || cid === idStr; };
let medA = ensureArray(base.mediatorsAccuser || []), medR = ensureArray(base.mediatorsRespondent || []);
let medATs = 0, medRTs = 0, judgeId = '', judgeTs = 0, voteId = String(base.voteId || ''), voteTs = 0;
let prefA = base.publicPrefAccuser, prefATs = 0, prefR = base.publicPrefRespondent, prefRTs = 0;
let answered = false, settled = false, verdict = null, verdictTs = 0, verdictAt = null, settledAt = null;
for (const m of msgs) {
const c = m.value?.content || m.content; if (!c) continue;
const author = String(m.value?.author || '');
const ts = m.value?.timestamp || m.timestamp || 0;
if (c.type === 'courtsMediators' && matchC(c)) {
let dec = c; if (tribeCrypto && caseKey) dec = decryptFields(c, caseKey);
const list = ensureArray(dec.mediators || []).map(x => String(x || '').trim()).filter(Boolean).filter(x => x !== accuser && x !== respondentId);
if (c.side === 'accuser' && author === accuser && ts >= medATs) { medA = list; medATs = ts; }
else if (c.side === 'respondent' && author === respondentId && ts >= medRTs) { medR = list; medRTs = ts; }
} else if (c.type === 'courtsJudge' && matchC(c) && isParty(author) && ts >= judgeTs) {
const jid = String(c.judgeId || '').trim();
if (jid && jid !== accuser && jid !== respondentId) { judgeId = jid; judgeTs = ts; }
} else if (c.type === 'courtsVoteLink' && matchC(c) && isParty(author) && ts >= voteTs) {
voteId = String(c.voteId || ''); voteTs = ts;
} else if (c.type === 'courtsVisibility' && matchC(c)) {
if (c.side === 'accuser' && author === accuser && ts >= prefATs) { prefA = !!c.pref; prefATs = ts; }
else if (c.side === 'respondent' && author === respondentId && ts >= prefRTs) { prefR = !!c.pref; prefRTs = ts; }
} else if (c.type === 'courtsAnswer' && matchC(c) && author === respondentId) {
answered = true;
} else if (c.type === 'courtsSettlementAccepted' && matchC(c) && isParty(author)) {
settled = true; settledAt = c.createdAt || new Date(ts).toISOString();
} else if (c.type === 'courtsVerdict' && matchC(c) && ts >= verdictTs) {
verdict = author; verdictTs = ts; verdictAt = c.createdAt || new Date(ts).toISOString();
}
}
let verdictValid = false;
if (verdict) {
if (method === 'JUDGE') verdictValid = (verdict === judgeId);
else if (method === 'MEDIATION') verdictValid = (medA.includes(verdict) || medR.includes(verdict));
else if (method === 'DICTATOR') verdictValid = (verdict === dictatorId);
}
let voteDecided = false;
if ((method === 'POPULAR' || method === 'KARMATOCRACY') && voteId) {
try { const t = await voteTally(voteId); if (t && t.closed && t.total > 0) voteDecided = true; } catch (_) {}
}
base.mediatorsAccuser = medA;
base.mediatorsRespondent = medR;
if (judgeId) base.judgeId = judgeId; else delete base.judgeId;
if (voteId) base.voteId = voteId;
if (typeof prefA === 'boolean') base.publicPrefAccuser = prefA;
if (typeof prefR === 'boolean') base.publicPrefRespondent = prefR;
base.status = (verdictValid || voteDecided) ? 'DECIDED' : settled ? 'CLOSED' : answered ? 'IN_PROGRESS' : 'OPEN';
if (verdictValid) { base.verdictAt = verdictAt; base.decidedAt = verdictAt; }
else if (voteDecided) { base.decidedAt = base.decidedAt || nowISO(); }
else if (settled) { base.closedAt = settledAt; base.decidedAt = settledAt; }
return base;
}
async function getCaseById(caseId) {
const id = String(caseId || '').trim();
if (!id) return null;
const msgs = await readLog();
const base = strictCaseBase(msgs, id);
if (!base) return null;
return await assembleCase(msgs, base);
}
function getCaseRole(caseObj, uid) {
const id = String(uid || '');
if (!id) return 'OTHER';
if (String(caseObj.accuser || '') === id) return 'ACCUSER';
if (String(caseObj.respondentId || '') === id) return 'DEFENCE';
const ma = ensureArray(caseObj.mediatorsAccuser || []);
const mr = ensureArray(caseObj.mediatorsRespondent || []);
if (ma.includes(id) || mr.includes(id)) return 'MEDIATOR';
if (String(caseObj.judgeId || '') === id) return 'JUDGE';
return 'OTHER';
}
async function setMediators({ caseId, side, mediators }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = side === 'accuser' ? 'ACCUSER' : side === 'respondent' ? 'DEFENCE' : null;
if (!role) throw new Error('Invalid side.');
const myRole = getCaseRole(c, userId);
if (role === 'ACCUSER' && myRole !== 'ACCUSER') throw new Error('Only accuser can set these mediators.');
if (role === 'DEFENCE' && myRole !== 'DEFENCE') throw new Error('Only defence can set these mediators.');
const list = Array.from(
new Set(
ensureArray(mediators || [])
.map((x) => String(x || '').trim())
.filter(Boolean)
)
);
const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
const caseRootId = c.rootCaseId || c.id;
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) {
for (const mediatorId of clean) {
await distributeKey(caseKey, caseRootId, mediatorId);
}
}
}
const ssbClient = await openSsb();
let content = { type: 'courtsMediators', caseId: caseRootId, side, mediators: clean, author: userId, createdAt: nowISO() };
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) content = encryptFields(content, caseKey, MEDIATORS_FIELDS);
}
await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve())));
if (side === 'accuser') c.mediatorsAccuser = clean;
else c.mediatorsRespondent = clean;
return c;
}
async function assignJudge({ caseId, judgeId }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const m = String(c.method || '').toUpperCase();
if (m !== 'JUDGE') throw new Error('This case does not use a judge.');
const myRole = getCaseRole(c, userId);
if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can assign a judge.');
const id = String(judgeId || '').trim();
if (!id) throw new Error('Judge ID is required.');
if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
if (id === String(c.accuser || '') || id === String(c.respondentId || '')) {
throw new Error('Judge cannot be a party of the case.');
}
const caseRootId = c.rootCaseId || c.id;
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) {
await distributeKey(caseKey, caseRootId, id);
}
}
const ssbClient = await openSsb();
const content = { type: 'courtsJudge', caseId: caseRootId, judgeId: id, author: userId, createdAt: nowISO() };
await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve())));
c.judgeId = id;
return c;
}
async function addEvidence({ caseId, text, link, imageMarkdown }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role === 'OTHER') throw new Error('You are not involved in this case.');
const t = String(text || '').trim();
const l = String(link || '').trim();
let imageUrl = null;
if (imageMarkdown) {
const match = imageMarkdown.match(/\(([^)]+)\)/);
imageUrl = match ? match[1] : imageMarkdown;
}
if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
const ssbClient = await openSsb();
let content = {
type: 'courtsEvidence',
caseId: c.rootCaseId || c.id,
author: userId,
role,
text: t,
link: l,
imageUrl,
createdAt: nowISO()
};
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) content = encryptFields(content, caseKey, EVIDENCE_FIELDS);
}
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function answerCase({ caseId, stance, text }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
if (String(c.respondentId || '') !== String(userId || '')) throw new Error('Only the respondent can answer.');
const s = String(stance || '').trim().toUpperCase();
const ALLOWED = new Set(['DENY', 'ADMIT', 'PARTIAL']);
if (!ALLOWED.has(s)) throw new Error('Invalid stance.');
const t = String(text || '').trim();
if (!t) throw new Error('Response text is required.');
const ssbClient = await openSsb();
let content = {
type: 'courtsAnswer',
caseId: c.rootCaseId || c.id,
respondent: userId,
stance: s,
text: t,
createdAt: nowISO()
};
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) content = encryptFields(content, caseKey, ANSWER_FIELDS);
}
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'IN_PROGRESS';
return c;
}
async function issueVerdict({ caseId, result, orders }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const me = String(userId || '');
const isParty = String(c.accuser || '') === me || String(c.respondentId || '') === me;
if (isParty) throw new Error('A party cannot issue the verdict.');
const method = String(c.method || '').toUpperCase();
const isMediator = ensureArray(c.mediatorsAccuser || []).includes(me) || ensureArray(c.mediatorsRespondent || []).includes(me);
if (method === 'JUDGE') {
if (String(c.judgeId || '') !== me) throw new Error('Only the assigned judge can issue the verdict.');
} else if (method === 'MEDIATION') {
if (!isMediator) throw new Error('Only an appointed mediator can issue the verdict.');
} else if (method === 'DICTATOR') {
if (String(c.dictatorId || '') !== me) throw new Error('Only the dictator can issue the verdict.');
} else {
throw new Error('This method is resolved by public vote, not by a verdict.');
}
const r = String(result || '').trim();
if (!r) throw new Error('Result is required.');
const o = String(orders || '').trim();
const ssbClient = await openSsb();
let content = {
type: 'courtsVerdict',
caseId: c.rootCaseId || c.id,
judgeId: userId,
result: r,
orders: o,
createdAt: nowISO()
};
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) content = encryptFields(content, caseKey, VERDICT_FIELDS);
}
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'DECIDED';
c.verdictAt = nowISO();
c.ruledBy = userId;
return c;
}
async function proposeSettlement({ caseId, terms }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role === 'OTHER') throw new Error('You are not involved in this case.');
const t = String(terms || '').trim();
if (!t) throw new Error('Terms are required.');
const ssbClient = await openSsb();
let content = {
type: 'courtsSettlementProposal',
caseId: c.rootCaseId || c.id,
proposer: userId,
terms: t,
createdAt: nowISO()
};
if (tribeCrypto) {
const caseKey = getCaseKey(c);
if (caseKey) content = encryptFields(content, caseKey, SETTLEMENT_FIELDS);
}
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function acceptSettlement({ caseId }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const role = getCaseRole(c, userId);
if (role !== 'ACCUSER' && role !== 'DEFENCE') throw new Error('Only parties can accept a settlement.');
const ssbClient = await openSsb();
const content = {
type: 'courtsSettlementAccepted',
caseId: c.rootCaseId || c.id,
by: userId,
createdAt: nowISO()
};
await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
c.status = 'CLOSED';
return c;
}
async function supportCase({ caseId }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
if (getCaseRole(c, userId) !== 'OTHER') throw new Error('Parties cannot support their own case.');
const rootId = c.rootCaseId || c.id;
const supports = await listByType('courtsSupport');
const already = supports.find(s => String(s.caseId || '') === String(rootId) && String(s.supporter || '') === String(userId || ''));
if (already) throw new Error('You already support this case.');
const ssbClient = await openSsb();
const content = { type: 'courtsSupport', caseId: rootId, supporter: userId, createdAt: nowISO() };
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function countSupportsByCase() {
const supports = await listByType('courtsSupport');
const map = new Map();
for (const s of supports) {
const k = String(s.caseId || '');
if (!k) continue;
map.set(k, (map.get(k) || 0) + 1);
}
return map;
}
async function countSettlementsByCase() {
const items = await listByType('courtsSettlementProposal');
const map = new Map();
for (const s of items) {
const k = String(s.caseId || '');
if (!k) continue;
map.set(k, (map.get(k) || 0) + 1);
}
return map;
}
async function voteTally(voteId) {
if (!voteId || !services.votes || !services.votes.getVoteById) return null;
try {
const v = await services.votes.getVoteById(voteId);
const vm = v && v.votes ? v.votes : {};
const yes = Number(vm.YES ?? vm.Yes ?? vm.yes ?? 0);
const no = Number(vm.NO ?? vm.No ?? vm.no ?? 0);
const sum = Object.values(vm).reduce((s, n) => s + Number(n || 0), 0);
const total = Number(v.totalVotes ?? v.total ?? sum);
const deadline = v.deadline || v.endAt || v.expiresAt || null;
const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
const needed = Math.floor(total / 2) + 1;
return { yes, no, total, needed, closed, deadline };
} catch { return null; }
}
async function sweepCases() {
return;
}
async function setPublicPreference({ caseId, preference }) {
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const id = String(userId || '');
const pref = !!preference;
let side = null;
if (String(c.accuser || '') === id) { side = 'accuser'; c.publicPrefAccuser = pref; }
else if (String(c.respondentId || '') === id) { side = 'respondent'; c.publicPrefRespondent = pref; }
else throw new Error('Only parties can set visibility preference.');
const ssbClient = await openSsb();
const content = { type: 'courtsVisibility', caseId: c.rootCaseId || c.id, side, pref, author: id, createdAt: nowISO() };
await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve())));
return c;
}
async function openPopularVote({ caseId }) {
if (!services.votes || !services.votes.createVote) throw new Error('Votes service not available.');
const c = await getCaseById(caseId);
if (!c) throw new Error('Case not found.');
const m = String(c.method || '').toUpperCase();
if (m !== 'POPULAR' && m !== 'KARMATOCRACY') throw new Error('This case does not use public voting.');
if (c.voteId) throw new Error('Vote already opened.');
const myRole = getCaseRole(c, userId);
if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can open the vote.');
const question = c.title || `Case ${caseId}`;
const deadline = moment().add(POPULAR_DAYS, 'days').toISOString();
const voteMsg = await services.votes.createVote(
question,
deadline,
['YES', 'NO', 'ABSTENTION'],
[`courtsCase:${caseId}`, `courtsMethod:${m}`]
);
const voteId = voteMsg.key || voteMsg.id;
const ssbClient = await openSsb();
const content = { type: 'courtsVoteLink', caseId: c.rootCaseId || c.id, voteId, author: userId, createdAt: nowISO() };
await new Promise((resolve, reject) => ssbClient.publish(content, (err) => (err ? reject(err) : resolve())));
c.voteId = voteId;
return c;
}
async function getInhabitantKarma(feedId) {
if (services.banking && services.banking.getUserEngagementScore) {
try {
const v = await services.banking.getUserEngagementScore(feedId);
return Number(v || 0) || 0;
} catch {
return 0;
}
}
return 0;
}
async function getFirstUserTimestamp(feedId) {
const ssbClient = await openSsb();
return new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: feedId, reverse: false }),
pull.filter((m) => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(Date.now());
const m = arr[0];
const ts = (m.value && m.value.timestamp) || m.timestamp || Date.now();
resolve(ts < 1e12 ? ts * 1000 : ts);
})
);
});
}
async function nominateJudge({ judgeId }) {
const id = String(judgeId || '').trim();
if (!id) throw new Error('Judge ID is required.');
if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
const ssbClient = await openSsb();
const content = {
type: 'courtsNomination',
judgeId: id,
createdAt: nowISO()
};
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function voteNomination(nominationId) {
const id = String(nominationId || '').trim();
if (!id) throw new Error('Nomination not found.');
const nominations = await listByType('courtsNomination');
const nomination = nominations.find((n) => n.id === id);
if (!nomination) throw new Error('Nomination not found.');
if (String(nomination.judgeId || '') === String(userId || '')) {
throw new Error('You cannot vote for yourself.');
}
const votes = await listByType('courtsNominationVote');
const already = votes.find(
(v) =>
String(v.nominationId || '') === id &&
String(v.voter || '') === String(userId || '')
);
if (already) throw new Error('You have already voted.');
const ssbClient = await openSsb();
const content = {
type: 'courtsNominationVote',
nominationId: id,
voter: userId,
createdAt: nowISO()
};
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
);
}
async function listNominations() {
const nominations = await listByType('courtsNomination');
const votes = await listByType('courtsNominationVote');
const byId = new Map();
for (const n of nominations) {
byId.set(n.id, { ...n, supports: 0, karma: 0, profileSince: 0 });
}
for (const v of votes) {
const rec = byId.get(v.nominationId);
if (rec) rec.supports = (rec.supports || 0) + 1;
}
const rows = [];
for (const rec of byId.values()) {
const karma = await getInhabitantKarma(rec.judgeId);
const since = await getFirstUserTimestamp(rec.judgeId);
rows.push({ ...rec, karma, profileSince: since });
}
rows.sort((a, b) => {
if ((b.supports || 0) !== (a.supports || 0)) return (b.supports || 0) - (a.supports || 0);
if ((b.karma || 0) !== (a.karma || 0)) return (b.karma || 0) - (a.karma || 0);
if ((a.profileSince || 0) !== (b.profileSince || 0)) return (a.profileSince || 0) - (b.profileSince || 0);
const ta = new Date(a.createdAt || 0).getTime();
const tb = new Date(b.createdAt || 0).getTime();
if (ta !== tb) return ta - tb;
return String(a.judgeId || '').localeCompare(String(b.judgeId || ''));
});
return rows;
}
async function getCaseDetails({ caseId }) {
const id = String(caseId || '').trim();
if (!id) return null;
const base = await getCaseById(id);
if (!base) return null;
const currentUser = await getCurrentUserId();
const me = String(currentUser || '');
const accuserId = String(base.accuser || '');
const respondentId = String(base.respondentId || '');
const ma = ensureArray(base.mediatorsAccuser || []);
const mr = ensureArray(base.mediatorsRespondent || []);
const judgeId = String(base.judgeId || '');
const dictatorId = String(base.dictatorId || '');
const isAccuser = accuserId === me;
const isRespondent = respondentId === me;
const isMediator = ma.includes(me) || mr.includes(me);
const isJudge = judgeId === me;
const isDictator = dictatorId === me;
const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
let myPublicPreference = null;
if (isAccuser && typeof base.publicPrefAccuser === 'boolean') {
myPublicPreference = base.publicPrefAccuser;
} else if (isRespondent && typeof base.publicPrefRespondent === 'boolean') {
myPublicPreference = base.publicPrefRespondent;
}
const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
const caseKey = getCaseKey(base);
const caseRootId = base.rootCaseId || base.id;
const matchCase = (e) => {
const eCaseId = String(e.caseId || '');
return eCaseId === id || eCaseId === caseRootId;
};
const tryDecrypt = (item) => {
if (!caseKey) return item;
return decryptFields(item, caseKey);
};
const evidencesAll = await listByType('courtsEvidence');
const answersAll = await listByType('courtsAnswer');
const settlementsAll = await listByType('courtsSettlementProposal');
const verdictsAll = await listByType('courtsVerdict');
const acceptedAll = await listByType('courtsSettlementAccepted');
const evidences = evidencesAll
.filter(matchCase)
.map(tryDecrypt)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const answers = answersAll
.filter(matchCase)
.map(tryDecrypt)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const settlements = settlementsAll
.filter(matchCase)
.map(tryDecrypt)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const verdicts = verdictsAll
.filter(matchCase)
.map(tryDecrypt)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
const acceptedSettlements = acceptedAll
.filter(matchCase)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const decidedAt =
base.verdictAt ||
base.closedAt ||
(verdict && verdict.createdAt) ||
base.decidedAt;
const hasVerdict = !!verdict;
const supportsMap = await countSupportsByCase();
const supportCount = supportsMap.get(String(caseRootId)) || supportsMap.get(String(id)) || 0;
const tally = base.voteId ? await voteTally(base.voteId) : null;
return {
...base,
id,
respondent: base.respondentId || base.respondent,
evidences,
answers,
settlements,
acceptedSettlements,
verdict,
decidedAt,
isAccuser,
isRespondent,
isMediator,
isJudge,
isDictator,
mine,
publicDetails,
myPublicPreference,
supportCount,
hasSettlement: settlements.length > 0,
yes: tally ? tally.yes : 0,
no: tally ? tally.no : 0,
total: tally ? tally.total : 0,
needed: tally ? tally.needed : 0,
voteClosed: tally ? tally.closed : false,
hasVerdict
};
}
async function processIncomingCourtsKeys() {
if (!tribeCrypto) return;
const ssbKeys = require('../server/node_modules/ssb-keys');
const ssbConfig = require('../server/ssb_config');
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, arr) => (err ? rej(err) : res(arr)))
);
});
for (const m of msgs) {
const c = m.value?.content;
if (!c || c.type !== 'courts-key') continue;
if (c.for !== ssbClient.id) continue;
if (!c.memberKey || !c.caseRootId) continue;
if (tribeCrypto.getKey(c.caseRootId)) continue;
try {
const key = tribeCrypto.unboxKeyFromMember(c.memberKey, ssbConfig.keys, ssbKeys);
if (key) tribeCrypto.setKey(c.caseRootId, key, 1);
} catch (e) {}
}
}
return {
getCurrentUserId,
openCase,
listCases,
listCasesForUser,
getCaseById,
setMediators,
assignJudge,
addEvidence,
answerCase,
issueVerdict,
proposeSettlement,
acceptSettlement,
supportCase,
sweepCases,
setPublicPreference,
openPopularVote,
nominateJudge,
voteNomination,
listNominations,
getCaseDetails,
processIncomingCourtsKeys
};
};

530
src/models/crypto.js Normal file
View file

@ -0,0 +1,530 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const SENSITIVE_FIELDS = [
'title', 'description', 'location', 'price', 'salary', 'options', 'votes',
'category', 'tags', 'image', 'url', 'attendees', 'assignees', 'deadline',
'goal', 'funded', 'refeeds', 'refeeds_inhabitants', 'opinions',
'opinions_inhabitants', 'status', 'priority', 'date', 'mediaType'
];
const ENVELOPE_PRESERVE = new Set([
'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
'createdAt', 'updatedAt', 'encryptedPayload',
'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
'members', 'invites', 'participants',
'_decrypted', '_undecryptable'
]);
const INVITE_SALT_LEGACY = 'SolarNET.HuB';
const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const FP_INFO = Buffer.from('v1-fp', 'utf8');
const ENVELOPE_TYPE = 'tribe-msg';
const ENVELOPE_VERSION = 1;
const KEY_DISTRIB_TYPE = 'tribe-keys-distrib';
const KEY_DISTRIB_BATCH = 7;
module.exports = (configPath, namespace = 'tribes') => {
const keysDir = path.join(configPath, 'keys');
try { fs.mkdirSync(keysDir, { recursive: true, mode: 0o700 }); } catch (_) {}
const keyringPath = path.join(keysDir, `${namespace}-keys.json`);
if (namespace === 'tribes') {
const legacyPath = path.join(configPath, 'tribe-keys.json');
try {
if (fs.existsSync(legacyPath) && !fs.existsSync(keyringPath)) {
fs.renameSync(legacyPath, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
}
} catch (_) {}
}
let keyring = {};
const loadKeyring = () => {
try {
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
keyring = {};
}
return keyring;
};
const saveKeyring = () => {
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
};
const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
const getKey = (rid) => {
const e = keyring[rid];
return e && Array.isArray(e.keys) && e.keys[0] ? e.keys[0] : null;
};
const getKeys = (rid) => {
const e = keyring[rid];
return e && Array.isArray(e.keys) ? e.keys : [];
};
const getGen = (rid) => {
const e = keyring[rid];
return e ? e.gen || 1 : 0;
};
const getAllRootIds = () => Object.keys(keyring);
const setKey = (rid, kHex, gen) => {
keyring[rid] = { keys: [kHex], gen: gen || 1 };
saveKeyring();
};
const setKeys = (rid, ks, topGen) => {
if (!Array.isArray(ks) || !ks.length) return;
const seen = new Set();
const dedup = [];
for (const k of ks) if (k && !seen.has(k)) { seen.add(k); dedup.push(k); }
keyring[rid] = { keys: dedup, gen: topGen || dedup.length };
saveKeyring();
};
const mergeKeys = (rid, incoming, topGen) => {
const e = keyring[rid] || { keys: [], gen: 0 };
const seen = new Set(e.keys);
const merged = [...e.keys];
for (const k of incoming) if (k && !seen.has(k)) { seen.add(k); merged.push(k); }
keyring[rid] = { keys: merged, gen: Math.max(e.gen || 0, topGen || merged.length) };
saveKeyring();
return keyring[rid].gen;
};
const addNewKey = (rid, kHex) => {
const e = keyring[rid] || { keys: [], gen: 0 };
if (e.keys.includes(kHex)) return e.gen;
e.keys.unshift(kHex);
e.gen = (e.gen || 0) + 1;
keyring[rid] = e;
saveKeyring();
return e.gen;
};
const dropKey = (rid) => {
if (keyring[rid]) {
delete keyring[rid];
saveKeyring();
}
};
const fingerprint = (kHex) =>
crypto.createHmac('sha256', Buffer.from(kHex, 'hex')).update(FP_INFO).digest('hex').slice(0, 32);
const buildFingerprintIndex = () => {
const m = new Map();
for (const [rid, e] of Object.entries(keyring)) {
if (!e || !Array.isArray(e.keys)) continue;
for (let i = 0; i < e.keys.length; i++) {
const k = e.keys[i];
if (!k) continue;
m.set(fingerprint(k), { rootId: rid, keyHex: k, isCurrent: i === 0 });
}
}
return m;
};
const buildAad = (fp) => Buffer.from(`${ENVELOPE_TYPE}|v${ENVELOPE_VERSION}|${fp}`, 'utf8');
const encryptWithKey = (plaintext, kHex, aad) => {
const key = Buffer.from(kHex, 'hex');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
if (aad) cipher.setAAD(aad);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return iv.toString('hex') + tag.toString('hex') + enc.toString('hex');
};
const decryptWithKey = (encrypted, kHex, aad) => {
const key = Buffer.from(kHex, 'hex');
const iv = Buffer.from(encrypted.slice(0, 24), 'hex');
const tag = Buffer.from(encrypted.slice(24, 56), 'hex');
const ct = Buffer.from(encrypted.slice(56), 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
if (aad) decipher.setAAD(aad);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
};
const isTribeMsg = (c) =>
!!c && c.type === ENVELOPE_TYPE && c.v === ENVELOPE_VERSION && typeof c.fp === 'string' && typeof c.p === 'string';
const wrapMsg = (body, kHex) => {
if (!kHex) throw new Error('wrapMsg: missing key');
const fp = fingerprint(kHex);
const aad = buildAad(fp);
const p = encryptWithKey(JSON.stringify(body), kHex, aad);
return { type: ENVELOPE_TYPE, v: ENVELOPE_VERSION, fp, p };
};
const unwrapMsg = (envelope, fpIdx) => {
if (!isTribeMsg(envelope)) return null;
const aad = buildAad(envelope.fp);
const tryKey = (rootId, keyHex) => {
try {
const pt = decryptWithKey(envelope.p, keyHex, aad);
return { body: JSON.parse(pt), rootId, keyHex };
} catch (_) { return null; }
};
const entry = fpIdx.get(envelope.fp);
if (entry) {
const r = tryKey(entry.rootId, entry.keyHex);
if (r) return r;
for (const [, e] of fpIdx) {
if (e.rootId !== entry.rootId || e.keyHex === entry.keyHex) continue;
const r2 = tryKey(e.rootId, e.keyHex);
if (r2) return r2;
}
return null;
}
for (const [, e] of fpIdx) {
const r = tryKey(e.rootId, e.keyHex);
if (r) return r;
}
return null;
};
const generateInviteSalt = () => crypto.randomBytes(16).toString('hex');
const deriveInviteKey = (code, salt) => {
const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt;
return crypto.scryptSync(code, s, 32, INVITE_SCRYPT);
};
const hashInviteCode = (code, salt) => {
const s = (salt === undefined || salt === null || salt === '') ? INVITE_SALT_LEGACY : salt;
return crypto.createHmac('sha256', s).update(String(code), 'utf8').digest('hex');
};
const inviteAad = (code, salt) =>
Buffer.from(`tribe-invite|v1|${hashInviteCode(code, salt)}`, 'utf8');
const encryptForInvite = (tribeKeyHex, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
return encryptWithKey(tribeKeyHex, derived.toString('hex'), inviteAad(inviteCode, salt));
};
const decryptFromInvite = (encryptedKey, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
};
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
const k = deriveInviteKey(code, salt);
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
};
const decryptChainFromInvite = (encryptedPayload, code, salt) => {
const k = deriveInviteKey(code, salt);
try {
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
const parsed = JSON.parse(json);
if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && Array.isArray(e.keys) && e.keys.length)) {
return parsed.map(e => ({
rootId: e.rootId,
keys: e.keys.slice(),
key: e.keys[0],
gen: e.gen || e.keys.length
}));
}
} catch (_) {}
return null;
};
const inviteMatchesCode = (inv, code) => {
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
return inv.codeHash === hashInviteCode(code, inv.salt);
};
const buildKeyDistribPayload = (rootId, keys, gen) => ({
type: KEY_DISTRIB_TYPE,
rootId,
keys: Array.isArray(keys) ? keys.slice() : [],
gen: gen || (Array.isArray(keys) ? keys.length : 1),
distributedAt: new Date().toISOString()
});
const isKeyDistribContent = (decoded) =>
!!decoded && decoded.type === KEY_DISTRIB_TYPE && typeof decoded.rootId === 'string' &&
Array.isArray(decoded.keys) && decoded.keys.length > 0;
const tryUnboxKeyDistrib = (rawContent, localKeypair, ssbKeys) => {
if (typeof rawContent !== 'string' || !rawContent.endsWith('.box')) return null;
let decoded;
try { decoded = ssbKeys.unbox(rawContent, localKeypair); } catch (_) { return null; }
if (!decoded) return null;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return null; }
}
return isKeyDistribContent(decoded) ? decoded : null;
};
const boxKeyForMember = (tribeKeyHex, memberFeedId, ssbKeys) =>
ssbKeys.box(tribeKeyHex, [memberFeedId]);
const unboxKeyFromMember = (boxed, localKeypair, ssbKeys) =>
ssbKeys.unbox(boxed, localKeypair);
const canonicalAad = (envelope) => {
if (!envelope) return null;
const fields = ['type', 'tribeId', 'contentType', 'replaces', 'author', 'createdAt'];
const obj = {};
for (const f of fields) if (envelope[f] !== undefined && envelope[f] !== null) obj[f] = envelope[f];
return Buffer.from(JSON.stringify(obj), 'utf8');
};
const encryptChain = (plaintext, keyChain, aad) => {
let data = plaintext;
const last = keyChain.length - 1;
for (let i = 0; i < keyChain.length; i++) {
data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined);
}
return data;
};
const decryptChain = (encrypted, keyChain, aad) => {
const reversed = [...keyChain].reverse();
let data = encrypted;
for (let i = 0; i < reversed.length; i++) {
data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
}
return data;
};
const encryptContent = (content, keyChain, customFields) => {
const payload = {};
if (customFields) {
for (const [k, v] of Object.entries(content)) {
if (ENVELOPE_PRESERVE.has(k)) continue;
payload[k] = v;
}
} else {
for (const field of SENSITIVE_FIELDS) {
if (content[field] !== undefined) payload[field] = content[field];
}
}
const plaintext = JSON.stringify(payload);
const result = {};
for (const [k, v] of Object.entries(content)) {
if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
result[k] = v;
}
}
const aad = canonicalAad(result);
const encryptedPayload = encryptChain(plaintext, keyChain, aad);
result.encryptedPayload = encryptedPayload;
return result;
};
const decryptContent = (content, keyChainSets) => {
if (!content || !content.encryptedPayload) return content;
const envelope = { ...content };
delete envelope.encryptedPayload;
const aad = canonicalAad(envelope);
for (const keyChain of keyChainSets) {
try {
const plaintext = decryptChain(content.encryptedPayload, keyChain, aad);
const payload = JSON.parse(plaintext);
const result = { ...content };
delete result.encryptedPayload;
Object.assign(result, payload);
return result;
} catch (_) {}
}
return { ...content, _undecryptable: true };
};
const buildKeyChainSets = (ancestryRootIds) => {
if (!Array.isArray(ancestryRootIds) || ancestryRootIds.length === 0) return [];
if (ancestryRootIds.length === 1) {
const keys = getKeys(ancestryRootIds[0]);
return keys.map(k => [k]);
}
const ownKeys = getKeys(ancestryRootIds[0]);
const parentSets = buildKeyChainSets(ancestryRootIds.slice(1));
const sets = [];
for (const ownKey of ownKeys) {
for (const parentChain of parentSets) {
sets.push([ownKey, ...parentChain]);
}
}
return sets;
};
const resolveKeyChain = async (tribeId, tribesModel) => {
if (!tribeId || !tribesModel) return null;
let ancestryIds;
try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
const chain = [];
for (const rid of ancestryIds) {
const k = getKey(rid);
if (!k) return null;
chain.push(k);
}
return chain.length ? chain : null;
};
const resolveKeyChainSets = async (tribeId, tribesModel) => {
if (!tribeId || !tribesModel) return null;
let ancestryIds;
try { ancestryIds = await tribesModel.getAncestryChain(tribeId); } catch (_) { return null; }
if (!Array.isArray(ancestryIds) || !ancestryIds.length) return null;
return buildKeyChainSets(ancestryIds);
};
const encryptForTribe = async (content, tribeId, tribesModel) => {
const chain = await resolveKeyChain(tribeId, tribesModel);
if (!chain) throw new Error('Missing tribe key chain — cannot encrypt content for this tribe');
return encryptContent(content, chain, true);
};
const decryptFromTribe = async (content, tribesModel) => {
if (!content) return content;
if (isTribeMsg(content)) {
const fpIdx = buildFingerprintIndex();
const r = unwrapMsg(content, fpIdx);
if (!r) return { ...content, _undecryptable: true };
return { ...r.body, _decrypted: true };
}
if (!content.encryptedPayload) return content;
const tid = content.tribeId;
if (tid && tribesModel) {
let sets = null;
try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {}
if (sets && sets.length) {
const r = decryptContent(content, sets);
if (r && !r._undecryptable) return r;
}
const directKeys = getKeys(tid);
if (directKeys && directKeys.length) {
const r = decryptContent(content, directKeys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
const candidateRoots = [
content.calendarId, content.chatId, content.padId,
content.mapId, content.roomId, content.parentId, content.dateId
].filter(Boolean);
for (const rid of candidateRoots) {
const keys = getKeys(rid);
if (keys && keys.length) {
const r = decryptContent(content, keys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
return { ...content, _undecryptable: true };
};
const createHelpers = (tribesModel) => ({
async encryptIfTribe(content) {
if (!content || !content.tribeId || !tribesModel) return content;
const rootId = await tribesModel.getRootId(content.tribeId);
const key = getKey(rootId);
if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted content`);
const body = { k: content.type, ...content };
return wrapMsg(body, key);
},
async decryptIfTribe(content) {
if (!content || !tribesModel) return content;
if (isTribeMsg(content)) {
const fpIdx = buildFingerprintIndex();
const r = unwrapMsg(content, fpIdx);
if (!r) return { ...content, _undecryptable: true };
const flat = { ...r.body, _decrypted: true, _rootId: r.rootId };
if (r.body.k === 'tombstone') flat.type = 'tombstone';
else if (r.body.k && !flat.type) flat.type = r.body.k;
delete flat.k;
return flat;
}
if (!content.encryptedPayload) return content;
return await decryptFromTribe(content, tribesModel);
},
assertReadable(decrypted, what) {
if (decrypted && decrypted._undecryptable) throw new Error(`${what} is tribe-encrypted and cannot be decrypted with available keys`);
},
async decryptIndexNodes(idx) {
if (!tribesModel) return;
for (const [k, n] of idx.nodes.entries()) {
if (!n.c) continue;
if (!n.c.encryptedPayload && !isTribeMsg(n.c)) continue;
const dec = await decryptFromTribe(n.c, tribesModel);
if (dec && !dec._undecryptable) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
}
}
},
unwrapMessagesForKind(messages, kindOrKinds) {
const kinds = Array.isArray(kindOrKinds) ? kindOrKinds : [kindOrKinds];
const kSet = new Set(kinds);
const fpIdx = buildFingerprintIndex();
const out = [];
for (const m of messages) {
const c = m && m.value && m.value.content;
if (!c) continue;
if (!isTribeMsg(c)) { out.push(m); continue; }
const r = unwrapMsg(c, fpIdx);
if (!r || !r.body) continue;
const inner = r.body;
if (inner.k === 'tombstone' && inner.target) {
const flat = { type: 'tombstone', target: inner.target, deletedAt: inner.deletedAt, author: inner.author, _rootId: r.rootId };
out.push({ ...m, value: { ...m.value, content: flat } });
} else if (kSet.has(inner.k)) {
const flat = { ...inner, type: inner.k, _decrypted: true, _rootId: r.rootId };
delete flat.k;
out.push({ ...m, value: { ...m.value, content: flat } });
}
}
return out;
},
async encryptTombstone(target, tribeId, author) {
const deletedAt = new Date().toISOString();
if (!tribeId || !tribesModel) return { type: 'tombstone', target, deletedAt, author };
const rootId = await tribesModel.getRootId(tribeId);
const key = getKey(rootId);
if (!key) throw new Error(`Missing tribe key for ${rootId} — cannot publish encrypted tombstone`);
return wrapMsg({ k: 'tombstone', target, deletedAt, author }, key);
}
});
loadKeyring();
return {
SENSITIVE_FIELDS, ENVELOPE_PRESERVE,
ENVELOPE_TYPE, ENVELOPE_VERSION, KEY_DISTRIB_TYPE, KEY_DISTRIB_BATCH,
loadKeyring, saveKeyring,
generateTribeKey, getKey, getKeys, getGen, getAllRootIds,
setKey, setKeys, mergeKeys, addNewKey, dropKey,
fingerprint, buildFingerprintIndex,
isTribeMsg, wrapMsg, unwrapMsg,
encryptWithKey, decryptWithKey,
generateInviteSalt, hashInviteCode, deriveInviteKey,
encryptForInvite, decryptFromInvite,
encryptChainForInvite, decryptChainFromInvite, inviteMatchesCode,
buildKeyDistribPayload, isKeyDistribContent, tryUnboxKeyDistrib,
boxKeyForMember, unboxKeyFromMember,
canonicalAad, encryptChain, decryptChain,
encryptContent, decryptContent,
buildKeyChainSets, resolveKeyChain, resolveKeyChainSets,
encryptForTribe, decryptFromTribe,
createHelpers
};
};

177
src/models/cv_model.js Normal file
View file

@ -0,0 +1,177 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const extractBlobId = str => {
if (!str || typeof str !== 'string') return null;
const match = str.match(/\(([^)]+\.sha256)\)/);
return match ? match[1] : str.trim();
};
const parseCSV = str => str
? str.split(',').map(s => s.trim()).filter(Boolean)
: [];
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
return {
type: 'curriculum',
async createCV(data, photoBlobId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const content = {
type: 'curriculum',
author: userId,
name: data.name,
description: data.description,
photo: extractBlobId(photoBlobId) || null,
contact: userId,
personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '',
oasisExperiences: data.oasisExperiences || '',
oasisSkills: parseCSV(data.oasisSkills),
educationExperiences: data.educationExperiences || '',
educationalSkills: parseCSV(data.educationalSkills),
languages: data.languages || '',
professionalExperiences: data.professionalExperiences || '',
professionalSkills: parseCSV(data.professionalSkills),
location: data.location || 'UNKNOWN',
status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING',
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
createdAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
},
async updateCV(id, data, photoBlobId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const old = await new Promise((res, rej) =>
ssbClient.get(id, (err, msg) =>
err || !msg?.content
? rej(err || new Error('CV not found'))
: res(msg)
)
);
if (old.author !== userId) {
throw new Error('Not the author');
}
const tombstone = {
type: 'tombstone',
target: id,
deletedAt: new Date().toISOString()
};
await new Promise((res, rej) =>
ssbClient.publish(tombstone, err => err ? rej(err) : res())
);
const content = {
type: 'curriculum',
author: userId,
name: data.name,
description: data.description,
photo: extractBlobId(photoBlobId) || null,
contact: userId,
personalSkills: parseCSV(data.personalSkills),
personalExperiences: data.personalExperiences || '',
oasisExperiences: data.oasisExperiences || '',
oasisSkills: parseCSV(data.oasisSkills),
educationExperiences: data.educationExperiences || '',
educationalSkills: parseCSV(data.educationalSkills),
languages: data.languages || '',
professionalExperiences: data.professionalExperiences || '',
professionalSkills: parseCSV(data.professionalSkills),
location: data.location || 'UNKNOWN',
status: data.status || 'LOOKING FOR WORK',
preferences: data.preferences || 'REMOTE WORKING',
visibility: data.visibility !== undefined
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
: (old.content.visibility || 'PUBLIC'),
createdAt: old.content.createdAt,
updatedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg));
});
},
async deleteCVById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const msg = await new Promise((res, rej) =>
ssbClient.get(id, (err, msg) =>
err || !msg?.content
? rej(new Error('CV not found'))
: res(msg)
)
);
if (msg.author !== userId) {
throw new Error('Not the author');
}
const tombstone = {
type: 'tombstone',
target: id,
deletedAt: new Date().toISOString()
};
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, result) => err ? reject(err) : resolve(result));
});
},
async getCVByUserId(targetUserId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const authorId = targetUserId || userId;
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
const tombstoned = buildValidatedTombstoneSet(msgs);
const cvMsgs = msgs
.filter(m =>
m.value?.content?.type === 'curriculum' &&
m.value.author === authorId &&
!tombstoned.has(m.key)
)
.sort((a, b) => b.value.timestamp - a.value.timestamp);
if (!cvMsgs.length) {
return resolve(null);
}
const latest = cvMsgs[0];
const c = latest.value.content;
const visibility = String(c.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
if (visibility === 'HIDDEN' && authorId !== userId) return resolve(null);
resolve({ id: latest.key, ...c, visibility });
})
);
});
}
};
};

View file

@ -0,0 +1,370 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const mediaFavorites = require("../backend/media-favorites");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const parseBlobId = (blobMarkdown) => {
if (!blobMarkdown) return null;
const s = String(blobMarkdown);
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s.trim();
};
const parseCSV = (str) =>
str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean);
const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone") continue;
if (c.type === "documentOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "document") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, t] of Array.from(parent.entries())) {
const orig = nodes.get(t);
const node = nodes.get(k);
if (orig && node && orig.author === node.author) continue;
parent.delete(k);
if (child.get(t) === k) child.delete(t);
if (orig) nodes.delete(k);
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const pickDoc = (node, rootId, agg) => {
const c = node.c || {};
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt,
updatedAt: c.updatedAt || null,
tags: safeArr(c.tags),
author: node.author,
title: c.title || "",
description: c.description || "",
opinions: agg ? agg.opinions : (c.opinions || {}),
opinions_inhabitants: agg ? agg.voters : safeArr(c.opinions_inhabitants),
sizeBytes: node.sizeBytes || 0
};
};
const hasBlob = (ssbClient, blobId) =>
new Promise((resolve) => {
if (!blobId) return resolve(false);
ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
});
const favoritesSetForDocuments = async () => {
try {
return await mediaFavorites.getFavoriteSet("documents");
} catch {
return new Set();
}
};
return {
type: "document",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Document not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Document not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createDocument(blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
if (!blobId) throw new Error("Missing document blob");
const tags = parseCSV(tagsRaw) || [];
const content = {
type: "document",
url: blobId,
createdAt: new Date().toISOString(),
author: userId,
tags,
title: title || "",
description: description || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
const oldMsg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg)))
);
if (oldMsg.content?.type !== "document") throw new Error("Document not found");
if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
const aggDoc = await this.getDocumentById(id);
if (aggDoc && Object.keys(aggDoc.opinions || {}).some(k => (aggDoc.opinions[k] || 0) > 0)) throw new Error("Cannot edit document after it has received opinions.");
const parsedTags = parseCSV(tagsRaw);
const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
const blobId = parseBlobId(blobMarkdown);
const updatedAt = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? (title || "") : oldMsg.content.title || "",
description: description !== undefined ? (description || "") : oldMsg.content.description || "",
updatedAt
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
});
},
async deleteDocumentById(id) {
const ssbClient = await openSsb();
const tipId = await this.resolveCurrentId(id);
const msg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m)))
);
if (msg.content?.type !== "document") throw new Error("Document not found");
if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(arg1 = "all") {
const ssbClient = await openSsb();
const opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 };
const filter = safeText(opts.filter || "all");
const q = safeText(opts.q || "").toLowerCase();
const sort = safeText(opts.sort || "recent");
const favorites = await favoritesSetForDocuments();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(pickDoc(node, rootId, idx.resolveGroup(rootId)));
}
let out = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId));
else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000);
else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key));
if (q) {
out = out.filter((d) => {
const t = String(d.title || "").toLowerCase();
const desc = String(d.description || "").toLowerCase();
const u = String(d.url || "").toLowerCase();
const a = String(d.author || "").toLowerCase();
const tags = safeArr(d.tags).join(" ").toLowerCase();
return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q);
});
}
const effectiveSort = filter === "top" ? "top" : sort;
if (effectiveSort === "top") {
out = out
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (effectiveSort === "oldest") {
out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
return checked
.filter(Boolean)
.map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
},
async getDocumentById(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const favorites = await favoritesSetForDocuments();
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Document not found");
const c = agg.contentNode.c || {};
return {
key: agg.contentTip,
rootId,
url: c.url,
createdAt: c.createdAt,
updatedAt: c.updatedAt || null,
tags: c.tags || [],
author: agg.contentNode.author,
title: c.title || "",
description: c.description || "",
opinions: agg.opinions,
opinions_inhabitants: agg.voters,
isFavorite: favorites.has(rootId || agg.contentTip)
};
},
async createOpinion(id, category) {
if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category"));
const ssbClient = await openSsb();
const doc = await this.getDocumentById(id);
if (!doc) throw new Error("Document not found");
if (safeArr(doc.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "documentOpinion", target: doc.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};

633
src/models/events_model.js Normal file
View file

@ -0,0 +1,633 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const crypto = require('crypto');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js');
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const me = async () => (await openSsb()).id;
const ownCrypto = eventCrypto || tribeCrypto;
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
);
const ingestOwnTribeKeys = async () => {
if (!ownCrypto) return;
try {
const ssbClient = await openSsb();
const ssbKeys = require("../server/node_modules/ssb-keys");
const cfg = require("../server/ssb_config");
const msgs = await readAll(ssbClient);
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c || c.type !== "tribe-keys") continue;
const memberKeys = c.memberKeys;
if (!memberKeys || typeof memberKeys !== "object") continue;
const boxed = memberKeys[ssbClient.id];
if (!boxed) continue;
try {
const unboxed = ssbKeys.unbox(boxed, cfg.keys);
const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
} catch (_) {}
}
} catch (_) {}
};
const decryptEventContent = (rawContent, eventId) => {
if (!rawContent) return null;
if (!rawContent.encryptedPayload) return rawContent;
if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
const keys = (ownCrypto.getKeys && ownCrypto.getKeys(eventId)) || [];
if (!keys.length) return { ...rawContent, _undecryptable: true };
const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
return { ...dec, _decrypted: true };
};
const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
const normalizePrivacy = (v) => {
const s = String(v || 'public').toLowerCase();
return s === 'private' ? 'private' : 'public';
};
const normalizePrice = (price) => {
let p = typeof price === 'string' ? parseFloat(price.replace(',', '.')) : price;
if (isNaN(p) || p < 0) p = 0;
return Number(p).toFixed(6);
};
const normalizeDate = (date) => {
const m = moment(date);
if (!m.isValid()) throw new Error("Invalid date format");
return m.toISOString();
};
const deriveStatus = (c) => {
const dateM = moment(c.date);
let status = String(c.status || 'OPEN').toUpperCase();
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
return status;
};
const buildEventIndex = (messages) => {
const eventAuthor = new Map();
const eventReplaces = new Map();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'event') continue;
eventAuthor.set(m.key, m.value.author);
if (c.replaces) eventReplaces.set(m.key, c.replaces);
}
for (const [key, target] of Array.from(eventReplaces.entries())) {
if (!eventAuthor.has(target)) { eventReplaces.delete(key); continue; }
if (eventAuthor.get(target) !== eventAuthor.get(key)) { eventReplaces.delete(key); eventAuthor.delete(key); }
}
const strictNext = new Map();
for (const [key, target] of eventReplaces) {
if (eventAuthor.get(target) === eventAuthor.get(key)) strictNext.set(target, key);
}
const rootOf = (id) => {
let cur = id;
const seen = new Set();
while (eventReplaces.has(cur) && eventAuthor.has(eventReplaces.get(cur))) {
if (seen.has(cur)) break;
seen.add(cur);
cur = eventReplaces.get(cur);
}
return cur;
};
const contentTipOf = (root) => {
let cur = root;
const seen = new Set();
while (strictNext.has(cur)) {
if (seen.has(cur)) break;
seen.add(cur);
cur = strictNext.get(cur);
}
return cur;
};
return { eventAuthor, eventReplaces, strictNext, rootOf, contentTipOf };
};
const collectCollab = (messages) => {
const opinions = new Map();
const attend = new Map();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || typeof c.target !== 'string') continue;
const author = m.value.author;
const ts = (m.value && m.value.timestamp) || 0;
if (c.type === 'eventOpinion' && typeof c.category === 'string') {
if (!opinions.has(c.target)) opinions.set(c.target, new Map());
const byAuthor = opinions.get(c.target);
if (!byAuthor.has(author)) byAuthor.set(author, c.category);
} else if (c.type === 'eventAttend') {
if (!attend.has(c.target)) attend.set(c.target, new Map());
const byAuthor = attend.get(c.target);
const prev = byAuthor.get(author);
if (!prev || ts >= prev.ts) byAuthor.set(author, { on: c.on !== false, ts });
}
}
return { opinions, attend };
};
const aggregateCollab = (ownerContent, rootId, collab) => {
const attendees = new Set(uniq(ownerContent.attendees || []));
if (ownerContent.organizer) attendees.add(ownerContent.organizer);
const attMap = collab.attend.get(rootId);
if (attMap) for (const [author, st] of attMap) { if (st.on) attendees.add(author); else if (author !== ownerContent.organizer) attendees.delete(author); }
const opinionInh = new Set(Array.isArray(ownerContent.opinions_inhabitants) ? ownerContent.opinions_inhabitants : []);
const opinions = Object.assign({}, ownerContent.opinions || {});
const opMap = collab.opinions.get(rootId);
if (opMap) for (const [author, category] of opMap) {
if (opinionInh.has(author)) continue;
opinionInh.add(author);
opinions[category] = (opinions[category] || 0) + 1;
}
return { attendees: uniq([...attendees]), opinions, opinions_inhabitants: [...opinionInh] };
};
return {
type: 'event',
async ingestKeys() { await ingestOwnTribeKeys(); },
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await readAll(ssbClient);
const idx = buildEventIndex(messages);
return idx.rootOf(id);
},
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
const ssbClient = await openSsb();
const userId = await me();
const formattedDate = normalizeDate(date);
if (moment(formattedDate).isBefore(moment().startOf('minute'))) throw new Error("Cannot create an event in the past");
let attendeeList = attendees;
if (!Array.isArray(attendeeList)) attendeeList = String(attendeeList || '').split(',').map(s => s.trim()).filter(Boolean);
attendeeList = uniq([...attendeeList, userId]);
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(s => s.trim()).filter(Boolean);
const visibility = normalizePrivacy(isPublic);
const plainContent = {
type: 'event',
title,
description,
date: formattedDate,
location,
price: normalizePrice(price),
url: url || '',
attendees: attendeeList,
tags,
createdAt: new Date().toISOString(),
organizer: userId,
status: 'OPEN',
isPublic: visibility,
mapUrl: String(mapUrl || "").trim(),
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
opinions: {},
opinions_inhabitants: []
};
const shouldEncrypt = visibility === 'private' && ownCrypto && tribeCrypto;
let eventKey = null;
let content = plainContent;
if (shouldEncrypt) {
eventKey = ownCrypto.generateTribeKey();
content = tribeCrypto.encryptContent(plainContent, [eventKey], true);
}
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
});
if (eventKey) {
ownCrypto.setKey(result.key, eventKey, 1);
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
for (const m of attendeeList) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(eventKey, m, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return result;
},
async generateInvite(eventId) {
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
const ssbClient = await openSsb();
const userId = await me();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m)));
const rid = await this.resolveRootId(eventId);
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
if (c.organizer !== userId) throw new Error("Only the organizer can generate invites");
if (normalizePrivacy(c.isPublic) !== 'private') throw new Error("Only private events use invitation codes");
const key = lookupKey(rid);
if (!key) throw new Error("Missing event key — cannot generate invite");
const code = crypto.randomBytes(16).toString('hex');
const inviteSalt = tribeCrypto.generateInviteSalt();
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
const inviteRef = { code, ek, salt: inviteSalt, gen: 1, rootId: rid };
await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
});
return { code, eventId: rid };
},
async getOpenInvite(eventId) {
const ssbClient = await openSsb();
const rid = await this.resolveRootId(eventId).catch(() => eventId);
const messages = await readAll(ssbClient);
const markerTomb = new Set();
const invTomb = new Set();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === 'event-open-invite-tombstone' && typeof c.target === 'string') markerTomb.add(c.target);
if (c.type === 'event-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target);
}
let best = null;
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'event-open-invite' || c.v !== 1) continue;
if (c.target !== rid || typeof c.code !== 'string') continue;
if (markerTomb.has(m.key)) continue;
if (c.inviteKey && invTomb.has(c.inviteKey)) continue;
const ts = (m.value && m.value.timestamp) || 0;
if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts };
}
return best ? { code: best.code, by: best.by, markerKey: best.markerKey, inviteKey: best.inviteKey } : null;
},
async generateOpenInvite(eventId) {
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
const ssbClient = await openSsb();
const userId = await me();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("Error retrieving event")) : res(m)));
const rid = await this.resolveRootId(eventId);
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
if (c.organizer !== userId) throw new Error("Only the organizer can generate invites");
if (normalizePrivacy(c.isPublic) !== 'private') throw new Error("Only private events use invitation codes");
if (await this.getOpenInvite(eventId)) throw new Error("An open invitation already exists");
const key = lookupKey(rid);
if (!key) throw new Error("Missing event key — cannot generate invite");
const code = crypto.randomBytes(16).toString('hex');
const inviteSalt = tribeCrypto.generateInviteSalt();
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
const invitePub = await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'event-invite', target: rid, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
});
await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'event-open-invite', v: 1, target: rid, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve());
});
return { code, eventId: rid };
},
async removeOpenInvite(eventId) {
const ssbClient = await openSsb();
const userId = await me();
const rec = await this.getOpenInvite(eventId);
if (!rec) return;
let organizer = null;
try {
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, m) => err || !m || !m.content ? rej(new Error("nf")) : res(m)));
const rid = await this.resolveRootId(eventId);
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
organizer = c && c.organizer;
} catch (_) {}
if (rec.by !== userId && organizer !== userId) throw new Error("Not allowed to remove this invitation");
await new Promise((resolve, reject) => ssbClient.publish({ type: 'event-open-invite-tombstone', target: rec.markerKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve()));
if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: 'event-invite-tombstone', target: rec.inviteKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve()));
},
async joinByInvite(code) {
if (!ownCrypto || !tribeCrypto) throw new Error("Event crypto unavailable");
const ssbClient = await openSsb();
const userId = await me();
const messages = await readAll(ssbClient);
const invTomb = new Set();
for (const m of messages) {
const c = m.value && m.value.content;
if (c && c.type === 'event-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target);
}
let matched = null;
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'event-invite') continue;
if (invTomb.has(m.key)) continue;
try {
const hash = tribeCrypto.hashInviteCode(code, c.salt);
if (hash === c.codeHash) { matched = c; break; }
} catch (_) {}
}
if (!matched) throw new Error("Invalid or expired invite code");
const eventKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
if (!eventKey) throw new Error("Could not decrypt invite");
ownCrypto.addNewKey(matched.target, eventKey);
await this.toggleAttendee(matched.target);
return { ok: true, eventId: matched.target };
},
async toggleAttendee(eventId) {
const ssbClient = await openSsb();
const userId = await me();
const messages = await readAll(ssbClient);
const idx = buildEventIndex(messages);
const rid = idx.rootOf(eventId);
const contentTip = idx.contentTipOf(rid);
const ev = await new Promise((res, rej) => ssbClient.get(contentTip, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
const status = deriveStatus(c);
if (status === 'CLOSED') throw new Error("Cannot attend a closed event");
const collab = collectCollab(messages);
const agg = aggregateCollab(c, rid, collab);
const isAttending = agg.attendees.includes(userId);
const isLeaving = isAttending;
const on = !isAttending;
const isPrivate = normalizePrivacy(c.isPublic) === 'private';
const isOrganizer = c.organizer === userId;
const result = await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'eventAttend', target: rid, on, createdAt: new Date().toISOString() }, (err2, res2) => err2 ? reject(err2) : resolve(res2));
});
if (isPrivate && !isLeaving && ownCrypto && tribeCrypto) {
try {
const key = lookupKey(rid);
if (key) {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(key, userId, ssbKeys); } catch (_) {}
if (memberKeys[userId]) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve());
});
}
}
} catch (_) {}
}
if (isPrivate && isLeaving && !isOrganizer && ownCrypto && tribeCrypto) {
try {
const remaining = agg.attendees.filter(a => a !== userId);
const newKey = ownCrypto.generateTribeKey();
const newGen = ownCrypto.addNewKey(rid, newKey);
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
for (const m of remaining) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: newGen, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return result;
},
async deleteEventById(eventId) {
const ssbClient = await openSsb();
const userId = await me();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
const rid = await this.resolveRootId(eventId);
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
if (c.organizer !== userId) throw new Error("Only the organizer can delete this event");
const tombstone = { type: 'tombstone', target: eventId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => err ? reject(err) : resolve(res));
});
},
async getEventById(eventId) {
const ssbClient = await openSsb();
const messages = await readAll(ssbClient);
const idx = buildEventIndex(messages);
const rid = idx.rootOf(eventId);
const contentTip = idx.contentTipOf(rid);
const msg = await new Promise((res, rej) => ssbClient.get(contentTip, (err, msg) => err || !msg || !msg.content ? rej(new Error("Error retrieving event")) : res(msg)));
const c = msg.content && msg.content.encryptedPayload ? decryptEventContent(msg.content, rid) : msg.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted with available keys");
const status = deriveStatus(c);
const agg = aggregateCollab(c, rid, collectCollab(messages));
return {
id: contentTip,
title: c.title || '',
description: c.description || '',
date: c.date || '',
location: c.location || '',
price: c.price || 0,
url: c.url || '',
attendees: agg.attendees,
tags: Array.isArray(c.tags) ? c.tags : [],
createdAt: c.createdAt || new Date().toISOString(),
updatedAt: c.updatedAt || new Date().toISOString(),
organizer: c.organizer || '',
status,
isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "",
clearnetPublic: !!c.clearnetPublic,
encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants
};
},
async updateEventById(eventId, updatedData) {
const ssbClient = await openSsb();
const userId = await me();
const ev = await new Promise((res, rej) => ssbClient.get(eventId, (err, ev) => err || !ev || !ev.content ? rej(new Error("Error retrieving event")) : res(ev)));
const rid = await this.resolveRootId(eventId);
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error("Event is encrypted and cannot be decrypted");
if (c.organizer !== userId) throw new Error("Only the organizer can update this event");
const status = deriveStatus(c);
if (status === 'CLOSED') throw new Error("Cannot edit a closed event");
const tags = updatedData.tags !== undefined
? (Array.isArray(updatedData.tags)
? updatedData.tags.filter(Boolean)
: String(updatedData.tags || '').split(',').map(t => t.trim()).filter(Boolean))
: (Array.isArray(c.tags) ? c.tags : []);
const date = updatedData.date !== undefined && updatedData.date !== ''
? normalizeDate(updatedData.date)
: c.date;
if (moment(date).isBefore(moment().startOf('minute'))) throw new Error("Cannot set an event in the past");
let updated = {
...c,
title: updatedData.title ?? c.title,
description: updatedData.description ?? c.description,
date,
location: updatedData.location ?? c.location,
price: updatedData.price !== undefined ? normalizePrice(updatedData.price) : c.price,
url: updatedData.url ?? c.url,
tags,
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
updatedAt: new Date().toISOString(),
replaces: eventId
};
const wasPrivate = normalizePrivacy(c.isPublic) === 'private';
const isPrivate = updated.isPublic === 'private';
let newKey = null;
if (isPrivate && ownCrypto && tribeCrypto) {
let key = lookupKey(rid);
if (!key) {
newKey = ownCrypto.generateTribeKey();
ownCrypto.setKey(rid, newKey, 1);
key = newKey;
}
updated = tribeCrypto.encryptContent(updated, [key], true);
}
const result = await new Promise((resolve, reject) => {
ssbClient.publish(updated, (err2, res2) => err2 ? reject(err2) : resolve(res2));
});
if (newKey && tribeCrypto) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
for (const m of (Array.isArray(c.attendees) ? c.attendees : [userId])) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rid, generation: 1, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return result;
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await readAll(ssbClient);
const idx = buildEventIndex(messages);
const rid = idx.rootOf(id);
const contentTip = idx.contentTipOf(rid);
const ev = await new Promise((res, rej) => ssbClient.get(contentTip, (err, m) => (err || !m || !m.content) ? rej(new Error('Event not found')) : res(m)));
const c = ev.content && ev.content.encryptedPayload ? decryptEventContent(ev.content, rid) : ev.content;
if (c && c._undecryptable) throw new Error('Event is encrypted and cannot be decrypted');
const agg = aggregateCollab(c, rid, collectCollab(messages));
if (agg.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
return new Promise((res, rej) => ssbClient.publish({ type: 'eventOpinion', target: rid, category, createdAt: new Date().toISOString() }, (err, result) => err ? rej(err) : res(result)));
},
async listAll(author = null, filter = 'all') {
const ssbClient = await openSsb();
const userId = await me();
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => {
if (err) return reject(new Error("Error listing events: " + err.message));
const tombstoned = buildValidatedTombstoneSet(results);
const idx = buildEventIndex(results);
const collab = collectCollab(results);
const roots = new Set();
for (const r of results) {
const rawC = r.value && r.value.content;
if (!rawC || rawC.type !== 'event') continue;
if (!idx.eventAuthor.has(r.key)) continue;
roots.add(idx.rootOf(r.key));
}
const byRoot = new Map();
for (const rid of roots) {
if (tombstoned.has(rid)) continue;
const contentTip = idx.contentTipOf(rid);
if (tombstoned.has(contentTip)) continue;
const r = results.find(x => x.key === contentTip);
if (!r) continue;
const rawC = r.value && r.value.content;
if (!rawC) continue;
const c = rawC.encryptedPayload ? decryptEventContent(rawC, rid) : rawC;
if (!c || c._undecryptable) continue;
if (author && c.organizer !== author) continue;
const status = deriveStatus(c);
const agg = aggregateCollab(c, rid, collab);
byRoot.set(rid, {
id: contentTip,
title: c.title || '',
description: c.description || '',
date: c.date || '',
location: c.location || '',
price: c.price || 0,
url: c.url || '',
attendees: agg.attendees,
tags: Array.isArray(c.tags) ? c.tags.filter(Boolean) : [],
createdAt: c.createdAt || new Date().toISOString(),
organizer: c.organizer || '',
status,
isPublic: normalizePrivacy(c.isPublic),
mapUrl: c.mapUrl || "",
encrypted: normalizePrivacy(c.isPublic) === 'private',
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants
});
}
let out = Array.from(byRoot.values());
out = dedupeBy(out, e => e.title ? [norm(e.organizer), norm(e.title), norm(e.date)].join('|') : null);
if (filter === 'mine') out = out.filter(e => e.organizer === userId);
if (filter === 'open') out = out.filter(e => String(e.status).toUpperCase() === 'OPEN');
if (filter === 'closed') out = out.filter(e => String(e.status).toUpperCase() === 'CLOSED');
resolve(out);
})
);
});
}
};
};

View file

@ -0,0 +1,52 @@
const os = require('os');
const fs = require('fs');
const path = require('path');
const archiver = require('../server/node_modules/archiver');
module.exports = {
exportSSB: async (outputPath) => {
try {
const homeDir = os.homedir();
const ssbPath = path.join(homeDir, '.ssb');
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', {
zlib: { level: 9 }
});
archive.pipe(output);
const addDirectoryToArchive = (dirPath, archive) => {
const files = fs.readdirSync(dirPath);
let hasFiles = false;
files.forEach((file) => {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (file === 'secret') {
return;
}
if (stat.isDirectory()) {
addDirectoryToArchive(filePath, archive);
archive.directory(filePath, path.relative(ssbPath, filePath));
hasFiles = true;
} else {
archive.file(filePath, { name: path.relative(ssbPath, filePath) });
hasFiles = true;
}
});
if (!hasFiles) {
archive.directory(dirPath, path.relative(ssbPath, dirPath));
}
};
addDirectoryToArchive(ssbPath, archive);
await archive.finalize();
return outputPath;
} catch (error) {
throw new Error("Error exporting data: " + error.message);
}
}
};

View file

@ -0,0 +1,178 @@
const mediaFavorites = require("../backend/media-favorites");
const safeArr = (v) => (Array.isArray(v) ? v : []);
const safeText = (v) => String(v || "").trim();
const getFn = (obj, names) => {
for (const n of names) {
if (obj && typeof obj[n] === "function") return obj[n].bind(obj);
}
return null;
};
const toTs = (d) => {
const t = Date.parse(String(d || ""));
return Number.isFinite(t) ? t : 0;
};
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => {
const kindConfig = {
audios: {
base: "/audios/",
getById: getFn(audiosModel, ["getAudioById", "getById"])
},
bookmarks: {
base: "/bookmarks/",
getById: getFn(bookmarksModel, ["getBookmarkById", "getById"])
},
documents: {
base: "/documents/",
getById: getFn(documentsModel, ["getDocumentById", "getById"])
},
images: {
base: "/images/",
getById: getFn(imagesModel, ["getImageById", "getById"])
},
maps: {
base: "/maps/",
getById: getFn(mapsModel, ["getMapById", "getById"])
},
videos: {
base: "/videos/",
getById: getFn(videosModel, ["getVideoById", "getById"])
},
pads: {
base: "/pads/",
getById: getFn(padsModel, ["getPadById", "getById"])
},
chats: {
base: "/chats/",
getById: getFn(chatsModel, ["getChatById", "getById"])
},
calendars: {
base: "/calendars/",
getById: getFn(calendarsModel, ["getCalendarById", "getById"])
},
torrents: {
base: "/torrents/",
getById: getFn(torrentsModel, ["getTorrentById", "getById"])
},
market: {
base: "/market/",
getById: getFn(marketModel, ["getItemById", "getById"])
},
shopProducts: {
base: "/shops/product/",
getById: getFn(shopsModel, ["getProductById"])
}
};
const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"];
const hydrateKind = async (kind, ids) => {
const cfg = kindConfig[kind];
if (!cfg?.getById) return [];
const out = await Promise.all(
safeArr(ids).map(async (favId) => {
const id = safeText(favId);
if (!id) return null;
try {
const obj = await cfg.getById(id);
const viewId = safeText(obj?.key || obj?.id || id);
return {
kind,
favId: id,
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
title: safeText(obj?.title) || safeText(obj?.name) || safeText(obj?.category) || safeText(obj?.url) || "",
description: safeText(obj?.description) || "",
tags: safeArr(obj?.tags),
author: safeText(obj?.author || obj?.organizer || obj?.seller || obj?.from || ""),
createdAt: obj?.createdAt || null,
updatedAt: obj?.updatedAt || null,
url: obj?.url || null,
category: obj?.category || null
};
} catch {
return null;
}
})
);
return out.filter(Boolean);
};
const loadAll = async () => {
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
const idsByKind = {};
kindOrder.forEach((k, i) => {
idsByKind[k] = Array.from(sets[i] || []);
});
const hydrated = await Promise.all(kindOrder.map((k) => hydrateKind(k, idsByKind[k])));
const byKind = {};
kindOrder.forEach((k, i) => {
byKind[k] = hydrated[i] || [];
});
const flat = kindOrder.flatMap((k) => byKind[k]);
const counts = {
audios: byKind.audios.length,
bookmarks: byKind.bookmarks.length,
calendars: byKind.calendars.length,
chats: byKind.chats.length,
documents: byKind.documents.length,
images: byKind.images.length,
maps: byKind.maps.length,
pads: byKind.pads.length,
torrents: byKind.torrents.length,
videos: byKind.videos.length,
market: byKind.market.length,
shopProducts: byKind.shopProducts.length,
all: flat.length
};
const recentFlat = flat
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
return { byKind, flat, recentFlat, counts };
};
return {
async listAll(opts = {}) {
const filter = safeText(opts.filter || "all").toLowerCase();
const { byKind, recentFlat, counts } = await loadAll();
if (filter === "recent") {
return { items: recentFlat, counts };
}
if (kindOrder.includes(filter)) {
const items = byKind[filter] || [];
const sorted = items
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)));
return { items: sorted, counts };
}
const grouped = kindOrder.flatMap((k) =>
(byKind[k] || [])
.slice()
.sort((a, b) => (toTs(b.updatedAt) || toTs(b.createdAt)) - (toTs(a.updatedAt) || toTs(a.createdAt)))
);
return { items: grouped, counts };
},
async removeFavorite(kind, id) {
const k = safeText(kind);
const favId = safeText(id);
if (!k || !favId) return;
await mediaFavorites.removeFavorite(k, favId);
}
};
};

View file

@ -0,0 +1,409 @@
const fs = require('fs');
const path = require('path');
const { getConfig, saveConfig } = require('../configs/config-manager.js');
const ACCOUNTS_PATH = path.join(__dirname, '..', 'configs', 'fediverse-accounts.json');
const FETCH_TIMEOUT_MS = 8000;
const TIMELINE_CACHE_MS = 60 * 1000;
const TIMELINE_LIMIT = 40;
const MEDIA_PROCESS_TIMEOUT_MS = 60000;
const MEDIA_POLL_INTERVAL_MS = 1500;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const SPOOF_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0';
const spoofHeaders = (accept) => ({
'User-Agent': SPOOF_UA,
'Accept': accept || '*/*',
'Accept-Language': 'en-US,en;q=0.5',
'DNT': '1'
});
const isPrivateHost = (hostname) => {
const h = String(hostname || '').toLowerCase();
if (!h) return true;
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
if (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = +m[1], b = +m[2];
if (a === 0 || a === 10 || a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
}
return false;
};
module.exports = ({ isPublic } = {}) => {
const cache = new Map();
let mediaHosts = new Set();
const readStore = () => {
try {
const obj = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, 'utf8'));
return obj && typeof obj === 'object' ? obj : {};
} catch (_) {
return {};
}
};
const writeStore = (obj) => {
const tmp = ACCOUNTS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
fs.renameSync(tmp, ACCOUNTS_PATH);
try { fs.chmodSync(ACCOUNTS_PATH, 0o600); } catch (_) {}
};
const getMastodon = () => readStore().mastodon || null;
const setModuleFlag = (value) => {
try {
const cfg = getConfig();
if (cfg.modules && cfg.modules.fediverseMod !== value) {
cfg.modules.fediverseMod = value;
saveConfig(cfg);
}
} catch (_) {}
};
const normalizeInstance = (raw) => {
let s = String(raw || '').trim();
if (!s) throw new Error('fediverseErrInstance');
if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
let u;
try { u = new URL(s); } catch (_) { throw new Error('fediverseErrInstance'); }
if (u.protocol !== 'https:') throw new Error('fediverseErrInstance');
if (isPrivateHost(u.hostname)) throw new Error('fediverseErrInstance');
return u.origin;
};
const apiFetch = async (instance, token, pathName, opts = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const headers = Object.assign(spoofHeaders('application/json'), { Authorization: `Bearer ${token}` }, opts.headers || {});
const res = await fetch(`${instance}${pathName}`, { ...opts, headers, signal: controller.signal });
return res;
} finally {
clearTimeout(timer);
}
};
const proxify = (remoteUrl) => {
if (typeof remoteUrl !== 'string' || !remoteUrl) return '';
try {
const u = new URL(remoteUrl);
if (u.protocol !== 'https:') return '';
mediaHosts.add(u.host);
return `/fediverse/media?u=${encodeURIComponent(remoteUrl)}`;
} catch (_) {
return '';
}
};
const mapAccount = (acc = {}) => ({
displayName: acc.display_name || acc.username || '',
acct: acc.acct || acc.username || '',
avatar: proxify(acc.avatar_static || acc.avatar || ''),
url: typeof acc.url === 'string' ? acc.url : ''
});
const mapStatus = (st) => {
if (!st || typeof st !== 'object') return null;
const base = st.reblog && typeof st.reblog === 'object' ? st.reblog : st;
const media = Array.isArray(base.media_attachments)
? base.media_attachments.map(m => ({
type: m.type,
url: proxify(m.url || m.remote_url || ''),
description: typeof m.description === 'string' ? m.description : ''
})).filter(m => m.url)
: [];
return {
id: String(base.id || st.id || ''),
url: typeof base.url === 'string' ? base.url : '',
createdAt: base.created_at || st.created_at || '',
account: mapAccount(base.account || {}),
html: typeof base.content === 'string' ? base.content : '',
media,
boostedBy: st.reblog ? (st.account?.display_name || st.account?.acct || '') : '',
inReplyToId: base.in_reply_to_id || null,
favourited: base.favourited === true,
reblogged: base.reblogged === true,
counts: {
replies: Number(base.replies_count || 0),
reblogs: Number(base.reblogs_count || 0),
favourites: Number(base.favourites_count || 0)
}
};
};
const invalidate = () => cache.clear();
const api = {
hasAccount() {
return !!getMastodon();
},
invalidateCache() {
invalidate();
},
getAccount() {
const m = getMastodon();
if (!m) return null;
return {
network: 'mastodon',
instance: m.instance,
acct: m.acct,
displayName: m.displayName,
avatar: m.avatar ? proxify(m.avatar) : ''
};
},
async getAccountStats() {
const m = getMastodon();
if (!m) return null;
let res;
try {
res = await apiFetch(m.instance, m.token, '/api/v1/accounts/verify_credentials');
} catch (_) {
return null;
}
if (!res.ok) return null;
const acc = await res.json().catch(() => null);
if (!acc) return null;
return {
followers: Number(acc.followers_count || 0),
following: Number(acc.following_count || 0),
posts: Number(acc.statuses_count || 0),
createdAt: acc.created_at || '',
bio: typeof acc.note === 'string' ? acc.note : '',
fields: Array.isArray(acc.fields)
? acc.fields.map(f => ({ name: String(f && f.name || ''), value: String(f && f.value || ''), verified: !!(f && f.verified_at) })).filter(f => f.name || f.value)
: []
};
},
async connectMastodon({ instance, token }) {
if (isPublic) throw new Error('fediverseErrPublic');
const origin = normalizeInstance(instance);
const tok = String(token || '').trim();
if (!tok) throw new Error('fediverseErrToken');
let res;
try {
res = await apiFetch(origin, tok, '/api/v1/accounts/verify_credentials');
} catch (_) {
throw new Error('fediverseErrConnect');
}
if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth');
if (!res.ok) throw new Error('fediverseErrConnect');
const acc = await res.json().catch(() => ({}));
const store = readStore();
store.mastodon = {
instance: origin,
token: tok,
id: String(acc.id || ''),
acct: acc.acct || acc.username || '',
displayName: acc.display_name || acc.username || '',
avatar: acc.avatar_static || acc.avatar || ''
};
writeStore(store);
invalidate();
mediaHosts = new Set();
try { mediaHosts.add(new URL(origin).host); } catch (_) {}
setModuleFlag('on');
return this.getAccount();
},
disconnect() {
const store = readStore();
delete store.mastodon;
writeStore(store);
invalidate();
mediaHosts = new Set();
return true;
},
async fetchStatuses(pathBase) {
const m = getMastodon();
const res = await apiFetch(m.instance, m.token, `${pathBase}?limit=${TIMELINE_LIMIT}`);
if (res.status === 401 || res.status === 403) return { error: 'fediverseErrAuth' };
if (!res.ok) return { error: 'fediverseErrFetch' };
const arr = await res.json().catch(() => []);
const list = Array.isArray(arr) ? arr : [];
return { posts: list.map(mapStatus).filter(Boolean) };
},
async getTimeline() {
const m = getMastodon();
if (!m) return { connected: false, posts: [] };
try { mediaHosts.add(new URL(m.instance).host); } catch (_) {}
const key = 'home';
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.ts < TIMELINE_CACHE_MS) return hit.value;
let result;
try {
result = await this.fetchStatuses('/api/v1/timelines/home');
} catch (_) {
return { connected: true, account: this.getAccount(), posts: [], error: 'fediverseErrFetch' };
}
if (result.error) return { connected: true, account: this.getAccount(), posts: [], error: result.error };
if (!result.posts.length && m.id) {
try {
const own = await this.fetchStatuses(`/api/v1/accounts/${encodeURIComponent(m.id)}/statuses`);
if (own && !own.error) result = own;
} catch (_) {}
}
const value = { connected: true, account: this.getAccount(), posts: result.posts };
if (result.posts.length) cache.set(key, { ts: now, value });
return value;
},
async getThread(id) {
const m = getMastodon();
if (!m) return null;
const sid = encodeURIComponent(String(id || ''));
let statusRes, ctxRes;
try {
statusRes = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}`);
ctxRes = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}/context`);
} catch (_) {
return { error: 'fediverseErrFetch' };
}
if (!statusRes.ok) return { error: 'fediverseErrFetch' };
const status = mapStatus(await statusRes.json().catch(() => null));
const context = ctxRes.ok ? await ctxRes.json().catch(() => ({})) : {};
return {
status,
ancestors: (Array.isArray(context.ancestors) ? context.ancestors : []).map(mapStatus).filter(Boolean),
descendants: (Array.isArray(context.descendants) ? context.descendants : []).map(mapStatus).filter(Boolean)
};
},
async uploadMedia(file) {
const m = getMastodon();
if (!m) throw new Error('fediverseErrAuth');
if (!file || !file.filepath) return null;
let buf;
try { buf = fs.readFileSync(file.filepath); } catch (_) { throw new Error('fediverseErrMedia'); }
const fd = new FormData();
fd.append('file', new Blob([buf], { type: file.mimetype || 'application/octet-stream' }), file.originalFilename || 'upload');
let res;
try {
res = await apiFetch(m.instance, m.token, '/api/v2/media', { method: 'POST', body: fd });
} catch (_) {
throw new Error('fediverseErrMedia');
}
if (!res.ok && res.status !== 202) throw new Error('fediverseErrMedia');
const data = await res.json().catch(() => ({}));
if (!data || !data.id) return null;
const id = String(data.id);
if (res.status === 202) {
const deadline = Date.now() + MEDIA_PROCESS_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(MEDIA_POLL_INTERVAL_MS);
let poll;
try { poll = await apiFetch(m.instance, m.token, `/api/v1/media/${id}`); } catch (_) { break; }
if (poll.status === 200) break;
if (poll.status !== 206 && poll.status !== 202) break;
}
}
return { id, preview: proxify(data.preview_url || data.url || ''), description: '' };
},
async postStatus({ text, inReplyToId, mediaIds, visibility } = {}) {
const m = getMastodon();
if (!m) throw new Error('fediverseErrAuth');
const status = String(text || '').trim();
const ids = Array.isArray(mediaIds) ? mediaIds.filter(Boolean) : [];
if (!status && !ids.length) throw new Error('fediverseErrEmpty');
const body = { status };
if (inReplyToId) body.in_reply_to_id = String(inReplyToId);
if (ids.length) body.media_ids = ids;
if (visibility) body.visibility = String(visibility);
let res;
try {
res = await apiFetch(m.instance, m.token, '/api/v1/statuses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (_) {
throw new Error('fediverseErrPost');
}
if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth');
if (!res.ok) throw new Error('fediverseErrPost');
invalidate();
return true;
},
async action(id, verb) {
const m = getMastodon();
if (!m) throw new Error('fediverseErrAuth');
const allowed = { reblog: 1, unreblog: 1, favourite: 1, unfavourite: 1 };
if (!allowed[verb]) throw new Error('fediverseErrPost');
const sid = encodeURIComponent(String(id || ''));
let res;
try {
res = await apiFetch(m.instance, m.token, `/api/v1/statuses/${sid}/${verb}`, { method: 'POST' });
} catch (_) {
throw new Error('fediverseErrPost');
}
if (res.status === 401 || res.status === 403) throw new Error('fediverseErrAuth');
if (!res.ok) throw new Error('fediverseErrPost');
invalidate();
return true;
},
reblog(id) { return this.action(id, 'reblog'); },
unreblog(id) { return this.action(id, 'unreblog'); },
favourite(id) { return this.action(id, 'favourite'); },
unfavourite(id) { return this.action(id, 'unfavourite'); },
isHostAllowed(host) {
if (mediaHosts.has(host)) return true;
const m = getMastodon();
if (m) {
try { if (new URL(m.instance).host === host) return true; } catch (_) {}
try { if (m.avatar && new URL(m.avatar).host === host) return true; } catch (_) {}
}
return false;
},
async proxyMedia(remoteUrl) {
let u;
try { u = new URL(String(remoteUrl)); } catch (_) { return null; }
if (u.protocol !== 'https:') return null;
if (isPrivateHost(u.hostname)) return null;
if (!this.isHostAllowed(u.host)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let current = u, res = null;
for (let hop = 0; hop < 5; hop++) {
res = await fetch(current.href, { signal: controller.signal, redirect: 'manual', headers: spoofHeaders('image/avif,image/webp,*/*') });
if (res.status < 300 || res.status >= 400) break;
const loc = res.headers.get('location');
if (!loc) return null;
let next;
try { next = new URL(loc, current); } catch (_) { return null; }
if (next.protocol !== 'https:') return null;
if (isPrivateHost(next.hostname)) return null;
current = next;
}
if (!res || !res.ok) return null;
const ct = res.headers.get('content-type') || 'application/octet-stream';
if (!/^(image|video|audio)\//i.test(ct)) return null;
const ab = await res.arrayBuffer();
return { contentType: ct, buffer: Buffer.from(ab) };
} catch (_) {
return null;
} finally {
clearTimeout(timer);
}
}
};
return api;
};

435
src/models/feed_model.js Normal file
View file

@ -0,0 +1,435 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, mergeDuplicatesBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const FEED_TEXT_MIN = Number(getConfig().feed?.minLength ?? 1);
const FEED_TEXT_MAX = Number(getConfig().feed?.maxLength ?? 280);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const cleanText = (t) => (typeof t === "string" ? t.trim() : "");
const isValidFeedText = (t) => {
const s = cleanText(t);
return s.length >= FEED_TEXT_MIN && s.length <= FEED_TEXT_MAX;
};
const getMsg = (ssbClient, id) =>
new Promise((resolve, reject) => {
ssbClient.get(id, (err, val) => (err ? reject(err) : resolve({ key: id, value: val })));
});
const getAllMessages = (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const extractTags = (text) => {
const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase());
return Array.from(new Set(list));
};
const buildIndex = async (ssbClient) => {
const messages = await getAllMessages(ssbClient);
const forward = new Map();
const replacedIds = new Set();
const tombstoned = buildValidatedTombstoneSet(messages);
const feedsById = new Map();
const authorById = new Map();
const actions = [];
const opinionMsgs = [];
for (const msg of messages) {
const c = msg?.value?.content;
const k = msg?.key;
if (!c || !k) continue;
if (c.type === 'tombstone') continue;
if (c.type === "feedOpinion") {
opinionMsgs.push({ target: c.target, author: msg?.value?.author, category: c.category });
continue;
}
if (c.type === "feed") {
feedsById.set(k, msg);
authorById.set(k, msg?.value?.author);
continue;
}
if (c.type === "feed-action") {
actions.push(msg);
continue;
}
}
for (const [k, msg] of feedsById) {
const t = msg?.value?.content?.replaces;
if (!t) continue;
if (authorById.get(t) === authorById.get(k)) {
forward.set(t, k);
replacedIds.add(t);
}
}
const resolve = (id) => {
let cur = id;
const seen = new Set();
while (forward.has(cur) && !seen.has(cur)) {
seen.add(cur);
cur = forward.get(cur);
}
return cur;
};
const actionsByRoot = new Map();
for (const a of actions) {
const c = a?.value?.content || {};
const target = c.root || c.target;
if (!target) continue;
const root = resolve(target);
if (!actionsByRoot.has(root)) actionsByRoot.set(root, []);
actionsByRoot.get(root).push(a);
}
const opinionsByTip = new Map();
for (const op of opinionMsgs) {
if (!op.target || !feedsById.has(op.target)) continue;
const tip = resolve(op.target);
if (!opinionsByTip.has(tip)) opinionsByTip.set(tip, []);
opinionsByTip.get(tip).push(op);
}
return { resolve, tombstoned, feedsById, replacedIds, actionsByRoot, opinionsByTip };
};
const resolveCurrentId = async (id) => {
const ssbClient = await openSsb();
const idx = await buildIndex(ssbClient);
return idx.resolve(id);
};
const createFeed = async (text, mentions) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (typeof text !== "string") throw new Error("Invalid text");
const cleaned = cleanText(text);
if (!isValidFeedText(cleaned)) {
if (cleaned.length < FEED_TEXT_MIN) throw new Error("Text too short");
if (cleaned.length > FEED_TEXT_MAX) throw new Error("Text too long");
throw new Error("Text required");
}
const content = {
type: "feed",
text: cleaned,
author: userId,
createdAt: new Date().toISOString(),
tags: extractTags(cleaned),
mentions: Array.isArray(mentions) && mentions.length > 0 ? mentions : undefined
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)));
});
};
const createRefeed = async (contentId) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const idx = await buildIndex(ssbClient);
const tipId = idx.resolve(contentId);
let msg;
try {
msg = idx.feedsById.get(tipId) || (await getMsg(ssbClient, tipId));
} catch {
throw new Error("Invalid feed");
}
const c = msg?.value?.content;
if (!c || c.type !== "feed") throw new Error("Invalid feed");
if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
const existing = idx.actionsByRoot.get(tipId) || [];
for (const a of existing) {
const ac = a?.value?.content || {};
if (ac.type === "feed-action" && ac.action === "refeed" && a.value?.author === userId) throw new Error("Already refeeded");
}
const action = {
type: "feed-action",
action: "refeed",
root: tipId,
createdAt: new Date().toISOString(),
author: userId
};
return new Promise((resolve, reject) => {
ssbClient.publish(action, (err, out) => (err ? reject(err) : resolve(out)));
});
};
const aggregateOpinions = (idx, tipId, ownerContent) => {
const opinions = {};
const voters = new Set();
const cc = ownerContent || idx.feedsById.get(tipId)?.value?.content || {};
const ownerVoters = Array.isArray(cc.opinions_inhabitants) ? cc.opinions_inhabitants : [];
const ownerOpinions = cc.opinions && typeof cc.opinions === "object" ? cc.opinions : {};
for (const [k, v] of Object.entries(ownerOpinions)) opinions[k] = (Number(v) || 0);
for (const voter of ownerVoters) voters.add(voter);
const actions = idx.actionsByRoot.get(tipId) || [];
for (const a of actions) {
const ac = a?.value?.content || {};
if (ac.type !== "feed-action" || ac.action !== "vote") continue;
const author = a?.value?.author || ac.author;
if (!author || voters.has(author)) continue;
voters.add(author);
const cat = String(ac.category || "");
opinions[cat] = (Number(opinions[cat]) || 0) + 1;
}
for (const op of (idx.opinionsByTip.get(tipId) || [])) {
if (!op.author || voters.has(op.author)) continue;
voters.add(op.author);
const cat = String(op.category || "");
opinions[cat] = (Number(opinions[cat]) || 0) + 1;
}
return { opinions, voters };
};
const addOpinion = async (contentId, category) => {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const idx = await buildIndex(ssbClient);
const tipId = idx.resolve(contentId);
const msg = idx.feedsById.get(tipId);
const c = msg?.value?.content;
if (!c || c.type !== "feed") throw new Error("Invalid feed");
if (!isValidFeedText(c.text)) throw new Error("Invalid feed");
const agg = aggregateOpinions(idx, tipId, c);
if (agg.voters.has(userId)) throw new Error("Already voted");
const content = { type: "feedOpinion", target: tipId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
};
const listFeeds = async (filterOrOpts = "ALL") => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const now = Date.now();
const opts = typeof filterOrOpts === "string" ? { filter: filterOrOpts } : (filterOrOpts || {});
const filter = String(opts.filter || "ALL").toUpperCase();
const q = typeof opts.q === "string" ? opts.q.trim().toLowerCase() : "";
const tag = typeof opts.tag === "string" ? opts.tag.trim().toLowerCase() : "";
const idx = await buildIndex(ssbClient);
const isValidFeedMsg = (m) => {
const c = m?.value?.content;
return !!c && c.type === "feed" && isValidFeedText(c.text);
};
let tips = Array.from(idx.feedsById.values()).filter(
(m) =>
!idx.replacedIds.has(m.key) &&
!idx.tombstoned.has(m.key) &&
isValidFeedMsg(m)
);
const textEditedEver = (m) => {
const seen = new Set();
let cur = m;
let lastText = cur?.value?.content?.text;
while (cur?.value?.content?.replaces) {
const prevId = cur.value.content.replaces;
if (!prevId || seen.has(prevId)) break;
seen.add(prevId);
const prev = idx.feedsById.get(prevId);
if (!prev) break;
if (prev?.value?.author !== cur?.value?.author) break;
const prevText = prev?.value?.content?.text;
if (typeof lastText === "string" && typeof prevText === "string" && lastText !== prevText) return true;
cur = prev;
lastText = prevText;
}
return false;
};
const materialize = (feedMsg) => {
const base = feedMsg || {};
const content = { ...(base.value?.content || {}) };
const root = base.key;
let refeeds = Number(content.refeeds || 0) || 0;
const refeedsInhabitants = new Set(Array.isArray(content.refeeds_inhabitants) ? content.refeeds_inhabitants : []);
let commentCount = 0;
const actions = idx.actionsByRoot.get(root) || [];
for (const a of actions) {
const ac = a?.value?.content || {};
const author = a?.value?.author || ac.author;
if (!author) continue;
if (ac.action === "refeed") {
if (!refeedsInhabitants.has(author)) {
refeedsInhabitants.add(author);
refeeds += 1;
}
continue;
}
if (ac.action === "comment") {
commentCount++;
continue;
}
}
const agg = aggregateOpinions(idx, root, content);
content.refeeds = refeeds;
content.refeeds_inhabitants = Array.from(refeedsInhabitants);
content.opinions = agg.opinions;
content.opinions_inhabitants = Array.from(agg.voters);
content.commentCount = commentCount;
if (!Array.isArray(content.tags)) content.tags = extractTags(content.text);
content._textEdited = textEditedEver(base);
return { ...base, value: { ...base.value, content } };
};
let feeds = dedupeBy(tips.map(materialize), m => {
const c = (m && m.value && m.value.content) || {};
const author = (m && m.value && m.value.author) || c.author;
return c.text ? [norm(author), norm(c.text)].join('|') : null;
});
const tsOf = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0;
const isOwn = (m) => m?.value?.author === userId;
feeds = mergeDuplicatesBy(feeds, m => norm(m?.value?.content?.text) || null, (a, b) => {
let keep;
if (isOwn(a) !== isOwn(b)) keep = isOwn(a) ? a : b;
else keep = tsOf(a) <= tsOf(b) ? a : b;
const drop = keep === a ? b : a;
const kc = keep.value.content || {};
const dc = drop.value.content || {};
const voters = new Set([...(kc.opinions_inhabitants || []), ...(dc.opinions_inhabitants || [])]);
const ops = { ...(kc.opinions || {}) };
for (const [cat, n] of Object.entries(dc.opinions || {})) ops[cat] = (Number(ops[cat]) || 0) + (Number(n) || 0);
const refeeders = new Set([...(kc.refeeds_inhabitants || []), ...(dc.refeeds_inhabitants || [])]);
keep.value = {
...keep.value,
content: {
...kc,
opinions: ops,
opinions_inhabitants: Array.from(voters),
refeeds_inhabitants: Array.from(refeeders),
refeeds: refeeders.size,
commentCount: (Number(kc.commentCount) || 0) + (Number(dc.commentCount) || 0)
}
};
return keep;
});
if (q) {
const terms = q.split(/\s+/).map((s) => s.trim()).filter(Boolean);
feeds = feeds.filter((m) => {
const t = String(m.value?.content?.text || "").toLowerCase();
return terms.every((term) => t.includes(term));
});
}
if (tag) feeds = feeds.filter((m) => Array.isArray(m.value?.content?.tags) && m.value.content.tags.includes(tag));
const getTs = (m) => m?.value?.timestamp || Date.parse(m?.value?.content?.createdAt || "") || 0;
const totalVotes = (m) => Object.values(m?.value?.content?.opinions || {}).reduce((s, x) => s + (Number(x) || 0), 0);
if (filter === "MINE") {
feeds = feeds.filter((m) => (m.value?.author || m.value?.content?.author) === userId);
} else if (filter === "TODAY") {
feeds = feeds.filter((m) => now - getTs(m) < 86400000);
}
if (filter === "TOP") {
feeds.sort(
(a, b) =>
(b.value?.content?.refeeds || 0) - (a.value?.content?.refeeds || 0) ||
getTs(b) - getTs(a)
);
} else {
feeds.sort((a, b) => getTs(b) - getTs(a));
}
return feeds;
};
const getFeedById = async (feedId) => {
const ssbClient = await openSsb();
const idx = await buildIndex(ssbClient);
const currentId = idx.resolve(feedId);
if (idx.tombstoned.has(currentId)) return null;
const msg = idx.feedsById.get(currentId);
if (!msg) return null;
const actions = idx.actionsByRoot.get(currentId) || [];
const content = msg.value?.content || {};
const refeedsInhabitants = [];
let refeeds = 0;
let commentCount = 0;
for (const a of actions) {
const ac = a?.value?.content || {};
if (ac.type === "feed-action" && ac.action === "refeed") {
refeeds++;
if (ac.author || a?.value?.author) refeedsInhabitants.push(ac.author || a.value.author);
}
if (ac.type === "feed-action" && ac.action === "comment") {
commentCount++;
}
}
const agg = aggregateOpinions(idx, currentId, content);
const merged = { ...content, opinions: agg.opinions, opinions_inhabitants: Array.from(agg.voters), refeeds_inhabitants: refeedsInhabitants, refeeds, commentCount };
return { key: currentId, value: { ...msg.value, content: merged } };
};
const getComments = async (feedId) => {
const ssbClient = await openSsb();
const idx = await buildIndex(ssbClient);
const currentId = idx.resolve(feedId);
const actions = idx.actionsByRoot.get(currentId) || [];
return actions
.filter(a => a?.value?.content?.type === "feed-action" && a?.value?.content?.action === "comment")
.sort((a, b) => (a?.value?.timestamp || 0) - (b?.value?.timestamp || 0));
};
const addComment = async (feedId, text) => {
const ssbClient = await openSsb();
const idx = await buildIndex(ssbClient);
const currentId = idx.resolve(feedId);
await new Promise((resolve, reject) => {
ssbClient.publish({ type: "feed-action", action: "comment", root: currentId, text: cleanText(text) }, (err) => (err ? reject(err) : resolve()));
});
};
return { createFeed, createRefeed, addOpinion, listFeeds, resolveCurrentId, getFeedById, getComments, addComment };
};

532
src/models/forum_model.js Normal file
View file

@ -0,0 +1,532 @@
const pull = require('../server/node_modules/pull-stream');
const crypto = require('crypto');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
let ssb, userId;
const ownCrypto = forumCrypto || tribeCrypto;
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
const decryptForumContent = (rawContent, rootId) => {
if (!rawContent) return rawContent;
if (!rawContent.encryptedPayload) return rawContent;
if (!ownCrypto || !tribeCrypto) return { ...rawContent, _undecryptable: true };
let keys = (rootId && ownCrypto.getKeys && ownCrypto.getKeys(rootId)) || [];
if (!keys.length && typeof ownCrypto.getAllRootIds === 'function') {
const seen = new Set();
for (const rid of ownCrypto.getAllRootIds()) {
const ks = ownCrypto.getKeys(rid) || [];
for (const k of ks) if (!seen.has(k)) { seen.add(k); keys.push(k); }
}
}
if (!keys.length) return { ...rawContent, _undecryptable: true };
const dec = tribeCrypto.decryptContent(rawContent, keys.map(k => [k]));
if (!dec || dec._undecryptable) return { ...rawContent, _undecryptable: true };
return { ...dec, _decrypted: true };
};
const ingestOwnTribeKeys = async () => {
if (!ownCrypto) return;
try {
const ssbClient = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const cfg = require('../server/ssb_config');
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m)))
);
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c || c.type !== 'tribe-keys') continue;
const memberKeys = c.memberKeys;
if (!memberKeys || typeof memberKeys !== 'object') continue;
const boxed = memberKeys[ssbClient.id];
if (!boxed) continue;
try {
const unboxed = ssbKeys.unbox(boxed, cfg.keys);
const key = typeof unboxed === 'string' ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
} catch (_) {}
}
} catch (_) {}
};
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
const readForumLog = async () => {
const ssbClient = await openSsb();
return new Promise((res, rej) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m || []))));
};
const scanForumOpenInvite = async (forumId) => {
const messages = await readForumLog();
const markerTomb = new Set();
const invTomb = new Set();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === 'forum-open-invite-tombstone' && typeof c.target === 'string') markerTomb.add(c.target);
if (c.type === 'forum-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target);
}
let best = null;
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'forum-open-invite' || c.v !== 1) continue;
if (c.target !== forumId || typeof c.code !== 'string') continue;
if (markerTomb.has(m.key)) continue;
if (c.inviteKey && invTomb.has(c.inviteKey)) continue;
const ts = (m.value && m.value.timestamp) || 0;
if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts };
}
return best;
};
async function collectTombstones(ssbClient) {
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(buildValidatedTombstoneSet(msgs)))
);
});
}
async function findActiveVote(ssbClient, targetId, voter) {
const tombstoned = await collectTombstones(ssbClient);
return new Promise((resolve, reject) => {
pull(
ssbClient.links({ source: voter, dest: targetId, rel: 'vote', values: true, keys: true }),
pull.filter(link => !tombstoned.has(link.key)),
pull.collect((err, links) => err ? reject(err) : resolve(links))
);
});
}
async function aggregateVotes(ssbClient, targetId) {
const tombstoned = await collectTombstones(ssbClient);
return new Promise((resolve, reject) => {
let positives = 0, negatives = 0;
pull(
ssbClient.links({ source: null, dest: targetId, rel: 'vote', values: true, keys: true }),
pull.filter(link => link.value.content?.vote && !tombstoned.has(link.key)),
pull.drain(
link => link.value.content.vote.value > 0 ? positives++ : negatives++,
err => err ? reject(err) : resolve({ positives, negatives })
)
);
});
}
function nestReplies(flat) {
const lookup = new Map();
const roots = [];
for (const msg of flat) {
msg.children = [];
lookup.set(msg.key, msg);
}
for (const msg of flat) {
if (msg.parent && lookup.has(msg.parent)) {
lookup.get(msg.parent).children.push(msg);
} else {
roots.push(msg);
}
}
return roots;
}
async function getMessageById(id) {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const msg = msgs.find(m => m.key === id && m.value.content?.type === 'forum');
if (!msg) throw new Error('Message not found');
return { key: msg.key, ...msg.value.content, timestamp: msg.value.timestamp };
}
return {
ingestKeys: async () => { await ingestOwnTribeKeys(); },
createForum: async (category, title, text, isPrivate = false) => {
const ssbClient = await openSsb();
const isPrivateFlag = isPrivate === true || isPrivate === 'true' || isPrivate === 'on';
const plainContent = {
type: 'forum',
category,
title,
text,
createdAt: new Date().toISOString(),
author: userId,
votes: { positives: 0, negatives: 0 },
votes_inhabitants: [],
isPrivate: isPrivateFlag
};
let content = plainContent;
let forumKey = null;
if (isPrivateFlag && ownCrypto && tribeCrypto) {
forumKey = ownCrypto.generateTribeKey();
content = tribeCrypto.encryptContent(plainContent, [forumKey], true);
}
const result = await new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
);
if (forumKey) {
ownCrypto.setKey(result.key, forumKey, 1);
try {
const ssbKeys = require('../server/node_modules/ssb-keys');
const memberKeys = { [userId]: tribeCrypto.boxKeyForMember(forumKey, userId, ssbKeys) };
await new Promise((resolve) => {
ssbClient.publish({ type: 'tribe-keys', tribeId: result.key, generation: 1, memberKeys }, () => resolve());
});
} catch (_) {}
}
return { key: result.key, ...plainContent };
},
addMessageToForum: async (forumId, message, parentId = null) => {
const ssbClient = await openSsb();
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
const rootRaw = rawRoot && rawRoot.content;
const rootDec = rootRaw && rootRaw.encryptedPayload
? decryptForumContent(rootRaw, forumId)
: rootRaw;
const isPrivate = rootDec && rootDec.isPrivate === true;
let content = {
...message,
root: forumId,
type: 'forum',
author: userId,
timestamp: new Date().toISOString(),
votes: { positives: 0, negatives: 0 },
votes_inhabitants: []
};
if (parentId) content.branch = parentId;
if (isPrivate && ownCrypto && tribeCrypto) {
const key = lookupKey(forumId);
if (!key) throw new Error('Missing forum key — cannot reply to encrypted forum');
content = tribeCrypto.encryptContent(content, [key], true);
}
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res))
);
},
generateInvite: async (forumId) => {
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
const ssbClient = await openSsb();
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
if (!rawRoot || !rawRoot.content) throw new Error('Forum not found');
const rawC = rawRoot.content;
const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC;
if (dec && dec._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted');
if (dec.author !== userId) throw new Error('Only the author can generate invites');
if (dec.isPrivate !== true) throw new Error('Only private forums use invitation codes');
const key = lookupKey(forumId);
if (!key) throw new Error('Missing forum key');
const code = crypto.randomBytes(16).toString('hex');
const inviteSalt = tribeCrypto.generateInviteSalt();
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt) }, (err) => err ? reject(err) : resolve());
});
return { code, forumId };
},
getOpenInvite: async (forumId) => {
const rec = await scanForumOpenInvite(forumId);
return rec ? { code: rec.code, by: rec.by, markerKey: rec.markerKey, inviteKey: rec.inviteKey } : null;
},
generateOpenInvite: async (forumId) => {
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
const ssbClient = await openSsb();
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
if (!rawRoot || !rawRoot.content) throw new Error('Forum not found');
const rawC = rawRoot.content;
const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC;
if (dec && dec._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted');
if (dec.author !== userId) throw new Error('Only the author can generate invites');
if (dec.isPrivate !== true) throw new Error('Only private forums use invitation codes');
if (await scanForumOpenInvite(forumId)) throw new Error('An open invitation already exists');
const key = lookupKey(forumId);
if (!key) throw new Error('Missing forum key');
const code = crypto.randomBytes(16).toString('hex');
const inviteSalt = tribeCrypto.generateInviteSalt();
const ek = tribeCrypto.encryptForInvite(key, code, inviteSalt);
const invitePub = await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'forum-invite', target: forumId, ek, salt: inviteSalt, codeHash: tribeCrypto.hashInviteCode(code, inviteSalt), multi: 1 }, (err, r) => err ? reject(err) : resolve(r));
});
await new Promise((resolve, reject) => {
ssbClient.publish({ type: 'forum-open-invite', v: 1, target: forumId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err) => err ? reject(err) : resolve());
});
return { code, forumId };
},
removeOpenInvite: async (forumId) => {
const ssbClient = await openSsb();
const rec = await scanForumOpenInvite(forumId);
if (!rec) return;
let author = null;
try {
const rawRoot = await new Promise((res, rej) => ssbClient.get(forumId, (e, m) => e ? rej(e) : res(m)));
const rawC = rawRoot && rawRoot.content;
const dec = rawC && rawC.encryptedPayload ? decryptForumContent(rawC, forumId) : rawC;
author = dec && dec.author;
} catch (_) {}
if (rec.by !== userId && author !== userId) throw new Error('Not allowed to remove this invitation');
await new Promise((resolve, reject) => ssbClient.publish({ type: 'forum-open-invite-tombstone', target: rec.markerKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve()));
if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: 'forum-invite-tombstone', target: rec.inviteKey, ts: new Date().toISOString() }, (err) => err ? reject(err) : resolve()));
},
joinByInvite: async (code) => {
if (!ownCrypto || !tribeCrypto) throw new Error('Forum crypto unavailable');
const ssbClient = await openSsb();
const messages = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? rej(e) : res(m)))
);
const invTomb = new Set();
for (const m of messages) {
const c = m.value && m.value.content;
if (c && c.type === 'forum-invite-tombstone' && typeof c.target === 'string') invTomb.add(c.target);
}
let matched = null;
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'forum-invite') continue;
if (invTomb.has(m.key)) continue;
try {
const hash = tribeCrypto.hashInviteCode(code, c.salt);
if (hash === c.codeHash) { matched = c; break; }
} catch (_) {}
}
if (!matched) throw new Error('Invalid or expired invite code');
const forumKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt);
if (!forumKey) throw new Error('Could not decrypt invite');
ownCrypto.addNewKey(matched.target, forumKey);
return { ok: true, forumId: matched.target };
},
voteContent: async (targetId, value) => {
const ssbClient = await openSsb();
const whoami = await new Promise((res, rej) =>
ssbClient.whoami((err, info) => err ? rej(err) : res(info))
);
const voter = whoami.id;
const newVal = parseInt(value, 10);
const existing = await findActiveVote(ssbClient, targetId, voter);
if (existing.length > 0) {
const prev = existing[0].value.content.vote.value;
if (prev === newVal) return existing[0];
await new Promise((resolve, reject) =>
ssbClient.publish(
{ type: 'tombstone', target: existing[0].key, timestamp: new Date().toISOString(), author: voter },
err => err ? reject(err) : resolve()
)
);
}
return new Promise((resolve, reject) =>
ssbClient.publish(
{
type: 'vote',
vote: { link: targetId, value: newVal },
timestamp: new Date().toISOString(),
author: voter
},
(err, result) => err ? reject(err) : resolve(result)
)
);
},
deleteForumById: async id => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
ssbClient.publish(
{ type: 'tombstone', target: id, timestamp: new Date().toISOString(), author: userId },
(err, res) => err ? reject(err) : resolve(res)
)
);
},
listAll: async filter => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = buildValidatedTombstoneSet(msgs);
const decode = (m) => {
const c = m.value && m.value.content;
if (!c) return null;
if (c.encryptedPayload) {
const dec = decryptForumContent(c, m.value.content.root || m.key);
return (dec && !dec._undecryptable) ? dec : null;
}
return c;
};
const forums = msgs
.map(m => ({ m, c: decode(m) }))
.filter(({ m, c }) => c && c.type === 'forum' && !c.root && !deleted.has(m.key))
.map(({ m, c }) => ({ ...c, key: m.key }));
const forumsWithVotes = await Promise.all(
forums.map(async f => {
const { positives, negatives } = await aggregateVotes(ssbClient, f.key);
return { ...f, positiveVotes: positives, negativeVotes: negatives };
})
);
const repliesByRoot = {};
msgs.forEach(m => {
const cRaw = m.value && m.value.content;
if (!cRaw) return;
const root = cRaw.encryptedPayload ? null : cRaw.root;
if (!root) {
if (!cRaw.encryptedPayload) return;
const decReply = decryptForumContent(cRaw, null);
if (!decReply || decReply._undecryptable || decReply.type !== 'forum' || !decReply.root) return;
if (deleted.has(m.key)) return;
repliesByRoot[decReply.root] = repliesByRoot[decReply.root] || [];
repliesByRoot[decReply.root].push({ key: m.key, text: decReply.text, author: decReply.author, timestamp: m.value.timestamp });
return;
}
if (cRaw.type === 'forum' && root && !deleted.has(m.key)) {
repliesByRoot[root] = repliesByRoot[root] || [];
repliesByRoot[root].push({ key: m.key, text: cRaw.text, author: cRaw.author, timestamp: m.value.timestamp });
}
});
const final = await Promise.all(
forumsWithVotes.map(async f => {
const replies = repliesByRoot[f.key] || [];
for (let r of replies) {
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
r.positiveVotes = rp;
r.negativeVotes = rn;
r.score = rp - rn;
}
const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
const positiveVotes = f.positiveVotes + replyPos;
const negativeVotes = f.negativeVotes + replyNeg;
const score = positiveVotes - negativeVotes;
const participants = new Set(replies.map(r => r.author).concat(f.author));
const messagesCount = replies.length + 1;
const lastMessage =
replies.length
? replies.reduce((a, b) => (new Date(a.timestamp) > new Date(b.timestamp) ? a : b))
: null;
return {
...f,
positiveVotes,
negativeVotes,
score,
participants: Array.from(participants),
messagesCount,
lastMessage,
messages: replies
};
})
);
const filtered =
filter === 'mine'
? final.filter(f => f.author === userId)
: filter === 'recent'
? final.filter(f => new Date(f.createdAt).getTime() >= Date.now() - 86400000)
: final;
return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
},
getForumById: async id => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = buildValidatedTombstoneSet(msgs);
const original = msgs.find(m => m.key === id && !deleted.has(m.key));
if (!original || original.value.content?.type !== 'forum') throw new Error('Forum not found');
const rawBase = original.value.content;
const base = rawBase.encryptedPayload ? decryptForumContent(rawBase, id) : rawBase;
if (base && base._undecryptable) throw new Error('Forum is encrypted and cannot be decrypted with available keys');
const { positives, negatives } = await aggregateVotes(ssbClient, id);
const replyAuthors = [];
for (const m of msgs) {
if (deleted.has(m.key)) continue;
const cRaw = m.value && m.value.content;
if (!cRaw) continue;
let rc = cRaw;
if (cRaw.encryptedPayload) {
const dec = decryptForumContent(cRaw, id);
if (!dec || dec._undecryptable) continue;
rc = dec;
}
if (rc.type === 'forum' && rc.root === id) replyAuthors.push(rc.author);
}
const participants = Array.from(new Set(replyAuthors.concat(base.author).filter(Boolean)));
return {
...base,
key: id,
positiveVotes: positives,
negativeVotes: negatives,
score: positives - negatives,
participants
};
},
getMessagesByForumId: async forumId => {
const ssbClient = await openSsb();
const msgs = await new Promise((res, rej) =>
pull(ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, data) => err ? rej(err) : res(data)))
);
const deleted = buildValidatedTombstoneSet(msgs);
const decodeReply = (m) => {
const c = m.value && m.value.content;
if (!c || c.type !== 'forum') return null;
if (c.encryptedPayload) {
const dec = decryptForumContent(c, forumId);
if (!dec || dec._undecryptable || dec.root !== forumId) return null;
return { c: dec, m };
}
if (c.root !== forumId) return null;
return { c, m };
};
const replies = msgs
.map(decodeReply)
.filter(r => r && !deleted.has(r.m.key))
.map(({ c, m }) => ({
key: m.key,
text: c.text,
author: c.author,
timestamp: m.value.timestamp,
parent: c.branch || null
}));
for (let r of replies) {
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
r.positiveVotes = rp;
r.negativeVotes = rn;
r.score = rp - rn;
}
const { positives: p, negatives: n } = await aggregateVotes(ssbClient, forumId);
const replyPos = replies.reduce((sum, r) => sum + (r.positiveVotes || 0), 0);
const replyNeg = replies.reduce((sum, r) => sum + (r.negativeVotes || 0), 0);
const positiveVotes = p + replyPos;
const negativeVotes = n + replyNeg;
const totalScore = positiveVotes - negativeVotes;
return {
messages: nestReplies(replies),
total: replies.length,
positiveVotes,
negativeVotes,
totalScore
};
},
getMessageById
};
};

67
src/models/games_model.js Normal file
View file

@ -0,0 +1,67 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 5000;
const VALID_GAMES = new Set([
'cocoland', 'ecoinflow', 'neoninfiltrator', 'spaceinvaders', 'arkanoid', 'pingpong',
'asteroids', 'tiktaktoe', 'flipflop',
'8ball', 'artillery', 'labyrinth', 'cocoman', 'tetris'
]);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
async function readAll(ssbClient) {
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => (err ? reject(err) : resolve(results)))
);
});
}
return {
async submitScore(game, score) {
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
const n = Number(score);
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
const ssbClient = await openSsb();
return new Promise((resolve, reject) => {
ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) }, (err, msg) => {
if (err) reject(err); else resolve(msg);
});
});
},
async getHallOfFame() {
const ssbClient = await openSsb();
const messages = await readAll(ssbClient);
const best = {};
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== 'gameScore') continue;
if (!VALID_GAMES.has(c.game)) continue;
const author = m.value.author;
const score = Number(c.score);
if (!Number.isFinite(score) || score < 0) continue;
const key = `${c.game}:${author}`;
if (!best[key] || score > best[key].score) {
best[key] = { author, score, game: c.game, ts: m.value.timestamp || 0 };
}
}
const hall = {};
for (const game of VALID_GAMES) hall[game] = [];
for (const entry of Object.values(best)) {
if (hall[entry.game]) hall[entry.game].push(entry);
}
for (const game of VALID_GAMES) {
hall[game] = hall[game].sort((a, b) => b.score - a.score).slice(0, 10);
}
return hall;
}
};
};

343
src/models/images_model.js Normal file
View file

@ -0,0 +1,343 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone") continue;
if (c.type === "imageOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "image") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, t] of Array.from(parent.entries())) {
const cn = nodes.get(k);
const pn = nodes.get(t);
if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue; }
if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k); }
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildImage = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: safeArr(c.tags),
author: node.author,
title: c.title || "",
description: c.description || "",
mapUrl: c.mapUrl || "",
meme: !!c.meme,
opinions: agg ? agg.opinions : (c.opinions || {}),
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "image",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Image not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Image not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "image",
url: blobId,
createdAt: now,
updatedAt: now,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
mapUrl: mapUrl || "",
meme: !!memeBool,
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "image") throw new Error("Image not found");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const aggImg = await this.getImageById(id, userId);
if (aggImg && Object.keys(aggImg.opinions || {}).some(k => (aggImg.opinions[k] || 0) > 0)) throw new Error("Cannot edit image after it has received opinions.");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
});
},
async deleteImageById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "image") throw new Error("Image not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildImage(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
else if (filter === "meme") list = list.filter((im) => im.meme === true);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((im) => {
const t = String(im.title || "").toLowerCase();
const d = String(im.description || "").toLowerCase();
const tags = safeArr(im.tags).join(" ").toLowerCase();
const a = String(im.author || "").toLowerCase();
return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getImageById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Image not found");
return buildImage(agg.contentNode, rootId, viewer, agg);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const img = await this.getImageById(id, userId);
if (!img) throw new Error("Image not found");
if (safeArr(img.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "imageOpinion", target: img.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};

View file

@ -0,0 +1,570 @@
const pull = require('../server/node_modules/pull-stream');
const ssbClientGUI = require("../client/gui");
const coolerInstance = ssbClientGUI({ offline: require('../server/ssb_config').offline });
const models = require("../models/main_models");
const { about, friend } = models({
cooler: coolerInstance,
isPublic: require('../server/ssb_config').public,
});
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
function toImageUrl(imgId, size=256){
if (!imgId) return '/assets/images/default-avatar.png';
if (typeof imgId === 'string' && imgId.startsWith('/image/')) return imgId.replace('/image/256/','/image/'+size+'/').replace('/image/512/','/image/'+size+'/');
return `/image/${size}/${encodeURIComponent(imgId)}`;
}
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
async function getLastKarmaScore(feedId) {
const ssbClient = await openSsb();
return new Promise(resolve => {
const src = ssbClient.messagesByType
? ssbClient.messagesByType({ type: "karmaScore", reverse: true })
: ssbClient.createLogStream && ssbClient.createLogStream({ reverse: true });
if (!src) return resolve(0);
pull(
src,
pull.filter(msg => {
const v = msg.value || msg;
const c = v.content || {};
return v.author === feedId && c.type === "karmaScore" && typeof c.karmaScore !== "undefined";
}),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(0);
const v = arr[0].value || arr[0];
resolve(v.content.karmaScore || 0);
})
);
});
}
async function getLastActivityTimestamp(feedId) {
const ssbClient = await openSsb();
const norm = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
return new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: feedId, reverse: true }),
pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
pull.take(1),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const m = arr[0];
const ts = norm((m.value && m.value.timestamp) || m.timestamp);
resolve(ts || null);
})
);
});
}
function bucketLastActivity(ts) {
if (!ts) return { bucket: 'red', range: '≥6m' };
const now = Date.now();
const delta = Math.max(0, now - ts);
const days = delta / 86400000;
if (days < 14) return { bucket: 'green', range: '<2w' };
if (days < 182.5) return { bucket: 'orange', range: '2w–6m' };
return { bucket: 'red', range: '≥6m' };
}
const timeoutPromise = (timeout) => new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout));
const fetchUserImageUrl = async (feedId, size=256) => {
try{
const img = await Promise.race([about.image(feedId), timeoutPromise(5000)]);
const id = typeof img === 'string' ? img : (img && (img.link || img.url));
return toImageUrl(id, size);
}catch{
return '/assets/images/default-avatar.png';
}
};
async function listAllBase(ssbClient) {
const authorsMsgs = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg => !!msg.value?.author && msg.value?.content?.type !== 'tombstone'),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const uniqueFeedIds = Array.from(new Set(authorsMsgs.map(r => r.value.author).filter(Boolean)));
const users = await Promise.all(
uniqueFeedIds.map(async (feedId) => {
const rawName = await about.name(feedId);
const name = rawName || feedId.slice(0, 10);
const description = await about.description(feedId);
const photo = await fetchUserImageUrl(feedId, 256);
const lastActivityTs = await getLastActivityTimestamp(feedId);
const { bucket, range } = bucketLastActivity(lastActivityTs);
return { id: feedId, name, description, photo, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
})
);
return Array.from(new Map(users.filter(u => u && u.id).map(u => [u.id, u])).values());
}
function normalizeRel(rel) {
const r = rel || {};
const iFollow = !!(r.following || r.iFollow || r.youFollow || r.i_follow || r.isFollowing);
const followsMe = !!(r.followsMe || r.followingMe || r.follows_me || r.theyFollow || r.isFollowedBy);
const blocking = !!(r.blocking || r.iBlock || r.isBlocking);
const blockedBy = !!(r.blocked || r.blocksMe || r.isBlockedBy);
return { iFollow, followsMe, blocking, blockedBy };
}
return {
async listInhabitants(options = {}) {
const { filter = 'all', search = '', location = '', language = '', skills = '', includeInactive = false } = options;
const ssbClient = await openSsb();
const userId = ssbClient.id;
const filterInactive = (users) => {
if (includeInactive) return users;
return users.filter(u => u.lastActivityBucket !== 'red');
};
if (filter === 'GALLERY') {
const users = await listAllBase(ssbClient);
return filterInactive(users);
}
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') {
let users = await listAllBase(ssbClient);
if (filter !== 'TOP ACTIVITY') {
users = filterInactive(users);
}
if (search) {
const q = search.toLowerCase();
users = users.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
(u.id || '').toLowerCase().includes(q)
);
}
const bytesByAuthor = await new Promise((res) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err || !Array.isArray(msgs)) return res({});
const acc = {};
for (const m of msgs) {
const author = m && m.value && m.value.author;
if (!author) continue;
try { acc[author] = (acc[author] || 0) + Buffer.byteLength(JSON.stringify(m.value), 'utf8'); } catch (_) {}
}
res(acc);
})
);
});
const withMetrics = await Promise.all(users.map(async u => {
const karmaScore = await getLastKarmaScore(u.id);
const bytes = (bytesByAuthor && bytesByAuthor[u.id]) || 0;
const carbonGrams = (bytes / (1024 * 1024)) * 0.095;
if (filter === 'TOP ECO') {
const ecoScore = karmaScore / Math.max(0.01, carbonGrams);
return { ...u, karmaScore, carbonGrams, ecoScore };
}
return { ...u, karmaScore, carbonGrams };
}));
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
return withMetrics;
}
if (filter === 'contacts') {
const all = await this.listInhabitants({ filter: 'all' });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
if (rel && (rel.following || rel.iFollow)) result.push(user);
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
if (filter === 'blocked') {
const all = await this.listInhabitants({ filter: 'all', includeInactive: true });
const result = [];
for (const user of all) {
const rel = await friend.getRelationship(user.id).catch(() => ({}));
const n = normalizeRel(rel);
if (n.blocking) result.push({ ...user, isBlocked: true });
}
return Array.from(new Map(result.map(u => [u.id, u])).values());
}
if (filter === 'SUGGESTED') {
const base = await listAllBase(ssbClient);
const active = filterInactive(base);
const cvRecords = await new Promise((res) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg => msg && msg.value && msg.value.content && msg.value.content.type === 'curriculum'),
pull.collect((err, msgs) => err ? res([]) : res(msgs))
);
});
const cvByAuthor = new Map();
for (const r of cvRecords) {
const c = r.value && r.value.content;
if (c && c.author && !cvByAuthor.has(c.author)) cvByAuthor.set(c.author, c);
}
const extractSkills = (cv) => cv ? [
...(cv.personalSkills || []),
...(cv.oasisSkills || []),
...(cv.educationalSkills || []),
...(cv.professionalSkills || [])
].map(s => String(s || '').toLowerCase()).filter(Boolean) : [];
const mecv = await this.getCVByUserId().catch(() => null);
const mySkills = extractSkills(mecv);
const rels = await Promise.all(
active.map(async u => {
if (u.id === userId) return null;
const rel = await friend.getRelationship(u.id).catch(() => ({}));
const n = normalizeRel(rel);
if (n.iFollow || n.blocking || n.blockedBy) return null;
const karmaScore = await getLastKarmaScore(u.id);
const theirSkills = extractSkills(cvByAuthor.get(u.id));
const commonSkills = mySkills.length && theirSkills.length
? Array.from(new Set(mySkills.filter(s => theirSkills.includes(s))))
: [];
const followsMeBonus = n.followsMe ? 20 : 0;
const karmaBonus = Math.min(20, Math.log10(1 + Math.max(0, karmaScore)) * 5);
const skillBonus = commonSkills.length * 4;
const activityBonus = u.lastActivityBucket === 'green' ? 5 : (u.lastActivityBucket === 'orange' ? 2 : 0);
const suggestionScore = followsMeBonus + karmaBonus + skillBonus + activityBonus;
return { user: u, rel: n, karmaScore, commonSkills, suggestionScore };
})
);
const candidates = rels.filter(Boolean).filter(x => x.suggestionScore > 0);
const enriched = candidates.map(x => ({
...x.user,
karmaScore: x.karmaScore,
followsYou: x.rel.followsMe,
commonSkills: x.commonSkills,
mutualCount: x.rel.followsMe ? 1 : 0,
suggestionScore: x.suggestionScore
}));
const unique = Array.from(new Map(enriched.map(u => [u.id, u])).values());
return unique.sort((a, b) =>
(b.suggestionScore || 0) - (a.suggestionScore || 0) ||
(b.karmaScore || 0) - (a.karmaScore || 0) ||
(b.lastActivityTs || 0) - (a.lastActivityTs || 0)
);
}
if (filter === 'CVs' || filter === 'MATCHSKILLS') {
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
if (filter === 'CVs') {
let out = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const base = this._normalizeCurriculum(c, photo);
return { ...base, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range };
}));
out = filterInactive(out);
if (search) {
const q = search.toLowerCase();
out = out.filter(u =>
(u.name || '').toLowerCase().includes(q) ||
(u.description || '').toLowerCase().includes(q) ||
u.skills.some(s => (s || '').toLowerCase().includes(q))
);
}
if (location) out = out.filter(u => (u.location || '').toLowerCase() === location.toLowerCase());
if (language) out = out.filter(u => u.languages.map(l => l.toLowerCase()).includes(language.toLowerCase()));
if (skills) {
const skillList = skills.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
out = out.filter(u => skillList.every(s => u.skills.map(k => (k || '').toLowerCase()).includes(s)));
}
return out;
}
if (filter === 'MATCHSKILLS') {
let base = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const norm = this._normalizeCurriculum(c, photo);
const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
}));
base = filterInactive(base);
const mecv = await this.getCVByUserId();
const userSkills = Array.from(new Set(
(mecv
? [
...(mecv.personalSkills || []),
...(mecv.oasisSkills || []),
...(mecv.educationalSkills || []),
...(mecv.professionalSkills || [])
]
: []).map(s => String(s || '').toLowerCase()).filter(Boolean)
));
if (!userSkills.length) return [];
const userSet = new Set(userSkills);
const matches = base.map(c => {
if (c.id === userId) return null;
const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
const theirSet = new Set(theirSkillsRaw);
const common = Array.from(theirSet).filter(s => userSet.has(s));
if (!common.length) return null;
const unionSize = userSet.size + theirSet.size - common.length;
const matchScore = unionSize > 0 ? common.length / unionSize : 0;
const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
return { ...c, commonSkills: common, matchScore, matchCoverage };
}).filter(Boolean);
return matches.sort((a, b) =>
(b.matchScore - a.matchScore) ||
(b.commonSkills.length - a.commonSkills.length) ||
((b.karmaScore || 0) - (a.karmaScore || 0)) ||
((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
);
}
}
return [];
},
_normalizeCurriculum(c, photoUrl) {
const photo = photoUrl || toImageUrl(c.photo, 256);
return {
id: c.author,
name: c.name,
description: c.description,
photo,
skills: [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
],
location: c.location,
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim())
: Array.isArray(c.languages) ? c.languages : [],
createdAt: c.createdAt
};
},
async getLatestAboutById(id) {
const ssbClient = await openSsb();
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id }),
pull.filter(msg =>
msg.value.content?.type === 'about' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
if (!records.length) return null;
const latest = records.sort((a, b) => b.value.timestamp - a.value.timestamp)[0];
return latest.value.content;
},
async getFeedByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value &&
msg.value.content &&
typeof msg.value.content.text === 'string' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records
.filter(m => typeof m.value.content.text === 'string')
.sort((a, b) => b.value.timestamp - a.value.timestamp)
.slice(0, 10);
},
async getInhabitantStats(targetId, viewerId) {
const ssbClient = await openSsb();
const target = targetId || ssbClient.id;
const viewer = viewerId || ssbClient.id;
const isOwner = viewer === target;
const arr = (v) => Array.isArray(v) ? v : [];
const up = (v) => String(v || '').toUpperCase();
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','shop','image','video','audio','document','bookmark','transfer','map']);
const accessible = (type, c) => {
if (c.encryptedPayload) return false;
switch (type) {
case 'task': return up(c.isPublic) !== 'PRIVATE' || isOwner || arr(c.assignees).includes(viewer);
case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer);
case 'forum': return c.isPrivate !== true || isOwner;
case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer);
case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner;
case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner;
case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); }
default: return true;
}
};
const counts = {};
await new Promise((resolve) => {
pull(
ssbClient.createUserStream({ id: target }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || typeof c !== 'object') return;
const type = c.type;
if (!type || !COUNTED.has(type)) return;
if (c.replaces) return;
if (!accessible(type, c)) return;
counts[type] = (counts[type] || 0) + 1;
}, () => resolve())
);
});
return counts;
},
async getCVByUserId(id) {
const ssbClient = await openSsb();
const targetId = id || ssbClient.id;
const records = await new Promise((res, rej) => {
pull(
ssbClient.createUserStream({ id: targetId }),
pull.filter(msg =>
msg.value.content?.type === 'curriculum' &&
msg.value.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
return records.length ? records[records.length - 1].value.content : null;
},
async getCandidatesForJob(job, viewerId = null) {
if (!job || typeof job !== 'object') return [];
const ssbClient = await openSsb();
const tokenize = (s) => String(s || '')
.toLowerCase()
.split(/[^a-z0-9áéíóúñü+#./-]+/i)
.map(t => t.trim())
.filter(t => t && t.length >= 2);
const stop = new Set(['the','a','an','and','or','of','to','in','for','on','with','is','are','be','as','at','by','from','that','this','it','we','you','our','your','un','una','el','la','los','las','de','del','en','con','para','por','y','o','un','una','que','se','su','sus','al','etc']);
const keywords = new Set();
const addAll = (arr) => arr.forEach(t => { if (!stop.has(t)) keywords.add(t); });
if (Array.isArray(job.tags)) job.tags.forEach(t => { const k = String(t || '').toLowerCase().trim(); if (k) keywords.add(k); });
addAll(tokenize(job.title));
addAll(tokenize(job.description));
addAll(tokenize(job.requirements));
if (keywords.size === 0) return [];
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true }),
pull.filter(msg =>
msg.value?.content?.type === 'curriculum' &&
msg.value?.content?.type !== 'tombstone'
),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
let cvs = records.map(r => r.value.content);
cvs = Array.from(new Map(cvs.map(u => [u.author, u])).values());
cvs = cvs.filter(c => String(c.visibility || 'PUBLIC').toUpperCase() !== 'HIDDEN');
const jobAuthor = job.author || null;
const out = await Promise.all(cvs.map(async c => {
if (!c.author) return null;
if (jobAuthor && c.author === jobAuthor) return null;
const cvSkills = [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
].map(s => String(s || '').toLowerCase()).filter(Boolean);
const common = Array.from(new Set(cvSkills.filter(s => keywords.has(s))));
if (common.length === 0) return null;
if (viewerId && c.author !== viewerId) {
try {
const rel = await friend.getRelationship(c.author);
if (rel && (rel.blocking || rel.blockedBy)) return null;
} catch (_) {}
}
const authorTs = await getLastActivityTimestamp(c.author);
let interactionTs = null;
try {
const cvId = c.id || c.key || null;
if (cvId) {
const ssbClient = await openSsb();
interactionTs = await new Promise((resolve) => {
try {
pull(
ssbClient.backlinks.read({ query: [{ $filter: { dest: cvId } }], index: 'DTA', reverse: true, limit: 1 }),
pull.collect((err, arr) => {
if (err || !arr || !arr.length) return resolve(null);
const m = arr[0];
const raw = (m.value && m.value.timestamp) || m.timestamp;
resolve(raw && raw < 1e12 ? raw * 1000 : raw || null);
})
);
} catch (_) { resolve(null); }
});
}
} catch (_) {}
const lastActivityTs = Math.max(authorTs || 0, interactionTs || 0) || null;
const { bucket, range } = bucketLastActivity(lastActivityTs);
if (bucket === 'red') return null;
const photo = await fetchUserImageUrl(c.author, 256);
const matchScore = common.length / keywords.size;
return {
id: c.author,
name: c.name || 'Anonymous',
description: c.description || '',
photo,
location: c.location || '',
status: c.status || '',
preferences: c.preferences || '',
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim()).filter(Boolean)
: Array.isArray(c.languages) ? c.languages : [],
commonSkills: common,
matchScore,
lastActivityTs,
lastActivityBucket: bucket,
lastActivityRange: range
};
}));
return out.filter(Boolean).sort((a, b) => {
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore;
return (b.lastActivityTs || 0) - (a.lastActivityTs || 0);
}).slice(0, 20);
},
async getPhotoUrlByUserId(id, size = 256) {
return await fetchUserImageUrl(id, size);
},
async getLastActivityTimestampByUserId(id) {
return await getLastActivityTimestamp(id);
},
bucketLastActivity(ts) {
return bucketLastActivity(ts);
}
};
};

558
src/models/jobs_model.js Normal file
View file

@ -0,0 +1,558 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeByPreferring } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const norm = (s) => String(s || "").trim().toLowerCase()
const toNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const toInt = (v, fallback = 0) => {
const n = parseInt(String(v ?? ""), 10)
return Number.isFinite(n) ? n : fallback
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const matchSearch = (job, q) => {
const qq = norm(q)
if (!qq) return true
const hay = [
job.title,
job.description,
job.requirements,
job.tasks,
job.languages,
Array.isArray(job.tags) ? job.tags.join(" ") : ""
].map(x => norm(x)).join(" ")
return hay.includes(qq)
}
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
)
const buildIndex = (messages, ssbClient) => {
const tomb = new Set()
const jobNodes = new Map()
const parent = new Map()
const child = new Map()
const naiveReplaces = new Map()
const jobSubLatest = new Map()
for (const m of messages) {
const key = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type === "job") {
jobNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) naiveReplaces.set(key, c.replaces)
continue
}
if (c.type === "job_sub" && c.jobId) {
const author = v.author
if (!author) continue
const ts = v.timestamp || m.timestamp || 0
const jobId = c.jobId
const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k)
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
continue
}
}
for (const [key, replacesId] of naiveReplaces.entries()) {
const node = jobNodes.get(key)
if (!node) continue
const orig = jobNodes.get(replacesId)
if (!orig) continue
if (String(orig.author) !== String(node.author)) { jobNodes.delete(key); continue }
parent.set(key, replacesId)
child.set(replacesId, key)
}
const rootOf = (id) => {
let cur = id
while (parent.has(cur)) cur = parent.get(cur)
return cur
}
const roots = new Set()
for (const id of jobNodes.keys()) roots.add(rootOf(id))
const tipOf = (id) => {
let cur = id
while (child.has(cur)) cur = child.get(cur)
return cur
}
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
const subsByJob = new Map()
for (const { jobId, author, value } of jobSubLatest.values()) {
if (!subsByJob.has(jobId)) subsByJob.set(jobId, new Set())
const set = subsByJob.get(jobId)
if (value) set.add(author)
else set.delete(author)
}
if (ssbClient) {
for (const m of messages) {
if (typeof m.value?.content !== 'string') continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
if (!dec?.value?.content) continue
const c = dec.value.content
if (c.type !== 'job_sub' || !c.jobId) continue
const author = dec.value.author
if (!author) continue
const ts = dec.value.timestamp || m.timestamp || 0
const jobId = c.jobId
const k = `${jobId}::${author}`
const prev = jobSubLatest.get(k)
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
} catch {}
}
}
return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
}
const buildJobObject = (node, rootId, subscribers) => {
const visibleSubs = (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (node.author || node.c?.author) ? subscribers : [];
const c = node.c || {}
let blobId = c.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const vacants = Math.max(1, toInt(c.vacants, 1))
const salaryN = toNum(c.salary)
const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
const hoursOfferedN = toNum(c.hoursOffered)
const hoursRequestedN = toNum(c.hoursRequested)
return {
id: node.key,
rootId,
job_type: c.job_type,
title: c.title,
description: c.description,
requirements: c.requirements,
languages: c.languages,
job_time: c.job_time,
tasks: c.tasks,
location: c.location,
vacants,
salary,
hoursOffered: Number.isFinite(hoursOfferedN) ? hoursOfferedN : 0,
hoursRequested: Number.isFinite(hoursRequestedN) ? hoursRequestedN : 0,
exchangeSkill: String(c.exchangeSkill || ""),
image: blobId,
author: node.author || c.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
status: c.status || "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
subscribers: Array.isArray(visibleSubs) ? visibleSubs : [],
mapUrl: c.mapUrl || "",
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: !!c.clearnetPublic
}
}
return {
type: "job",
async createJob(jobData) {
const ssbClient = await openSsb()
const job_type = String(jobData.job_type || "").toLowerCase()
if (!["freelancer", "employee", "exchange"].includes(job_type)) throw new Error("Invalid job type")
const title = String(jobData.title || "").trim()
const description = String(jobData.description || "").trim()
if (!title) throw new Error("Invalid title")
if (!description) throw new Error("Invalid description")
const vacants = Math.max(1, toInt(jobData.vacants, 1))
const salaryN = toNum(jobData.salary)
const salary = Number.isFinite(salaryN) ? salaryN.toFixed(6) : "0.000000"
const job_time = String(jobData.job_time || "").toLowerCase()
if (!["partial", "complete"].includes(job_time)) throw new Error("Invalid job time")
const location = String(jobData.location || "").toLowerCase()
if (!["remote", "presencial"].includes(location)) throw new Error("Invalid location")
let blobId = jobData.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const tags = normalizeTags(jobData.tags)
const hoursOfferedN = toNum(jobData.hoursOffered)
const hoursRequestedN = toNum(jobData.hoursRequested)
const content = {
type: "job",
job_type,
title,
description,
requirements: String(jobData.requirements || ""),
languages: String(jobData.languages || ""),
job_time,
tasks: String(jobData.tasks || ""),
location,
vacants,
salary,
hoursOffered: Number.isFinite(hoursOfferedN) && hoursOfferedN > 0 ? hoursOfferedN : 0,
hoursRequested: Number.isFinite(hoursRequestedN) && hoursRequestedN > 0 ? hoursRequestedN : 0,
exchangeSkill: String(jobData.exchangeSkill || "").trim(),
image: blobId,
tags,
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN",
mapUrl: String(jobData.mapUrl || "").trim(),
visibility: String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC",
clearnetPublic: jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => {
if (e) return rej(e)
if (m && m.key && tribeCrypto) {
const key = tribeCrypto.generateTribeKey()
tribeCrypto.setKey(m.key, key, 1)
}
res(m)
}))
},
async resolveCurrentId(jobId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, child } = buildIndex(messages, ssbClient)
let cur = jobId
while (child.has(cur)) cur = child.get(cur)
if (tomb.has(cur)) throw new Error("Job not found")
return cur
},
async resolveRootId(jobId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, parent, child } = buildIndex(messages, ssbClient)
let tip = jobId
while (child.has(tip)) tip = child.get(tip)
if (tomb.has(tip)) throw new Error("Job not found")
let root = tip
while (parent.has(root)) root = parent.get(root)
return root
},
async updateJob(id, jobData) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages, ssbClient)
const tipId = await this.resolveCurrentId(id)
const node = idx.jobNodes.get(tipId)
if (!node || !node.c) throw new Error("Job not found")
const existingContent = node.c
const author = existingContent.author
if (author !== ssbClient.id) throw new Error("Unauthorized")
const patch = {}
if (jobData.job_type !== undefined) {
const jt = String(jobData.job_type || "").toLowerCase()
if (!["freelancer", "employee", "exchange"].includes(jt)) throw new Error("Invalid job type")
patch.job_type = jt
}
if (jobData.title !== undefined) {
const t = String(jobData.title || "").trim()
if (!t) throw new Error("Invalid title")
patch.title = t
}
if (jobData.description !== undefined) {
const d = String(jobData.description || "").trim()
if (!d) throw new Error("Invalid description")
patch.description = d
}
if (jobData.requirements !== undefined) patch.requirements = String(jobData.requirements || "")
if (jobData.languages !== undefined) patch.languages = String(jobData.languages || "")
if (jobData.tasks !== undefined) patch.tasks = String(jobData.tasks || "")
if (jobData.job_time !== undefined) {
const jt = String(jobData.job_time || "").toLowerCase()
if (!["partial", "complete"].includes(jt)) throw new Error("Invalid job time")
patch.job_time = jt
}
if (jobData.location !== undefined) {
const loc = String(jobData.location || "").toLowerCase()
if (!["remote", "presencial"].includes(loc)) throw new Error("Invalid location")
patch.location = loc
}
if (jobData.vacants !== undefined) {
const v = Math.max(1, toInt(jobData.vacants, 1))
patch.vacants = v
}
if (jobData.salary !== undefined) {
const s = toNum(jobData.salary)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid salary")
patch.salary = s.toFixed(6)
}
if (jobData.hoursOffered !== undefined) {
const h = toNum(jobData.hoursOffered)
if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursOffered")
patch.hoursOffered = h
}
if (jobData.hoursRequested !== undefined) {
const h = toNum(jobData.hoursRequested)
if (!Number.isFinite(h) || h < 0) throw new Error("Invalid hoursRequested")
patch.hoursRequested = h
}
if (jobData.exchangeSkill !== undefined) patch.exchangeSkill = String(jobData.exchangeSkill || "").trim()
if (jobData.tags !== undefined) patch.tags = normalizeTags(jobData.tags)
if (jobData.image !== undefined) {
let blobId = jobData.image
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
patch.image = blobId || null
}
if (jobData.status !== undefined) {
const s = String(jobData.status || "").toUpperCase()
if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
patch.status = s
}
if (jobData.visibility !== undefined) {
patch.visibility = String(jobData.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
if (jobData.clearnetPublic !== undefined) {
patch.clearnetPublic = jobData.clearnetPublic === true || jobData.clearnetPublic === 'true' || jobData.clearnetPublic === 'on'
}
if (jobData.mapUrl !== undefined) patch.mapUrl = String(jobData.mapUrl || "").trim()
const next = {
...existingContent,
...patch,
author,
createdAt: existingContent.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId,
type: "job"
}
const tomb = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
return new Promise((res, rej) => ssbClient.publish(next, (e, m) => e ? rej(e) : res(m)))
},
async updateJobStatus(id, status) {
return this.updateJob(id, { status: String(status || "").toUpperCase() })
},
async deleteJob(id) {
const ssbClient = await openSsb()
const tipId = await this.resolveCurrentId(id)
const job = await this.getJobById(tipId)
if (!job || job.author !== ssbClient.id) throw new Error("Unauthorized")
const tomb = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
},
async subscribeToJob(id, userId) {
const ssbClient = await openSsb()
const me = ssbClient.id
const uid = userId || me
const job = await this.getJobById(id)
if (!job) throw new Error("Job not found")
if (job.author === uid) throw new Error("Cannot subscribe to your own job")
if (String(job.status || "").toUpperCase() !== "OPEN") throw new Error("Job is closed")
if (Array.isArray(job.subscribers) && job.subscribers.includes(uid)) return { alreadySubscribed: true }
const rootId = job.rootId || (await this.resolveRootId(id))
const msg = {
type: "job_sub",
jobId: rootId,
value: true,
createdAt: new Date().toISOString()
}
const recps = [me, job.author]
return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m)))
},
async unsubscribeFromJob(id, userId) {
const ssbClient = await openSsb()
const me = ssbClient.id
const uid = userId || me
const job = await this.getJobById(id)
if (!job) throw new Error("Job not found")
if (job.author === uid) throw new Error("Cannot unsubscribe from your own job")
if (Array.isArray(job.subscribers) && !job.subscribers.includes(uid)) return { notSubscribed: true }
const rootId = job.rootId || (await this.resolveRootId(id))
const msg = {
type: "job_sub",
jobId: rootId,
value: false,
createdAt: new Date().toISOString()
}
const recps = [me, job.author]
return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m)))
},
async listJobs(filter = "ALL", viewerId = null, query = {}) {
const ssbClient = await openSsb()
const me = ssbClient.id
const viewer = viewerId || me
const messages = await readAll(ssbClient)
const idx = buildIndex(messages, ssbClient)
const jobs = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.jobNodes.get(tipId)
if (!node) continue
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
const job = buildJobObject(node, rootId, subs)
if (job.visibility === "HIDDEN" && job.author !== viewer) continue
jobs.push(job)
}
const F = String(filter || "ALL").toUpperCase()
let list = dedupeByPreferring(jobs, (j) => (j.author && j.createdAt) ? norm(j.author) + "|" + norm(j.createdAt) : null, (j) => (Array.isArray(j.subscribers) ? j.subscribers.length : 0))
if (F === "MINE") list = list.filter((j) => j.author === viewer)
else if (F === "REMOTE") list = list.filter((j) => String(j.location || "").toUpperCase() === "REMOTE")
else if (F === "PRESENCIAL") list = list.filter((j) => String(j.location || "").toUpperCase() === "PRESENCIAL")
else if (F === "FREELANCER") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "FREELANCER")
else if (F === "EMPLOYEE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EMPLOYEE")
else if (F === "EXCHANGE") list = list.filter((j) => String(j.job_type || "").toUpperCase() === "EXCHANGE")
else if (F === "OPEN") list = list.filter((j) => String(j.status || "").toUpperCase() === "OPEN")
else if (F === "CLOSED") list = list.filter((j) => String(j.status || "").toUpperCase() === "CLOSED")
else if (F === "RECENT") list = list.filter((j) => moment(j.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "APPLIED") list = list.filter((j) => Array.isArray(j.subscribers) && j.subscribers.includes(viewer))
const search = String(query.search || query.q || "").trim()
const minSalary = query.minSalary ?? ""
const maxSalary = query.maxSalary ?? ""
const sort = String(query.sort || "").trim()
if (search) list = list.filter((j) => matchSearch(j, search))
const minS = toNum(minSalary)
const maxS = toNum(maxSalary)
if (Number.isFinite(minS)) list = list.filter((j) => toNum(j.salary) >= minS)
if (Number.isFinite(maxS)) list = list.filter((j) => toNum(j.salary) <= maxS)
const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
const bySalary = () => list.sort((a, b) => toNum(b.salary) - toNum(a.salary))
const bySubscribers = () => list.sort((a, b) => (b.subscribers || []).length - (a.subscribers || []).length)
if (F === "TOP") bySalary()
else if (sort === "salary") bySalary()
else if (sort === "subscribers") bySubscribers()
else byRecent()
return list
},
async getJobById(id, viewerId = null) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages, ssbClient)
let tipId = id
while (idx.child.has(tipId)) tipId = idx.child.get(tipId)
if (idx.tomb.has(tipId)) throw new Error("Job not found")
let rootId = tipId
while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
const gate = (job) => {
if (job.visibility === "HIDDEN" && job.author !== viewer) throw new Error("Job not found")
return job
}
const node = idx.jobNodes.get(tipId)
if (!node) {
const msg = await new Promise((r, j) => ssbClient.get(tipId, (e, m) => e ? j(e) : r(m)))
if (!msg || !msg.content) throw new Error("Job not found")
const tmpNode = { key: tipId, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
return gate(buildJobObject(tmpNode, rootId, subs))
}
const subsSet = idx.subsByJob.get(rootId) || new Set()
const subs = Array.from(subsSet)
return gate(buildJobObject(node, rootId, subs))
},
async getJobTipId(id) {
return this.resolveCurrentId(id)
}
}
}

942
src/models/larp_model.js Normal file
View file

@ -0,0 +1,942 @@
const pull = require('../server/node_modules/pull-stream');
const fs = require('fs');
const path = require('path');
const HOUSES_PATH = path.join(__dirname, '..', 'client', 'assets', 'larp', 'houses.json');
let HOUSES = {};
try { HOUSES = JSON.parse(fs.readFileSync(HOUSES_PATH, 'utf8')); } catch (_) { HOUSES = {}; }
const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad'];
const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase());
const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
const PROFILE_QUESTIONS = [
{
k: "larpProfileQ1",
q: "When you face a complex problem, what do you do first?",
options: [
{ k: "larpProfileQ1O1", t: "Talk to others to find consensus", w: { solaris: 3, hermandad: 1 } },
{ k: "larpProfileQ1O2", t: "Build a prototype to test", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ1O3", t: "Research the literature", w: { dogma: 3, terraverde: 1 } },
{ k: "larpProfileQ1O4", t: "Disrupt the system that creates it", w: { unsystem: 3, quark: 1 } }
]
},
{
k: "larpProfileQ2",
q: "What gives meaning to your daily work?",
options: [
{ k: "larpProfileQ2O1", t: "Defending the people I love", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ2O2", t: "Creating something tangible", w: { arrakis: 2, hermandad: 2 } },
{ k: "larpProfileQ2O3", t: "Healing or nurturing life", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ2O4", t: "Crafting words and ideas", w: { dogma: 2, solaris: 2 } },
{ k: "larpProfileQ2O5", t: "Making others laugh", w: { helix: 3, unsystem: 1 } }
]
},
{
k: "larpProfileQ3",
q: "When conflict arises in your group, you…",
options: [
{ k: "larpProfileQ3O1", t: "Lead the dialogue", w: { solaris: 3 } },
{ k: "larpProfileQ3O2", t: "Take a side and stand firm", w: { quark: 2, dogma: 1 } },
{ k: "larpProfileQ3O3", t: "Crack a joke to defuse", w: { helix: 3, unsystem: 1 } },
{ k: "larpProfileQ3O4", t: "Question whether the conflict is real",w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ3O5", t: "Look for root ecological causes", w: { terraverde: 2, dogma: 1 } }
]
},
{
k: "larpProfileQ4",
q: "Your favorite long-term project would be…",
options: [
{ k: "larpProfileQ4O1", t: "Building a city", w: { hermandad: 3, arrakis: 1 } },
{ k: "larpProfileQ4O2", t: "Restoring a forest", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ4O3", t: "Writing a constitution", w: { solaris: 2, dogma: 2 } },
{ k: "larpProfileQ4O4", t: "Organising a festival", w: { helix: 3, hermandad: 1 } },
{ k: "larpProfileQ4O5", t: "Setting up a defense network", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ4O6", t: "Designing a new machine", w: { arrakis: 3, quark: 1 } },
{ k: "larpProfileQ4O7", t: "Curating an archive", w: { dogma: 3, solaris: 1 } },
{ k: "larpProfileQ4O8", t: "Disrupting an unjust order", w: { unsystem: 3, quark: 1 } }
]
},
{
k: "larpProfileQ5",
q: "What makes a good leader?",
options: [
{ k: "larpProfileQ5O1", t: "Someone who listens and mediates", w: { solaris: 3, hermandad: 1 } },
{ k: "larpProfileQ5O2", t: "Someone who can fight and protect", w: { quark: 3, unsystem: 1 } },
{ k: "larpProfileQ5O3", t: "Someone who knows history", w: { dogma: 3, terraverde: 1 } },
{ k: "larpProfileQ5O4", t: "Someone who makes you smile", w: { helix: 3, hermandad: 1 } }
]
},
{
k: "larpProfileQ6",
q: "Your relationship with rules:",
options: [
{ k: "larpProfileQ6O1", t: "Rules emerge from dialogue and law", w: { solaris: 3 } },
{ k: "larpProfileQ6O2", t: "Rules should be followed strictly", w: { dogma: 2, quark: 2 } },
{ k: "larpProfileQ6O3", t: "Rules should be broken often", w: { unsystem: 3, helix: 1 } },
{ k: "larpProfileQ6O4", t: "Rules should serve life", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ6O5", t: "Rules build solid infrastructure", w: { hermandad: 2, arrakis: 2 } }
]
},
{
k: "larpProfileQ7",
q: "How do you handle information?",
options: [
{ k: "larpProfileQ7O1", t: "I curate and preserve it", w: { dogma: 3, hermandad: 1 } },
{ k: "larpProfileQ7O2", t: "I share it through stories", w: { helix: 2, dogma: 2 } },
{ k: "larpProfileQ7O3", t: "I question its origins", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ7O4", t: "I extract the useful bits", w: { arrakis: 2, quark: 2 } },
{ k: "larpProfileQ7O5", t: "I use it to heal", w: { terraverde: 3 } }
]
},
{
k: "larpProfileQ8",
q: "Your idea of success is…",
options: [
{ k: "larpProfileQ8O1", t: "A working machine", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ8O2", t: "A peaceful community", w: { solaris: 2, terraverde: 2 } },
{ k: "larpProfileQ8O3", t: "A lively festival", w: { helix: 3, hermandad: 1 } },
{ k: "larpProfileQ8O4", t: "A safe family", w: { quark: 3, terraverde: 1 } },
{ k: "larpProfileQ8O5", t: "An unbroken archive", w: { dogma: 3, hermandad: 1 } },
{ k: "larpProfileQ8O6", t: "A cracked dogma", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ8O7", t: "A thriving harvest", w: { terraverde: 3, hermandad: 1 } },
{ k: "larpProfileQ8O8", t: "A finished building", w: { hermandad: 3, arrakis: 1 } }
]
},
{
k: "larpProfileQ9",
q: "When you wake up, you want to…",
options: [
{ k: "larpProfileQ9O1", t: "Train your body", w: { quark: 3, helix: 1 } },
{ k: "larpProfileQ9O2", t: "Read or write", w: { dogma: 3, solaris: 1 } },
{ k: "larpProfileQ9O3", t: "Garden or cook", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ9O4", t: "Tinker with something", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ9O5", t: "Question authority", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ9O6", t: "Plan a project", w: { hermandad: 3, solaris: 1 } },
{ k: "larpProfileQ9O7", t: "Talk to friends", w: { solaris: 2, helix: 2 } },
{ k: "larpProfileQ9O8", t: "Make art", w: { helix: 3, unsystem: 1 } }
]
},
{
k: "larpProfileQ10",
q: "Your weakness might be:",
options: [
{ k: "larpProfileQ10O1", t: "Talking too much", w: { solaris: 3, dogma: 1 } },
{ k: "larpProfileQ10O2", t: "Being too pragmatic", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ10O3", t: "Being too idealistic", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ10O4", t: "Being too disruptive", w: { unsystem: 3, quark: 1 } },
{ k: "larpProfileQ10O5", t: "Being too rigid", w: { dogma: 3, quark: 1 } },
{ k: "larpProfileQ10O6", t: "Being too lighthearted", w: { helix: 3, unsystem: 1 } },
{ k: "larpProfileQ10O7", t: "Being too cautious", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ10O8", t: "Being too ambitious", w: { hermandad: 3, arrakis: 1 } }
]
}
];
const TEST_QUESTIONS_COUNT = PROFILE_QUESTIONS.length;
const SOLAR_AGE_OFFSET = 10000000 - 2026;
function computeCycle(now = new Date()) {
const year = now.getFullYear();
const monthIdx = now.getMonth();
const start = new Date(year, 0, 1);
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
const summerSolstice = new Date(year, 5, 21);
const winterSolstice = new Date(year, 11, 21);
const solsticeNum = now < summerSolstice ? 1 : (now < winterSolstice ? 2 : 1);
const houseKey = HOUSE_KEYS[monthIdx % HOUSE_KEYS.length];
const house = HOUSES[houseKey] || { short: houseKey.slice(0, 3), name: houseKey };
const solarAge = year + SOLAR_AGE_OFFSET;
const houseCycle = Math.floor(year - 2026 + 1);
return {
day: dayOfYear,
solstice: solsticeNum,
age: solarAge,
houseKey,
houseShort: house.short || houseKey.slice(0, 3),
cycle: houseCycle,
formatted: `${dayOfYear}.${solsticeNum}.${solarAge}.${house.short || houseKey.slice(0, 3)}.${houseCycle}`
};
}
function getGoverningHouseKey(now = new Date()) {
return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
}
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const houseTribeTag = (houseKey) => {
const h = HOUSES[houseKey];
return `larp-${h && h.name ? h.name : houseKey}`;
};
async function findMyHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
const client = await openSsb();
const me = client.id;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return null; }
const tag = houseTribeTag(houseKey);
const candidates = list.filter(t => {
const tags = Array.isArray(t.tags) ? t.tags : [];
const members = Array.isArray(t.members) ? t.members : [];
return tags.includes(tag) && members.includes(me);
});
if (!candidates.length) return null;
candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
return candidates[0];
}
async function findAnyHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return null; }
const tag = houseTribeTag(houseKey);
const candidates = list.filter(t => {
const tags = Array.isArray(t.tags) ? t.tags : [];
return tags.includes(tag);
});
if (!candidates.length) return null;
candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
return candidates[0];
}
async function findEarliestHouseAnchor(houseKey) {
if (!VALID_KEY(houseKey)) return null;
const client = await openSsb();
return new Promise((resolve) => {
const anchors = [];
const anchorTombstones = [];
const tribeTombstones = new Map();
const msgAuthorByKey = new Map();
pull(
client.createLogStream(),
pull.drain((m) => {
if (!m || !m.value) return;
const author = m.value.author;
if (m.key && author) msgAuthorByKey.set(m.key, author);
const c = m.value.content;
if (!c || typeof c !== 'object') return;
if (c.type === 'larpHouseTribeAnchor') {
if (c.house !== houseKey) return;
if (typeof c.tribeRootId !== 'string') return;
const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs });
} else if (c.type === 'larpHouseTribeAnchorTombstone') {
if (c.house !== houseKey) return;
if (typeof c.tribeRootId !== 'string') return;
anchorTombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: author });
} else if (c.type === 'tombstone' && typeof c.target === 'string') {
tribeTombstones.set(c.target, author);
}
}, () => {
const killedAnchorPairs = new Set();
for (const t of anchorTombstones) {
killedAnchorPairs.add(`${t.tribeRootId}|${t.tombstoneAuthor}`);
}
const deadTribes = new Set();
for (const [target, tombAuthor] of tribeTombstones) {
const tribeAuthor = msgAuthorByKey.get(target);
if (tribeAuthor && tribeAuthor === tombAuthor) deadTribes.add(target);
}
const live = anchors.filter(a =>
!killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`) &&
!deadTribes.has(a.tribeRootId)
);
if (!live.length) return resolve(null);
live.sort((a, b) => a.tribeTs - b.tribeTs);
const first = live[0];
resolve({ tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs });
})
);
});
}
async function findHouseAnchorByTribe(houseKey, tribeRootId) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
let hit = null;
pull(
client.createLogStream(),
pull.drain((m) => {
if (hit) return;
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpHouseTribeAnchor') return;
if (c.house !== houseKey) return;
if (c.tribeRootId !== tribeRootId) return;
hit = { author: m.value.author, ts: m.value.timestamp || 0 };
}, () => resolve(hit))
);
});
}
async function publishHouseTribeAnchor(houseKey, tribeRootId, tribeCreatedAt) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
client.publish({
type: 'larpHouseTribeAnchor',
house: houseKey,
tribeRootId,
tribeCreatedAt: tribeCreatedAt || new Date().toISOString(),
anchoredAt: new Date().toISOString()
}, (err, msg) => resolve(err ? null : msg));
});
}
async function listMyHouseTribes(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return [];
const client = await openSsb();
const me = client.id;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return []; }
const tag = houseTribeTag(houseKey);
const out = [];
for (const t of list) {
const tags = Array.isArray(t.tags) ? t.tags : [];
const members = Array.isArray(t.members) ? t.members : [];
if (!tags.includes(tag) || !members.includes(me)) continue;
const rootId = await tribesModel.getRootId(t.id).catch(() => t.id);
const createdAtTs = Number(Date.parse(t.createdAt || '')) || 0;
out.push({ tribe: t, rootId, createdAtTs });
}
return out;
}
async function tombstoneMyTribe(houseKey, rootId, tribeId) {
try { await tribesModel.publishTombstone(tribeId); } catch (_) {}
if (houseKey !== 'academia') {
await publishHouseAnchorTombstone(houseKey, rootId).catch(() => {});
}
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
try { tribeCrypto.dropKey(rootId); } catch (_) {}
}
}
async function ensureHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
const client = await openSsb();
const me = client.id;
if (houseKey === 'academia') {
const canonical = await findAnyHouseTribe(houseKey).catch(() => null);
const canonRoot = canonical ? await tribesModel.getRootId(canonical.id).catch(() => canonical.id) : null;
const myTribes = await listMyHouseTribes(houseKey);
myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs);
const keepRoot = canonRoot || (myTribes[0] && myTribes[0].rootId);
for (const t of myTribes) {
if (t.rootId === keepRoot) continue;
if (t.tribe.author !== me) continue;
await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id).catch(() => {});
}
const mineNow = await findMyHouseTribe(houseKey);
if (mineNow) return mineNow;
if (canonical) {
await redeemPendingAutoInvites().catch(() => {});
return await findMyHouseTribe(houseKey) || canonical;
}
const house = HOUSES[houseKey] || {};
const tag = houseTribeTag(houseKey);
try {
await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], false, 'open', null, 'PUBLIC', '');
} catch (_) {}
return await findMyHouseTribe(houseKey);
}
const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
const myTribes = await listMyHouseTribes(houseKey);
myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs);
let myCanonical = null;
if (anchor) {
myCanonical = myTribes.find(x => x.rootId === anchor.tribeRootId) || null;
}
if (!myCanonical && myTribes.length > 0) {
const myOldest = myTribes[0];
const myOldestTs = myOldest.createdAtTs;
if (!anchor) {
await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
myCanonical = myOldest;
} else if (myOldestTs > 0 && anchor.tribeTs > 0 && myOldestTs < anchor.tribeTs) {
const existingAnchor = await findHouseAnchorByTribe(houseKey, myOldest.rootId);
if (!existingAnchor) {
await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
}
myCanonical = myOldest;
}
}
for (const t of myTribes) {
if (myCanonical && t.rootId === myCanonical.rootId) continue;
if (t.tribe.author !== me) continue;
await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id);
}
if (myCanonical) {
const existingAnchorForMine = await findHouseAnchorByTribe(houseKey, myCanonical.rootId);
if (!existingAnchorForMine) {
await publishHouseTribeAnchor(houseKey, myCanonical.rootId, myCanonical.tribe.createdAt).catch(() => {});
}
return myCanonical.tribe;
}
if (anchor) return null;
const existingAny = await findAnyHouseTribe(houseKey).catch(() => null);
if (existingAny) {
const rootId = await tribesModel.getRootId(existingAny.id).catch(() => existingAny.id);
const hasAnchor = await findHouseAnchorByTribe(houseKey, rootId);
if (!hasAnchor) await publishHouseTribeAnchor(houseKey, rootId, existingAny.createdAt).catch(() => {});
if (Array.isArray(existingAny.members) && existingAny.members.includes(me)) return existingAny;
await redeemPendingAutoInvites().catch(() => {});
return await findMyHouseTribe(houseKey);
}
const house = HOUSES[houseKey] || {};
const tag = houseTribeTag(houseKey);
try {
await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], true, 'open', null, 'PRIVATE', '');
} catch (_) {}
const created = await findMyHouseTribe(houseKey);
if (created) {
const rootId = await tribesModel.getRootId(created.id).catch(() => created.id);
await publishHouseTribeAnchor(houseKey, rootId, created.createdAt).catch(() => {});
}
return created;
}
async function publishHouseAnchorTombstone(houseKey, tribeRootId) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
client.publish({
type: 'larpHouseTribeAnchorTombstone',
house: houseKey,
tribeRootId,
tombstonedAt: new Date().toISOString()
}, (err, msg) => resolve(err ? null : msg));
});
}
async function leaveMyHouseTribe(houseKey) {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myTribes = await listMyHouseTribes(houseKey);
for (const t of myTribes) {
const isSoloAuthor = t.tribe.author === me && Array.isArray(t.tribe.members) && t.tribe.members.length === 1 && t.tribe.members[0] === me;
try { await tribesModel.leaveTribe(t.tribe.id, { force: true }); } catch (_) {}
if (isSoloAuthor) {
if (houseKey !== 'academia') {
await publishHouseAnchorTombstone(houseKey, t.rootId).catch(() => {});
}
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
try { tribeCrypto.dropKey(t.rootId); } catch (_) {}
}
}
}
}
async function publishJoin(houseKey) {
if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
const client = await openSsb();
let previousHouse = null;
try { previousHouse = await getUserHouse(client.id); } catch (_) {}
await new Promise((resolve, reject) => {
client.publish({
type: 'larpJoinHouse',
house: houseKey,
joinedAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
if (previousHouse && previousHouse !== houseKey) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
await ensureHouseTribe(houseKey).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
}
async function getUserHouse(feedId) {
const client = await openSsb();
const target = feedId || client.id;
return new Promise((resolve) => {
let latest = null;
let latestTs = 0;
pull(
client.createUserStream({ id: target, reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c) return;
const ts = m.value.timestamp || 0;
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (ts > latestTs) { latestTs = ts; latest = c.house; }
} else if (c.type === 'larpLeaveLarp') {
if (ts > latestTs) { latestTs = ts; latest = null; }
}
}, () => resolve(latest))
);
});
}
async function listAllMemberships() {
const client = await openSsb();
return new Promise((resolve) => {
const byAuthor = new Map();
pull(
client.createLogStream({ reverse: true }),
pull.drain((m) => {
const author = m && m.value && m.value.author;
if (!author) return;
const c = m.value.content;
if (!c) return;
const ts = m.value.timestamp || 0;
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
const prev = byAuthor.get(author);
if (!prev || ts > prev.ts) byAuthor.set(author, { house: c.house, ts });
} else if (c.type === 'larpLeaveLarp') {
const prev = byAuthor.get(author);
if (!prev || ts > prev.ts) byAuthor.set(author, { house: null, ts });
}
}, () => {
const result = new Map();
for (const [a, v] of byAuthor.entries()) {
if (v.house) result.set(a, v.house);
}
resolve(result);
})
);
});
}
async function publishLeaveLarp() {
const client = await openSsb();
let previousHouse = null;
try { previousHouse = await getUserHouse(client.id); } catch (_) {}
await new Promise((resolve, reject) => {
client.publish({
type: 'larpLeaveLarp',
leftAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
if (previousHouse) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
}
async function listHousesWithCounts() {
const memberships = await listAllMemberships();
const counts = Object.fromEntries(HOUSE_KEYS.map(k => [k, 0]));
for (const house of memberships.values()) {
if (counts[house] !== undefined) counts[house] += 1;
}
return HOUSE_KEYS.map(key => ({
key,
...HOUSES[key],
memberCount: counts[key] || 0
}));
}
async function getMembersOfHouse(houseKey) {
if (!VALID_KEY(houseKey)) return [];
const memberships = await listAllMemberships();
const out = [];
for (const [author, house] of memberships.entries()) {
if (house === houseKey) out.push(author);
}
return out;
}
async function publishHousePost({ house, text }) {
if (!VALID_KEY(house)) throw new Error('Invalid house key');
const client = await openSsb();
const clean = String(text || '').trim().slice(0, 4000);
if (!clean) throw new Error('Empty post');
return new Promise((resolve, reject) => {
client.publish({
type: 'larpHousePost',
house,
text: clean,
createdAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
}
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
if (!VALID_KEY(houseKey)) return [];
const viewerIsMember = viewerHouse === houseKey;
if (!viewerIsMember && !isGoverning) return [];
const client = await openSsb();
const memberships = await listAllMemberships();
return new Promise((resolve) => {
const posts = [];
pull(
client.createLogStream({ reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpHousePost') return;
if (c.house !== houseKey) return;
const author = m.value.author;
const memberHouse = memberships.get(author) || 'academia';
if (memberHouse !== houseKey) return;
posts.push({
id: m.key,
author,
text: String(c.text || ''),
createdAt: c.createdAt || new Date(m.value.timestamp || 0).toISOString(),
ts: m.value.timestamp || 0
});
}, () => {
posts.sort((a, b) => b.ts - a.ts);
resolve(posts);
})
);
});
}
async function getLastTestAttempt(feedId) {
const client = await openSsb();
const target = feedId || client.id;
return new Promise((resolve) => {
let latest = null;
pull(
client.createUserStream({ id: target, reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpTestAttempt') return;
if (!VALID_KEY(c.house)) return;
const ts = m.value.timestamp || 0;
if (!latest || ts > latest.ts) latest = { house: c.house, ts, passed: c.passed === true, score: c.score || 0 };
}, () => resolve(latest))
);
});
}
async function canTakeTest(feedId) {
const last = await getLastTestAttempt(feedId);
if (!last) return { allowed: true, nextAt: 0, last: null };
const elapsed = Date.now() - last.ts;
if (elapsed >= TEST_COOLDOWN_MS) return { allowed: true, nextAt: 0, last };
return { allowed: false, nextAt: last.ts + TEST_COOLDOWN_MS, last };
}
function getProfileTest() {
return PROFILE_QUESTIONS.map(q => ({
key: q.k,
question: q.q,
options: q.options.map(o => ({ key: o.k, text: o.t }))
}));
}
function scoreProfileAnswers(answers, memberCounts = {}) {
const scores = Object.fromEntries(HOUSE_KEYS.filter(k => k !== 'academia').map(k => [k, 0]));
PROFILE_QUESTIONS.forEach((q, i) => {
const choice = Number(answers && answers[i]);
if (!Number.isInteger(choice) || choice < 0 || choice >= q.options.length) return;
const weights = q.options[choice].w || {};
for (const [house, weight] of Object.entries(weights)) {
if (scores[house] === undefined) continue;
scores[house] += Number(weight) || 0;
}
});
const ranking = Object.entries(scores).sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1];
const ma = memberCounts[a[0]] || 0;
const mb = memberCounts[b[0]] || 0;
if (ma !== mb) return ma - mb;
return a[0].localeCompare(b[0]);
});
const bestHouse = ranking[0] ? ranking[0][0] : null;
const bestScore = ranking[0] ? ranking[0][1] : 0;
return { scores, ranking, bestHouse, bestScore };
}
async function submitProfileTest({ answers }) {
const client = await openSsb();
const can = await canTakeTest(client.id);
if (!can.allowed) return { ok: false, reason: 'cooldown', nextAt: can.nextAt };
const housesWithCounts = await listHousesWithCounts();
const memberCounts = Object.fromEntries(housesWithCounts.map(h => [h.key, h.memberCount || 0]));
const { scores, ranking, bestHouse, bestScore } = scoreProfileAnswers(answers, memberCounts);
const target = bestHouse || 'academia';
await new Promise((resolve, reject) => {
client.publish({
type: 'larpTestAttempt',
house: target,
passed: true,
attemptedAt: new Date().toISOString()
}, (err) => err ? reject(err) : resolve());
});
await publishJoin(target);
return { ok: true, passed: true, house: target, score: bestScore, scores, ranking };
}
async function issueAutoInvitesForMyHouse() {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
const anchor = await findEarliestHouseAnchor(myHouse).catch(() => null);
if (!anchor) return;
let canonicalTribe;
try { canonicalTribe = await tribesModel.getTribeById(anchor.tribeRootId); } catch (_) { return; }
if (!canonicalTribe) return;
const tribeMembers = Array.isArray(canonicalTribe.members) ? canonicalTribe.members : [];
if (!tribeMembers.includes(me)) return;
const houseMembers = await getMembersOfHouse(myHouse).catch(() => []);
const missing = houseMembers.filter(id => id && id !== me && !tribeMembers.includes(id));
if (!missing.length) return;
const sent = await listMyAutoInviteRecipients(myHouse, anchor.tribeRootId).catch(() => new Set());
for (const newMember of missing) {
if (sent.has(newMember)) continue;
try {
const code = await tribesModel.generateInvite(canonicalTribe.id);
await new Promise((resolve) => {
client.publish({
type: 'larpAutoInvite',
house: myHouse,
tribeRootId: anchor.tribeRootId,
to: newMember,
code,
sentAt: new Date().toISOString(),
recps: [newMember, me]
}, () => resolve());
});
} catch (_) {}
}
}
async function listMyAutoInviteRecipients(houseKey, tribeRootId) {
const client = await openSsb();
const me = client.id;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
return new Promise((resolve) => {
const out = new Set();
pull(
client.createUserStream({ id: me }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (typeof c !== 'string' || !c.endsWith('.box')) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (decoded.house !== houseKey) return;
if (decoded.tribeRootId !== tribeRootId) return;
if (typeof decoded.to === 'string' && decoded.to !== me) out.add(decoded.to);
}, () => resolve(out))
);
});
}
async function alreadyInCanonical(houseKey) {
if (!VALID_KEY(houseKey)) return false;
const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
if (!anchor) return false;
try {
const canonical = await tribesModel.getTribeById(anchor.tribeRootId);
const client = await openSsb();
return !!(canonical && Array.isArray(canonical.members) && canonical.members.includes(client.id));
} catch (_) { return false; }
}
async function redeemPendingAutoInvites() {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
if (await alreadyInCanonical(myHouse)) return;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const codes = [];
await new Promise((resolve) => {
pull(
client.createLogStream({ reverse: true, limit: 2000 }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (typeof c !== 'string' || !c.endsWith('.box')) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
if (decoded.house !== myHouse) return;
if (m.value.author === me) return;
codes.push(decoded.code);
}, () => resolve())
);
});
for (const code of codes) {
try { await tribesModel.joinByInvite(code); } catch (_) {}
}
}
let liveSubscriberStarted = false;
let initRan = false;
let processingChain = Promise.resolve();
function enqueue(fn) {
processingChain = processingChain.then(fn).catch(() => {});
return processingChain;
}
async function runCatchup() {
try {
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (VALID_KEY(myHouse)) {
await ensureHouseTribe(myHouse).catch(() => {});
}
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
} catch (_) {}
}
async function handleLiveMessage(m) {
const c = m && m.value && m.value.content;
if (!c) return;
const client = await openSsb();
const me = client.id;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
try {
if (typeof c === 'object' && c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await issueAutoInvitesForMyHouse().catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'larpHouseTribeAnchor' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await ensureHouseTribe(c.house).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'larpHouseTribeAnchorTombstone' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await ensureHouseTribe(c.house).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
return;
}
if (typeof c === 'string' && c.endsWith('.box')) {
if (m.value.author === me) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
const myHouse = await getUserHouse(me).catch(() => null);
if (decoded.house !== myHouse) return;
if (await alreadyInCanonical(myHouse)) return;
try { await tribesModel.joinByInvite(decoded.code); } catch (_) {}
}
} catch (_) {}
}
async function init() {
if (initRan) return;
initRan = true;
if (!liveSubscriberStarted) {
liveSubscriberStarted = true;
try {
const client = await openSsb();
pull(
client.createLogStream({ live: true, old: false }),
pull.drain((m) => { enqueue(() => handleLiveMessage(m)); }, () => { liveSubscriberStarted = false; })
);
} catch (_) { liveSubscriberStarted = false; }
}
enqueue(runCatchup);
}
async function createHouseInvite(houseKey) {
if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
if (houseKey === 'academia') throw new Error('ACADEMIA does not issue invites');
const client = await openSsb();
const myHouse = await getUserHouse(client.id);
if (myHouse !== houseKey) throw new Error('Only members can issue invites');
const tribe = await ensureHouseTribe(houseKey);
if (!tribe) throw new Error('Could not resolve house tribe');
if (!tribesModel) throw new Error('tribesModel unavailable');
const code = await tribesModel.generateInvite(tribe.id);
return { code, house: houseKey, tribeId: tribe.id };
}
async function redeemHouseInvite(rawCode) {
const code = String(rawCode || '').trim();
if (!code) return { ok: false };
if (!tribesModel) return { ok: false };
const client = await openSsb();
const myHouse = await getUserHouse(client.id);
if (myHouse && myHouse !== 'academia') return { ok: false };
let rootId;
try { rootId = await tribesModel.joinByInvite(code); } catch (_) { return { ok: false }; }
if (!rootId) return { ok: false };
let tribe = null;
try { tribe = await tribesModel.getTribeById(rootId); } catch (_) { tribe = null; }
const tags = (tribe && Array.isArray(tribe.tags)) ? tribe.tags : [];
const houseTag = tags.find(t => typeof t === 'string' && t.startsWith('larp-'));
if (!houseTag) return { ok: false };
const suffix = houseTag.slice('larp-'.length);
const houseKey = HOUSE_KEYS.find(k => (HOUSES[k] && HOUSES[k].name === suffix) || k === suffix);
if (!houseKey || houseKey === 'academia') return { ok: false };
await publishJoin(houseKey);
return { ok: true, house: houseKey, tribeId: rootId };
}
return {
HOUSES,
HOUSE_KEYS,
TEST_COOLDOWN_MS,
TEST_QUESTIONS_COUNT,
PROFILE_QUESTIONS,
computeCycle,
getGoverningHouseKey,
publishJoin,
publishLeaveLarp,
getUserHouse,
listHousesWithCounts,
getMembersOfHouse,
publishHousePost,
listHousePosts,
getLastTestAttempt,
canTakeTest,
getProfileTest,
scoreProfileAnswers,
submitProfileTest,
createHouseInvite,
redeemHouseInvite,
findMyHouseTribe,
ensureHouseTribe,
leaveMyHouseTribe,
issueAutoInvitesForMyHouse,
redeemPendingAutoInvites,
init,
getHouse: (key) => HOUSES[key] || null
};
};

View file

@ -0,0 +1,99 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const MAGIC = Buffer.from('OASIS1');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
function normalizePassword(password) {
if (password && typeof password === 'object' && password.password) password = password.password;
return String(password || '');
}
function deriveKey(password, salt) {
return crypto.scryptSync(password, salt, 32);
}
function encryptBuffer(plaintext, password) {
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const key = deriveKey(password, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([MAGIC, salt, iv, tag, ciphertext]);
}
function decryptBuffer(data, password) {
if (!Buffer.isBuffer(data) || data.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN) {
throw new Error('Unrecognized or corrupt backup file.');
}
if (!data.slice(0, MAGIC.length).equals(MAGIC)) {
throw new Error('Unrecognized or corrupt backup file.');
}
let o = MAGIC.length;
const salt = data.slice(o, o += SALT_LEN);
const iv = data.slice(o, o += IV_LEN);
const tag = data.slice(o, o += TAG_LEN);
const ciphertext = data.slice(o);
const key = deriveKey(password, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
module.exports = {
exportData: async (password) => {
try {
const pw = normalizePassword(password);
const homeDir = os.homedir();
const secretFilePath = path.join(homeDir, '.ssb', 'secret');
if (!fs.existsSync(secretFilePath)) {
throw new Error(".ssb/secret file doesn't exist");
}
const original = fs.readFileSync(secretFilePath);
const encrypted = encryptBuffer(original, pw);
const roundtrip = decryptBuffer(encrypted, pw);
if (!roundtrip.equals(original)) {
throw new Error('Backup verification failed; nothing was written.');
}
const encryptedFilePath = path.join(homeDir, 'oasis.enc');
fs.writeFileSync(encryptedFilePath, encrypted, { mode: 0o600 });
return encryptedFilePath;
} catch (error) {
throw new Error("Error exporting data: " + error.message);
}
},
importData: async ({ filePath, password }) => {
try {
const pw = normalizePassword(password);
if (!fs.existsSync(filePath)) {
throw new Error('Encrypted file not found.');
}
const data = fs.readFileSync(filePath);
let decrypted;
try {
decrypted = decryptBuffer(data, pw);
} catch (_) {
throw new Error('Wrong password or corrupt backup file.');
}
const homeDir = os.homedir();
const ssbDir = path.join(homeDir, '.ssb');
fs.mkdirSync(ssbDir, { recursive: true });
const secretPath = path.join(ssbDir, 'secret');
if (fs.existsSync(secretPath)) {
fs.copyFileSync(secretPath, path.join(ssbDir, 'secret.bak-' + new Date().toISOString().replace(/[:.]/g, '-')));
}
const tmpPath = path.join(ssbDir, 'secret.tmp-' + process.pid);
fs.writeFileSync(tmpPath, decrypted, { mode: 0o600 });
fs.renameSync(tmpPath, secretPath);
try { fs.unlinkSync(filePath); } catch (_) {}
return secretPath;
} catch (error) {
throw new Error("Error importing data: " + error.message);
}
}
};

362
src/models/logs_model.js Normal file
View file

@ -0,0 +1,362 @@
const pull = require('../server/node_modules/pull-stream');
const util = require('../server/node_modules/util');
const axios = require('../server/node_modules/axios');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_MS = 7 * DAY_MS;
const MONTH_MS = 30 * DAY_MS;
const YEAR_MS = 365 * DAY_MS;
const FILTER_WINDOWS = {
today: DAY_MS,
week: WEEK_MS,
month: MONTH_MS,
year: YEAR_MS,
always: null
};
const ACTION_TYPES = new Set([
'post', 'about', 'contact', 'feed', 'bookmark', 'image', 'audio', 'video',
'document', 'torrent', 'event', 'task', 'taskAssignment',
'votes', 'vote', 'report', 'tribe', 'chat', 'chatMessage', 'pad', 'padEntry',
'forum', 'market', 'job', 'project', 'pixelia', 'map', 'mapMarker',
'shop', 'shopProduct', 'curriculum', 'gameScore',
'calendar', 'calendarDate', 'calendarNote',
'transfer', 'bankClaim', 'ubiClaim',
'parliamentCandidature', 'parliamentProposal', 'parliamentLaw',
'parliamentTerm', 'parliamentRevocation',
'courtsCase', 'courtsEvidence', 'courtsAnswer', 'courtsVerdict',
'courtsNomination', 'courtsNominationVote',
'courtsSettlementProposal', 'courtsSettlementAccepted',
'tribeParliamentCandidature', 'tribeParliamentRule'
]);
const ACTION_PHRASES = {
post: 'published a post',
about: 'updated profile information',
contact: 'followed or unfollowed someone',
feed: 'shared content in the feed',
bookmark: 'bookmarked a resource',
image: 'uploaded an image',
audio: 'uploaded an audio track',
video: 'uploaded a video',
document: 'uploaded a document',
torrent: 'shared a torrent',
event: 'created an event',
task: 'created a task',
taskAssignment: 'updated a task assignment',
votes: 'participated in a vote',
vote: 'cast a vote',
report: 'submitted a report',
tribe: 'interacted with a tribe',
chat: 'opened a chat room',
chatMessage: 'sent a chat message',
pad: 'worked on a collaborative pad',
padEntry: 'edited a pad entry',
market: 'posted in the market',
forum: 'posted in the forum',
job: 'posted a job opportunity',
project: 'advanced a project',
pixelia: 'placed a pixel in pixelia',
map: 'contributed to a map',
mapMarker: 'placed a marker on a map',
shop: 'updated a shop',
shopProduct: 'managed a shop product',
curriculum: 'edited the curriculum',
gameScore: 'logged a game score',
calendar: 'managed a calendar',
calendarDate: 'added a calendar date',
calendarNote: 'added a calendar note',
transfer: 'sent or confirmed a transfer',
bankClaim: 'completed a banking claim',
ubiClaim: 'claimed the UBI',
parliamentCandidature: 'published a parliamentary candidature',
parliamentProposal: 'published a parliamentary proposal',
parliamentLaw: 'participated in a parliamentary law',
parliamentTerm: 'participated in a parliamentary term',
parliamentRevocation: 'submitted a parliamentary revocation',
courtsCase: 'opened a courts case',
courtsEvidence: 'submitted courts evidence',
courtsAnswer: 'replied in a courts case',
courtsVerdict: 'reached a courts verdict',
courtsNomination: 'nominated a judge',
courtsNominationVote: 'voted on a judge nomination',
courtsSettlementProposal: 'proposed a courts settlement',
courtsSettlementAccepted: 'accepted a courts settlement',
tribeParliamentCandidature: 'stood for a tribe parliament',
tribeParliamentRule: 'contributed a tribe parliament rule'
};
const compact = (s, n = 200) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
module.exports = ({ cooler }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
async function listAllUserActions() {
const ssbClient = await openSsb();
const msgs = await new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
)
);
const out = [];
for (const m of msgs) {
const v = m?.value || {};
const c = v?.content;
if (!c || typeof c !== 'object' || !c.type) continue;
if (v.author !== userId) continue;
if (c.type === 'log') continue;
if (!ACTION_TYPES.has(c.type)) continue;
const ts = v.timestamp || 0;
const summary = c.title || c.text || c.question || c.subject || c.name || c.concept || c.description || '';
out.push({ key: m.key, ts, type: c.type, summary: compact(summary) });
}
return out;
}
async function callAI(prompt) {
if (!prompt) return '';
const tryOnce = async () => {
try {
const res = await axios.post('http://localhost:4001/ai', { input: prompt, raw: true }, { timeout: 90000 });
return String(res?.data?.answer || '').trim();
} catch { return ''; }
};
let out = await tryOnce();
if (!out) {
await new Promise(r => setTimeout(r, 2000));
out = await tryOnce();
}
return out;
}
function buildActionPrompt(a) {
const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' ');
const ctx = a.summary ? ` Subject: "${compact(a.summary, 120)}".` : '';
return `One first-person diary sentence about a "${a.type}" action at ${d}.${ctx} Vary phrasing. No IDs, hashes, quotes, lists or markdown.`;
}
function buildFallbackSentence(a) {
const phrase = ACTION_PHRASES[a.type] || `performed a ${a.type} action`;
const d = new Date(a.ts).toISOString().slice(0, 16).replace('T', ' ');
const ctx = a.summary ? ` — ${compact(a.summary, 120)}` : '';
return `At ${d} I ${phrase}${ctx}.`;
}
function isAImodOn() {
try { return getConfig().modules?.aiMod === 'on'; } catch { return false; }
}
async function publishLog({ text, label, mode, ref }) {
const ssbClient = await openSsb();
const content = {
type: 'log',
text: String(text || '').slice(0, 8000),
label: String(label || '').slice(0, 200),
mode: mode === 'ai' ? 'ai' : 'manual',
createdAt: new Date().toISOString(),
timestamp: Date.now(),
private: true
};
if (ref) content.ref = String(ref);
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, [userId]);
}
async function republishLog({ replaces, text, label, mode, createdAt }) {
const ssbClient = await openSsb();
const content = {
type: 'log',
replaces,
text: String(text || '').slice(0, 8000),
label: String(label || '').slice(0, 200),
mode: mode === 'ai' ? 'ai' : 'manual',
createdAt: createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
timestamp: Date.now(),
private: true
};
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, [userId]);
}
async function publishTombstone(target) {
const ssbClient = await openSsb();
const content = {
type: 'tombstone',
target,
deletedAt: new Date().toISOString(),
author: userId,
private: true
};
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, [userId]);
}
async function createManual(label, text) {
await openSsb();
const t = String(text || '').trim();
if (!t) return { status: 'empty' };
await publishLog({ text: t, label: String(label || '').trim(), mode: 'manual' });
return { status: 'ok' };
}
function sigOf(label, text) {
return `${String(label || '').trim()}||${String(text || '').trim().slice(0, 120)}`;
}
async function getProcessedState() {
const items = await readAllLogMessages();
const refs = new Set();
const sigs = new Set();
for (const it of items) {
if (it.ref) refs.add(it.ref);
sigs.add(sigOf(it.label, it.text));
}
return { refs, sigs };
}
async function createAI() {
await openSsb();
if (!isAImodOn()) return { status: 'ai_disabled' };
const actions = await listAllUserActions();
if (!actions.length) return { status: 'no_actions' };
const state = await getProcessedState();
const pending = actions.filter(a => a.key && !state.refs.has(a.key));
if (!pending.length) return { status: 'no_new_actions' };
const MAX_ACTIONS = 40;
const slice = pending.slice(0, MAX_ACTIONS);
let published = 0;
let aiFails = 0;
let aiDown = false;
for (const a of slice) {
let sentence = '';
if (!aiDown) {
sentence = await callAI(buildActionPrompt(a));
if (!sentence) {
aiFails++;
if (aiFails >= 3) aiDown = true;
} else {
aiFails = 0;
}
}
if (!sentence) sentence = buildFallbackSentence(a);
if (!sentence) continue;
const sig = sigOf(a.type, sentence);
if (state.sigs.has(sig)) { state.refs.add(a.key); continue; }
await publishLog({ text: sentence, label: a.type, mode: 'ai', ref: a.key });
state.refs.add(a.key);
state.sigs.add(sig);
published++;
await new Promise(r => setTimeout(r, 300));
}
if (!published) return { status: 'no_narrative' };
return { status: 'ok', count: published };
}
async function readAllLogMessages() {
const ssbClient = await openSsb();
const raw = await new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ reverse: false, limit: logLimit }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
)
);
const items = [];
const tombstoned = buildValidatedTombstoneSet(raw);
const replaced = new Map();
for (const m of raw) {
if (!m || !m.value) continue;
const keyIn = m.key;
const valueIn = m.value;
const tsIn = m.timestamp || valueIn?.timestamp || Date.now();
let dec;
try {
dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn });
} catch { continue; }
const v = dec?.value;
const c = v?.content;
if (!c) continue;
if (v.author !== userId) continue;
if (c.type === 'tombstone') continue;
if (c.type !== 'log') continue;
if (c.replaces) replaced.set(c.replaces, dec.key || keyIn);
items.push({
key: dec.key || keyIn,
author: v.author,
ts: v.timestamp || tsIn,
createdAt: c.createdAt || new Date(v.timestamp || tsIn).toISOString(),
text: String(c.text || ''),
label: String(c.label || ''),
mode: c.mode === 'ai' ? 'ai' : 'manual',
replaces: c.replaces || null,
ref: c.ref || null
});
}
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
survivors.sort((a, b) => b.ts - a.ts);
return survivors;
}
async function listLogs(filter = 'today') {
const items = await readAllLogMessages();
const win = FILTER_WINDOWS[filter];
if (win === null || win === undefined) return items;
const cutoff = Date.now() - win;
return items.filter(i => i.ts >= cutoff);
}
async function getLogById(id) {
const items = await readAllLogMessages();
return items.find(i => i.key === id) || null;
}
async function updateLog(id, { text, label, mode }) {
const current = await getLogById(id);
if (!current) return { status: 'not_found' };
await republishLog({
replaces: current.key,
text: text !== undefined ? text : current.text,
label: label !== undefined ? label : current.label,
mode: mode || current.mode,
createdAt: current.createdAt
});
return { status: 'ok' };
}
async function deleteLog(id) {
const current = await getLogById(id);
if (!current) return { status: 'not_found' };
await publishTombstone(current.key);
return { status: 'ok' };
}
async function countLogs() {
const items = await readAllLogMessages();
return items.length;
}
return {
createManual,
createAI,
updateLog,
deleteLog,
getLogById,
listLogs,
countLogs,
isAImodOn
};
};

2347
src/models/main_models.js Normal file

File diff suppressed because it is too large Load diff

920
src/models/maps_model.js Normal file
View file

@ -0,0 +1,920 @@
const pull = require("../server/node_modules/pull-stream");
const crypto = require("crypto");
const { getConfig } = require("../configs/config-manager.js");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { collabContent, openInviteOf } = require('../backend/collab_content');
const mapCollab = collabContent({ membersField: 'members', undecField: 'encrypted', contentFields: ['title', 'description', 'image'], listFields: ['tags', 'invites', 'markers'] });
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const INVITE_CODE_BYTES = 16;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const ALLOWED_MAP_TYPES = new Set(["OPEN", "CLOSED", "SINGLE"]);
module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const ownCrypto = mapCrypto || tribeCrypto;
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null;
const lookupKeys = (rid) => {
const a = (ownCrypto && ownCrypto.getKeys(rid)) || [];
if (a.length) return a;
return (tribeCrypto && tribeCrypto.getKeys(rid)) || [];
};
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0);
const rotateMapKey = async (rootId, remainingMembers) => {
if (!ownCrypto || !tribeCrypto || !rootId) return;
const existing = lookupKey(rootId);
if (!existing) return;
const newKey = ownCrypto.generateTribeKey();
const newGen = ownCrypto.addNewKey(rootId, newKey);
if (!Array.isArray(remainingMembers) || !remainingMembers.length) return;
const ssbClient = await openSsb();
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
for (const m of remainingMembers) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve());
});
}
};
const ingestOwnTribeKeys = async () => {
if (!ownCrypto) return;
try {
const ssbClient = await openSsb();
const ssbKeys = require("../server/node_modules/ssb-keys");
const config = require("../server/ssb_config");
const msgs = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m))));
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c || c.type !== "tribe-keys") continue;
const memberKeys = c.memberKeys;
if (!memberKeys || typeof memberKeys !== "object") continue;
const boxed = memberKeys[ssbClient.id];
if (!boxed) continue;
try {
const unboxed = ssbKeys.unbox(boxed, config.keys);
const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null);
if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key);
} catch (_) {}
}
} catch (_) {}
};
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, ['map', 'mapMarker']) : msgs;
const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
const encryptStandalone = (content, rootId) => {
if (!tribeCrypto || !rootId) return content;
const key = lookupKey(rootId);
if (!key) return content;
return tribeCrypto.encryptContent(content, [key], true);
};
const tryDecryptPublicInviteKey = (invites) => {
if (!tribeCrypto || !Array.isArray(invites)) return null;
for (const inv of invites) {
if (!inv || typeof inv !== "object" || inv.public !== true) continue;
if (typeof inv.code !== "string") continue;
if (typeof inv.ek === "string") {
try {
const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
if (k) return k;
} catch (_) {}
}
if (typeof inv.ekChain === "string") {
try {
const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
} catch (_) {}
}
}
return null;
};
const decryptMapRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content;
if (!tribeCrypto) return content;
const keys = lookupKeys(rootId);
let candidateChains = (keys || []).map(k => [k]);
const pubKey = tryDecryptPublicInviteKey(content.invites);
if (pubKey) candidateChains.push([pubKey]);
if (!candidateChains.length) return { ...content, _undecryptable: true };
return tribeCrypto.decryptContent(content, candidateChains);
};
const decryptIndexNodes = async (idx) => {
if (!tribeCrypto) return;
for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) {
if (!n.c || !n.c.encryptedPayload) continue;
const root = idx.rootOf(k);
let dec = null;
if (n.c.tribeId && tribesModel) {
try {
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel);
if (r && !r._undecryptable) dec = r;
} catch (_) {}
}
if (!dec) {
const r = decryptMapRoot(n.c, root);
if (r && !r._undecryptable) dec = r;
}
if (dec) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
}
}
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve) => {
ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const markers = new Map();
const rawMarkers = new Map();
const authorByKey = new Map();
const tombRequests = [];
const memberMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone" && c.target) {
tombRequests.push({ target: c.target, author: v.author });
continue;
}
if (c.type === "mapMember" && c.target) {
memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, author: v.author, ts: v.timestamp || m.timestamp || 0, code: typeof c.code === "string" ? c.code : "" });
continue;
}
if (c.type === "mapMarker") {
const mapId = c.mapId;
if (mapId) {
authorByKey.set(k, v.author);
if (!rawMarkers.has(mapId)) rawMarkers.set(mapId, []);
rawMarkers.get(mapId).push({
key: k,
ts: v.timestamp || m.timestamp || 0,
c,
envAuthor: v.author
});
}
continue;
}
if (c.type !== "map") continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c, author: v.author });
authorByKey.set(k, v.author);
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes.entries()) {
const t = node.c.replaces;
if (!t) continue;
const orig = nodes.get(t);
if (orig && orig.author === node.author) strictChild.set(t, k);
}
const rootOf = (id) => {
let cur = id;
while (parent.has(cur)) cur = parent.get(cur);
return cur;
};
const tipOf = (id) => {
let cur = id;
while (child.has(cur)) cur = child.get(cur);
return cur;
};
const contentTipOf = (root) => {
const rn = nodes.get(root);
if (!rn) return root;
let cur = root, best = root, g = 0;
const seen = new Set();
while (child.has(cur) && !seen.has(cur) && g++ < 100000) {
seen.add(cur);
const next = child.get(cur);
const n = nodes.get(next);
if (!n) break;
if (n.author === rn.author && !tomb.has(next)) best = next;
cur = next;
}
return best;
};
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target);
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
}
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, tipOf(r));
const contentTipByRoot = new Map();
for (const r of roots) contentTipByRoot.set(r, contentTipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const memberByRoot = new Map();
const consumedByRoot = new Map();
for (const mm of memberMsgs) {
if (!nodes.has(mm.target)) continue;
const r = rootOf(mm.target);
const ownerAuthor = nodes.get(r) && nodes.get(r).author;
if (!ownerAuthor) continue;
const self = mm.member === mm.author;
const byOwner = mm.author === ownerAuthor;
if (!self && !byOwner) continue;
if (!mm.member) continue;
if (mm.on && mm.code) {
if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set());
consumedByRoot.get(r).add(mm.code);
}
if (!memberByRoot.has(r)) memberByRoot.set(r, new Map());
const m2 = memberByRoot.get(r);
const prev = m2.get(mm.member);
if (!prev || mm.ts >= prev.ts) m2.set(mm.member, { on: mm.on, ts: mm.ts });
}
const isCodeConsumed = (root, code) => {
if (!code) return false;
const set = consumedByRoot.get(root);
return !!(set && set.has(code));
};
const resolveMembers = (root) => {
const ownerNode = nodes.get(root);
const oc = ownerNode ? ownerNode.c : {};
const set = new Set(Array.isArray(oc.members) ? oc.members.filter(x => typeof x === "string" && x) : []);
for (const [mem, st] of (memberByRoot.get(root) || new Map())) {
if (st.on) set.add(mem); else set.delete(mem);
}
return [...set];
};
const ensureMemberKeys = async (ssbClient, messages, items) => {
if (!tribeCrypto) return;
const distributed = new Map();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c || c.type !== "tribe-keys" || !c.tribeId) continue;
const mk = c.memberKeys;
if (!mk || typeof mk !== "object") continue;
if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set());
for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id);
}
const ssbKeys = require("../server/node_modules/ssb-keys");
for (const item of (Array.isArray(items) ? items : [])) {
if (!item || item.encrypted) continue;
const rootId = item.rootId;
if (!rootId) continue;
const key = lookupKey(rootId);
if (!key) continue;
const have = distributed.get(rootId) || new Set();
const missing = (Array.isArray(item.members) ? item.members : []).filter(m => m && m !== ssbClient.id && !have.has(m));
if (!missing.length) continue;
const memberKeys = {};
for (const m of missing) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
}
if (!Object.keys(memberKeys).length) continue;
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve())
});
}
}
return { tomb, nodes, parent, child, strictChild, rootOf, tipOf, contentTipOf, tipByRoot, contentTipByRoot, forward, markers, rawMarkers, resolveMembers, isCodeConsumed };
};
const expandMarkers = async (idx) => {
for (const [mapId, raws] of idx.rawMarkers.entries()) {
const list = [];
for (const r of raws) {
let c = r.c;
if (c.encryptedPayload && tribeCrypto && tribesModel) {
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
if (dec && !dec._undecryptable) c = dec;
}
list.push({
key: r.key,
lat: parseFloat(c.lat) || 0,
lng: parseFloat(c.lng) || 0,
label: c.label || "",
image: c.image || "",
author: r.envAuthor,
encrypted: !!(r.c.encryptedPayload && (!c || c._undecryptable)),
createdAt: c.createdAt || new Date(r.ts).toISOString()
});
}
idx.markers.set(mapId, list);
}
};
const buildMap = (node, rootId, viewerId, markerList = [], members = null) => {
const c = node.c || {};
const undec = c.encryptedPayload && c._decrypted === false;
return {
key: node.key,
rootId,
title: undec ? "" : (c.title || ""),
lat: parseFloat(c.lat) || 0,
lng: parseFloat(c.lng) || 0,
description: undec ? "" : (c.description || ""),
markerLabel: undec ? "" : (c.markerLabel || ""),
image: undec ? "" : (c.image || ""),
mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
tags: safeArr(c.tags),
author: node.author,
members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []),
invites: Array.isArray(c.invites) ? c.invites : [],
tribeId: c.tribeId || null,
encrypted: !!undec,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
markers: markerList.filter((mk) => !mk.tombstoned)
};
};
const collectMaps = (idx, viewerId) => {
const items = [];
for (const [rootId, tipId] of idx.contentTipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
const markerList = safeArr(idx.markers.get(tipId)).concat(safeArr(idx.markers.get(rootId)));
items.push(buildMap(node, rootId, viewerId, markerList, idx.resolveMembers(rootId)));
}
return items;
};
return {
type: "map",
async ingestKeys() { await ingestOwnTribeKeys() },
async pruneOrphanKeys() {
if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0;
try {
const ssbClient = await openSsb();
const messages = await new Promise((resolve, reject) => pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? reject(e) : resolve(m))));
const live = new Set();
const tomb = buildValidatedTombstoneSet(messages);
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === "map") live.add(m.key);
}
const all = ownCrypto.getAllRootIds();
let removed = 0;
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1; } catch (_) {}
}
}
return removed;
} catch (_) { return 0; }
},
async leaveMap(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author === userId) throw new Error("Author cannot leave their own map");
if (!Array.isArray(map.members) || !map.members.includes(userId)) return;
const members = map.members.filter(m => m !== userId);
const rootId = await this.resolveRootId(map.rootId || map.key);
const content = { type: "mapMember", target: rootId, member: userId, on: false, createdAt: new Date().toISOString() };
const result = await new Promise((resolve, reject) => ssbClient.publish(content, (e, r) => e ? reject(e) : resolve(r)));
try { await rotateMapKey(rootId, members); } catch (_) {}
return result;
},
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
let root = id;
while (idx.parent.has(root)) root = idx.parent.get(root);
const tip = idx.contentTipOf(root);
if (idx.tomb.has(tip)) throw new Error("Map not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
let root = id;
while (idx.parent.has(root)) root = idx.parent.get(root);
const tip = idx.contentTipOf(root);
if (idx.tomb.has(tip)) throw new Error("Map not found");
return root;
},
async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
let plainContent = {
type: "map",
title: title || "",
lat: parseFloat(lat) || 0,
lng: parseFloat(lng) || 0,
description: description || "",
markerLabel: markerLabel || "",
mapType: mType,
author: userId,
members: [userId],
invites: [],
tags,
...(tribeId ? { tribeId } : {}),
...(image ? { image } : {}),
createdAt: now,
updatedAt: now
};
const isPublicOpen = mType === "OPEN" && !tribeId;
const shouldEncryptStandalone = !tribeId && !isPublicOpen && tribeCrypto;
let mapKey = null;
let content = plainContent;
if (tribeId) {
content = await encryptIfTribe(plainContent);
} else if (shouldEncryptStandalone) {
mapKey = ownCrypto.generateTribeKey();
content = tribeCrypto.encryptContent(plainContent, [mapKey], true);
}
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
if (mapKey) {
ownCrypto.setKey(result.key, mapKey, 1);
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys);
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve());
});
} catch (_) {}
if (mType === "OPEN") {
try {
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
const inviteSalt = tribeCrypto.generateInviteSalt();
const ek = tribeCrypto.encryptForInvite(mapKey, pubCode, inviteSalt);
let updated = {
type: "map",
replaces: result.key,
title: plainContent.title,
lat: plainContent.lat,
lng: plainContent.lng,
description: plainContent.description,
markerLabel: plainContent.markerLabel,
mapType: mType,
author: userId,
members: [userId],
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
tags,
...(image ? { image } : {}),
createdAt: now,
updatedAt: new Date().toISOString()
};
updated = encryptStandalone(updated, result.key);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: result.key, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
} catch (_) {}
}
}
return result;
},
async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const rootId = await this.resolveRootId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg) throw new Error("Map not found");
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
const tribeIdHint = isWrapped ? null : (oldMsg.content && oldMsg.content.tribeId);
const oldDecrypted = (isWrapped || tribeIdHint)
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
if (!oldDecrypted || (oldDecrypted.type && oldDecrypted.type !== "map" && !isWrapped && oldMsg.content?.type !== "map")) throw new Error("Map not found");
assertReadable(oldDecrypted, "Map");
if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDecrypted.tags);
const now = new Date().toISOString();
const mType = mapType && ALLOWED_MAP_TYPES.has(mapType) ? mapType : oldDecrypted.mapType;
let updated = {
type: "map",
replaces: tipId,
title: title !== undefined ? title || "" : oldDecrypted.title || "",
lat: lat !== undefined ? parseFloat(lat) || 0 : oldDecrypted.lat,
lng: lng !== undefined ? parseFloat(lng) || 0 : oldDecrypted.lng,
description: description !== undefined ? description || "" : oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: mType,
tags,
author: oldDecrypted.author || userId,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [],
...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}),
...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
createdAt: oldDecrypted.createdAt,
updatedAt: now
};
if (effectiveTribeId) {
updated = await encryptIfTribe(updated);
} else if (mType !== "SINGLE") {
updated = encryptStandalone(updated, rootId);
}
const result = await new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
});
const tombstone = await tombFor(tipId, effectiveTribeId, userId);
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return result;
},
async deleteMapById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg) throw new Error("Map not found");
const decrypted = await decryptIfTribe(msg.content);
if (!decrypted) throw new Error("Map not found");
if ((decrypted.author || msg.content.author) !== userId) throw new Error("Not the author");
const tombstone = await tombFor(tipId, decrypted.tribeId, userId);
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async addMarker(mapId, lat, lng, label, image) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
let tipId = mapId;
while (idx.forward.has(tipId)) tipId = idx.forward.get(tipId);
if (idx.tomb.has(tipId)) throw new Error("Map not found");
const node = idx.nodes.get(tipId);
if (!node) throw new Error("Map not found");
const mapDecrypted = await decryptIfTribe(node.c);
assertReadable(mapDecrypted, "Map");
const mapType = mapDecrypted.mapType || node.c.mapType || "SINGLE";
const mapAuthor = mapDecrypted.author || node.c.author;
if (mapType === "SINGLE") throw new Error("Map does not allow markers");
if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers");
const now = new Date().toISOString();
const rootId = idx.rootOf(tipId);
let content = {
type: "mapMarker",
mapId: rootId,
lat: parseFloat(lat) || 0,
lng: parseFloat(lng) || 0,
label: label || "",
author: userId,
createdAt: now,
...(node.c.tribeId ? { tribeId: node.c.tribeId } : {})
};
if (image) content.image = image;
if (node.c.tribeId) {
content = await encryptIfTribe(content);
} else if (tribeCrypto) {
const mapKey = lookupKey(rootId);
if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
await decryptIndexNodes(idx);
await expandMarkers(idx);
let list = mapCollab.visibleThenCollapsed(collectMaps(idx, viewerId), viewerId);
const now = Date.now();
if (filter === "mine") list = list.filter((m) => String(m.author) === String(viewerId));
else if (filter === "recent") list = list.filter((m) => new Date(m.createdAt).getTime() >= now - 86400000);
if (q) {
list = list.filter((m) => {
const d = String(m.description || "").toLowerCase();
const tags = safeArr(m.tags).join(" ").toLowerCase();
const a = String(m.author || "").toLowerCase();
return d.includes(q) || tags.includes(q) || a.includes(q);
});
}
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
try { await ensureMemberKeys(ssbClient, messages, list); } catch (_) {}
return list;
},
async getMapById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
await decryptIndexNodes(idx);
await expandMarkers(idx);
let root = id;
while (idx.parent.has(root)) root = idx.parent.get(root);
const tip = idx.contentTipOf(root);
if (idx.tomb.has(tip)) throw new Error("Map not found");
const node = idx.nodes.get(tip);
if (!node) {
const msg = await getMsg(ssbClient, tip);
if (!msg || msg.content?.type !== "map") throw new Error("Map not found");
let c = msg.content;
if (c.encryptedPayload && tribeCrypto && tribesModel) {
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
}
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
return buildMap({ key: tip, ts: msg.timestamp || 0, c, author: msg.author }, root, viewer, markerList, idx.resolveMembers(root));
}
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
const map = buildMap(node, root, viewer, markerList, idx.resolveMembers(root));
return mapCollab.fold(map, collectMaps(idx, viewer));
},
async generateInvite(mapId, opts = {}) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author !== userId) throw new Error("Only the author can generate invites");
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
let invite = code;
if (tribeCrypto && !map.tribeId) {
const inviteSalt = tribeCrypto.generateInviteSalt();
const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code, inviteSalt);
if (ekChain) invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(map.rootId || map.key) || 1, ...(opts.public ? { public: true } : {}) };
}
if (opts.public && typeof invite !== "object") invite = { code, public: true };
const tipId = await this.resolveCurrentId(mapId);
const rootId = await this.resolveRootId(mapId);
const oldMsg = await getMsg(ssbClient, tipId);
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite];
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites,
...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (effectiveTribeId) updated = await encryptIfTribe(updated);
else updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve()));
return code;
},
async getOpenInvite(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
return openInviteOf(await this.getMapById(mapId, userId).catch(() => null));
},
async generateOpenInvite(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author !== userId) throw new Error("Only the author can generate invites");
const existing = (Array.isArray(map.invites) ? map.invites : []).find(inv => typeof inv === "object" && inv.public === true);
if (existing) throw new Error("An open invitation already exists");
return this.generateInvite(mapId, { public: true });
},
async removeOpenInvite(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author !== userId) throw new Error("Only the author can remove invites");
const tipId = await this.resolveCurrentId(mapId);
const rootId = await this.resolveRootId(mapId);
const oldMsg = await getMsg(ssbClient, tipId);
const isWrapped = tribeCrypto && tribeCrypto.isTribeMsg(oldMsg.content);
const oldDecrypted = (isWrapped || (oldMsg.content && oldMsg.content.tribeId))
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const effectiveTribeId = oldDecrypted && oldDecrypted.tribeId;
const invites = (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true));
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites,
...(effectiveTribeId ? { tribeId: effectiveTribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (effectiveTribeId) updated = await encryptIfTribe(updated);
else updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
const tomb1 = await tombFor(tipId, effectiveTribeId, userId);
await new Promise((resolve, reject) => ssbClient.publish(tomb1, e => e ? reject(e) : resolve()));
},
async joinByInvite(code) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const joinIdx = buildIndex(unwrapForIndex(await getAllMessages(ssbClient)));
await decryptIndexNodes(joinIdx);
await expandMarkers(joinIdx);
const maps = collectMaps(joinIdx, userId);
let matched = null;
let matchedInvite = null;
for (const m of maps) {
const invs = Array.isArray(m.invites) ? m.invites : [];
for (const inv of invs) {
if (typeof inv === "string" && inv === code) { matched = m; matchedInvite = inv; break; }
if (typeof inv === "object" && inv.code === code) { matched = m; matchedInvite = inv; break; }
}
if (matched) break;
}
if (!matched) throw new Error("Invalid or expired invite code");
if (Array.isArray(matched.members) && matched.members.includes(userId)) throw new Error("Already a member");
const isPublic = (typeof matchedInvite === "object" && matchedInvite.public === true);
const rootId = await this.resolveRootId(matched.rootId || matched.key);
if (!isPublic) {
const idx = buildIndex(unwrapForIndex(await getAllMessages(ssbClient)));
if (idx.isCodeConsumed(rootId, code)) throw new Error("Invite already used");
}
let mapKey = null;
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
}
}
mapKey = chain[0].key;
}
} else if (matchedInvite.ek) {
mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt);
ownCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
}
}
await new Promise((resolve, reject) => ssbClient.publish({ type: "mapMember", target: rootId, member: userId, on: true, createdAt: new Date().toISOString(), ...(isPublic ? {} : { code }) }, (e, r) => e ? reject(e) : resolve(r)));
if (tribeCrypto && mapKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {}
if (matched.author && matched.author !== userId) {
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return rootId;
},
async joinMap(mapId) {
const userId = (await openSsb()).id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key;
if (tribeCrypto && Array.isArray(map.invites)) {
const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain));
if (pub) return await this.joinByInvite(pub.code);
}
throw new Error("This map requires an invite code to join");
}
};
};

583
src/models/market_model.js Normal file
View file

@ -0,0 +1,583 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { dedupeByPreferring, norm } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
const D = (s) => ({ FOR_SALE: "FOR SALE", OPEN: "OPEN", RESERVED: "RESERVED", CLOSED: "CLOSED", SOLD: "SOLD", DISCARDED: "DISCARDED" })[s] || (s ? s.replace(/_/g, " ") : s)
const ORDER = ["FOR_SALE", "OPEN", "RESERVED", "CLOSED", "SOLD", "DISCARDED"]
const OI = (s) => ORDER.indexOf(N(s))
const parseBidEntry = (raw) => {
const s = String(raw || "").trim()
if (!s) return null
if (s.includes("|")) {
const parts = s.split("|")
if (parts.length < 3) return null
const bidder = parts[0] || ""
const amount = parseFloat(String(parts[1] || "").replace(",", "."))
const time = parts.slice(2).join("|")
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const first = s.indexOf(":")
const second = s.indexOf(":", first + 1)
if (first === -1 || second === -1) return null
const bidder = s.slice(0, first)
const amountStr = s.slice(first + 1, second)
const time = s.slice(second + 1)
const amount = parseFloat(String(amountStr || "").replace(",", "."))
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const highestBidAmount = (poll) => {
const arr = Array.isArray(poll) ? poll : []
let best = 0
for (const x of arr) {
const b = parseBidEntry(x)
if (b && Number.isFinite(b.amount) && b.amount > best) best = b.amount
}
return best
}
const hasBidder = (poll, userId) => {
const arr = Array.isArray(poll) ? poll : []
for (const x of arr) {
const b = parseBidEntry(x)
if (b && b.bidder === userId) return true
}
return false
}
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const readAll = async (ssbClient) => {
return new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))))
)
}
const buildMarketIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages)
const nodes = new Map()
const bids = []
const purchases = []
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type !== "market") continue
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author })
}
const naivePrev = new Map(), strictNext = new Map()
for (const [key, n] of nodes) {
const t = n.c.replaces
if (!t) continue
naivePrev.set(key, t)
}
for (const [child, parent] of Array.from(naivePrev.entries())) {
const cn = nodes.get(child)
const pn = nodes.get(parent)
if (!pn) { naivePrev.delete(child); continue }
if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue }
strictNext.set(parent, child)
}
return { tomb, nodes, bids, purchases, naivePrev, strictNext }
}
const resolveGroups = (idx) => {
const { tomb, nodes, bids, purchases, naivePrev, strictNext } = idx
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x }
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
const groups = new Map()
for (const key of nodes.keys()) { const r = rootOf(key); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(key) }
const bidsByRoot = new Map()
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
const soldByRoot = new Map()
for (const pu of purchases) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1) }
const out = new Map()
for (const [root, keys] of groups) {
const rootNode = nodes.get(root)
const sellerId = rootNode ? rootNode.author : null
const sellerKeys = keys.filter(k => { const n = nodes.get(k); return n && n.author === sellerId })
let tip = followStrict(root)
const tipNode0 = nodes.get(tip)
if (!tipNode0 || tipNode0.author !== sellerId) tip = root
if (tomb.has(tip)) continue
let best = nodes.get(tip)
if (!best) continue
let bestS = N(best.c.status || "FOR_SALE")
for (const k of (sellerKeys.length ? sellerKeys : keys)) {
const n = nodes.get(k); if (!n) continue
const s = N(n.c.status || "")
if (OI(s) > OI(bestS)) { best = n; bestS = s }
}
const pollSet = new Set(); const poll = []
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0 })
}
return out
}
return {
type: "market",
async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0, mapUrl = "", shopOpts = {}, visibility = "PUBLIC") {
const ssbClient = await openSsb()
const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null
if (!formattedDeadline || !formattedDeadline.isValid()) throw new Error("Invalid deadline")
if (formattedDeadline.isBefore(moment(), "minute")) throw new Error("Cannot create an item in the past")
let blobId = null
if (image) {
blobId = String(image).trim() || null
}
const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)
const p = typeof price === "string" ? parseFloat(String(price).replace(",", ".")) : parseFloat(price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
const s = parseInt(String(stock || "0"), 10)
if (!Number.isFinite(s) || s <= 0) throw new Error("Invalid stock")
const itemContent = {
type: "market",
item_type,
title,
description,
image: blobId,
price: p.toFixed(6),
tags,
item_status,
status: "FOR SALE",
deadline: formattedDeadline.toISOString(),
includesShipping: !!includesShipping,
stock: s,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
seller: ssbClient.id,
auctions_poll: [],
mapUrl: String(mapUrl || "").trim(),
shopProductId: shopOpts.shopProductId || "",
shopId: shopOpts.shopId || "",
shopTitle: shopOpts.shopTitle || "",
visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
return new Promise((resolve, reject) => {
ssbClient.publish(itemContent, (err, res) => {
if (err) return reject(err)
if (res && res.key && tribeCrypto) {
const key = tribeCrypto.generateTribeKey()
tribeCrypto.setKey(res.key, key, 1)
}
resolve(res)
})
})
},
async resolveCurrentId(itemId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, strictNext } = buildMarketIndex(messages)
let cur = itemId, g = 0
while (strictNext.has(cur) && g++ < 100000) cur = strictNext.get(cur)
if (tomb.has(cur)) throw new Error("Item not found")
return cur
},
async updateItemById(itemId, updatedData) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const normalizeTags = (v) => {
if (v === undefined) return undefined
if (Array.isArray(v)) return v.filter(Boolean)
if (typeof v === "string") return v.split(",").map((t) => t.trim()).filter(Boolean)
return []
}
const normalized = { ...(updatedData || {}) }
const tagsCandidate = normalizeTags(updatedData && updatedData.tags)
if (tagsCandidate !== undefined) normalized.tags = tagsCandidate
if (normalized.price !== undefined && normalized.price !== null && normalized.price !== "") {
const p = typeof normalized.price === "string" ? parseFloat(String(normalized.price).replace(",", ".")) : parseFloat(normalized.price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
normalized.price = p.toFixed(6)
}
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
const dl = moment(normalized.deadline, moment.ISO_8601, true)
if (!dl.isValid()) throw new Error("Invalid deadline")
normalized.deadline = dl.toISOString()
}
if (normalized.stock !== undefined) {
const s = parseInt(String(normalized.stock), 10)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
normalized.stock = s
}
if (normalized.includesShipping !== undefined) {
normalized.includesShipping = !!normalized.includesShipping
}
if (normalized.visibility !== undefined) {
normalized.visibility = String(normalized.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatusNorm = N(item.content.status || "FOR SALE")
const curStatus = D(curStatusNorm)
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Cannot update this item"))
const updated = {
...item.content,
...normalized,
tags: updatedData && updatedData.tags !== undefined ? normalized.tags : item.content.tags,
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
})
})
})
},
async deleteItemById(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err2) => (err2 ? reject(err2) : resolve({ message: "Item deleted successfully" })))
})
})
},
async listAllItems(filter = "all") {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const items = []
const now = moment()
for (const { tip, rootId, best, statusN, poll, soldCount } of resolveGroups(buildMarketIndex(messages)).values()) {
const leaf = tip
const c = best.c
let status = D(statusN)
const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0))
if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD"
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
if (status === "FOR SALE" && stock === 0) continue
const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
if (visibility === "HIDDEN" && best.author !== userId) continue
items.push({
id: leaf,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status || "NEW",
status,
visibility,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: best.author,
includesShipping: !!c.includesShipping,
stock,
deadline: c.deadline || null,
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || ""
})
}
let list = dedupeByPreferring(items, (i) => (i.seller && i.createdAt) ? norm(i.seller) + "|" + norm(i.createdAt) : null, (i) => (Array.isArray(i.auctions_poll) ? i.auctions_poll.length : 0))
switch (filter) {
case "mine":
list = list.filter((i) => i.seller === userId)
break
case "exchange":
list = list.filter((i) => i.item_type === "exchange" && i.status === "FOR SALE")
break
case "auctions":
list = list.filter((i) => i.item_type === "auction" && i.status === "FOR SALE")
break
case "mybids":
list = list.filter((i) => i.item_type === "auction").filter((i) => hasBidder(i.auctions_poll, userId))
break
case "new":
list = list.filter((i) => i.item_status === "NEW" && i.status === "FOR SALE")
break
case "used":
list = list.filter((i) => i.item_status === "USED" && i.status === "FOR SALE")
break
case "broken":
list = list.filter((i) => i.item_status === "BROKEN" && i.status === "FOR SALE")
break
case "for sale":
list = list.filter((i) => i.status === "FOR SALE")
break
case "sold":
list = list.filter((i) => i.status === "SOLD")
break
case "discarded":
list = list.filter((i) => i.status === "DISCARDED")
break
case "recent": {
const oneDayAgo = moment().subtract(1, "days")
list = list.filter((i) => i.status === "FOR SALE" && moment(i.createdAt).isAfter(oneDayAgo))
break
}
}
return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async getItemById(itemId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildMarketIndex(messages)
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const grp = resolveGroups(idx).get(rootOf(itemId))
if (!grp) return null
const { tip, rootId, best, statusN, poll, soldCount } = grp
const c = best.c
let status = D(statusN)
const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0))
if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD"
const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
if (visibility === "HIDDEN" && best.author !== userId) return null
const now = moment()
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
return {
id: tip,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status,
status,
visibility,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: best.author,
includesShipping: !!c.includesShipping,
stock,
deadline: c.deadline,
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || ""
}
},
async checkAuctionItemsStatus(items) {
const ssbClient = await openSsb()
const myId = ssbClient.id
const now = moment()
const list = Array.isArray(items) ? items : []
for (const item of list) {
if (!item || !item.deadline) continue
if (item.seller !== myId) continue
const dl = moment(item.deadline)
if (!dl.isValid()) continue
if (!dl.isBefore(now)) continue
const curStatus = D(N(item.status))
if (curStatus === "SOLD" || curStatus === "DISCARDED") continue
let status = curStatus
const kind = String(item.item_type || "").toLowerCase()
if (kind === "auction") {
status = highestBidAmount(item.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
try {
await this.updateItemById(item.id, { status })
} catch (_) {}
}
},
async getItemByShopProductId(shopProductId) {
if (!shopProductId) return null
const items = await this.listAllItems("all")
return items.find((i) => i.shopProductId === shopProductId) || null
},
async setItemAsSold(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Already sold/discarded"))
const soldMsg = {
...item.content,
stock: 0,
status: "SOLD",
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tomb1 = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb1, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(soldMsg, (err2, soldRes) => {
if (err2) return reject(err2)
const touchMsg = {
...soldMsg,
updatedAt: new Date().toISOString(),
replaces: soldRes.key
}
const tomb2 = { type: "tombstone", target: soldRes.key, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb2, (err3) => {
if (err3) return reject(err3)
ssbClient.publish(touchMsg, (err4, finalRes) => (err4 ? reject(err4) : resolve(finalRes)))
})
})
})
})
})
},
async addBidToAuction(itemId, userId, bidAmount) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildMarketIndex(messages)
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
const grp = resolveGroups(idx).get(rootOf(itemId))
if (!grp) throw new Error("Item not found")
const c = grp.best.c
if (String(c.item_type || "").toLowerCase() !== "auction") throw new Error("Not an auction")
if (grp.best.author === userId) throw new Error("Cannot bid on your own item")
if (D(N(c.status || "FOR_SALE")) !== "FOR SALE") throw new Error("Auction is not active")
const dl = c.deadline ? moment(c.deadline) : null
if (!dl || !dl.isValid()) throw new Error("Invalid deadline")
if (dl.isBefore(moment())) throw new Error("Auction closed")
if ((Number(c.stock) || 0) <= 0) throw new Error("Out of stock")
const basePrice = parseFloat(String(c.price || "0").replace(",", "."))
const bid = parseFloat(String(bidAmount || "").replace(",", "."))
if (!Number.isFinite(bid) || bid <= 0) throw new Error("Invalid bid")
const highest = highestBidAmount(grp.poll)
const min = Number.isFinite(highest) && highest > 0 ? highest : Number.isFinite(basePrice) ? basePrice : 0
if (bid <= min) throw new Error("Bid not highest")
const content = { type: "marketBid", target: grp.rootId, amount: bid.toFixed(6), time: new Date().toISOString(), createdAt: new Date().toISOString() }
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)))
)
},
async decrementStock(itemId) {
const ssbClient = await openSsb()
const item = await this.getItemById(itemId)
if (!item) throw new Error("Item not found")
const curStatus = String(item.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return { ok: true, noop: true }
if ((Number(item.stock) || 0) <= 0) return { ok: true, noop: true }
const content = { type: "marketPurchase", target: item.rootId, createdAt: new Date().toISOString() }
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)))
)
}
}
}

181
src/models/melody_model.js Normal file
View file

@ -0,0 +1,181 @@
"use strict";
const pull = require('../server/node_modules/pull-stream');
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const OCTAVES = [3, 4, 5];
const TYPE_TO_DEGREE = {
post: 0,
vote: 2,
about: 4,
contact: 5,
forum: 7,
tribe: 9,
transfer: 11,
shop: 1,
shopProduct: 1,
job: 3,
project: 6,
event: 8,
task: 10,
bookmark: 4,
feed: 5,
pad: 6,
chat: 7,
audio: 8,
video: 9,
image: 10,
document: 11,
torrent: 0,
map: 2,
pixelia: 3,
gameScore: 5,
votes: 7,
calendar: 8,
curriculum: 9,
report: 10,
parliament: 11,
courts: 0,
market: 1,
aiExchange: 6,
tombstone: 4
};
const NOTE_FREQS = (() => {
const a4 = 440;
const map = {};
for (const oct of [2, 3, 4, 5, 6]) {
for (let i = 0; i < NOTE_NAMES.length; i++) {
const name = NOTE_NAMES[i] + oct;
const semis = (oct - 4) * 12 + (i - 9);
map[name] = a4 * Math.pow(2, semis / 12);
}
}
return map;
})();
function blockToNote(msg) {
const c = msg && msg.value && msg.value.content;
if (!c || typeof c !== 'object') return null;
const type = String(c.type || '').trim() || 'unknown';
const degree = TYPE_TO_DEGREE[type] != null ? TYPE_TO_DEGREE[type] : (Math.abs(hashStr(type)) % 12);
const size = Buffer.byteLength(JSON.stringify(msg.value), 'utf8');
const octIdx = size < 256 ? 0 : (size < 1024 ? 1 : 2);
const octave = OCTAVES[octIdx];
const name = NOTE_NAMES[degree] + octave;
const freq = NOTE_FREQS[name] || 440;
const durMs = Math.min(600, 200 + Math.round(size / 64));
return {
id: msg.key,
ts: msg.value.timestamp,
type,
size,
name,
freq,
durMs
};
}
function hashStr(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
return h;
}
module.exports = ({ cooler }) => {
let ssb = null;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
async function getUserMelodyInternal(userId, limit = 200) {
const client = await openSsb();
const me = userId || client.id;
const msgs = await new Promise((resolve, reject) => {
pull(
client.createUserStream({ id: me, reverse: true, limit }),
pull.collect((err, rows) => err ? reject(err) : resolve(rows))
);
});
const seq = msgs
.filter(m => m && m.value && m.value.content && typeof m.value.content === 'object')
.map(blockToNote)
.filter(Boolean)
.reverse();
const stats = {};
for (const n of seq) {
stats[n.type] = (stats[n.type] || 0) + 1;
}
return { feedId: me, total: seq.length, sequence: seq, stats };
}
return {
NOTE_NAMES,
OCTAVES,
TYPE_TO_DEGREE,
getUserMelody: getUserMelodyInternal,
embedTextInWav,
extractTextFromWav
};
};
const STEG_MAGIC = Buffer.from([0xBC, 0x53]);
const STEG_HEADER_BITS = 32;
function embedTextInWav(wavBuffer, text) {
if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44) return wavBuffer;
const payload = Buffer.from(String(text || ''), 'utf8');
if (payload.length === 0) return wavBuffer;
if (payload.length > 4096) return wavBuffer;
const lenBuf = Buffer.alloc(2);
lenBuf.writeUInt16BE(payload.length, 0);
const fullData = Buffer.concat([STEG_MAGIC, lenBuf, payload]);
const totalBits = fullData.length * 8;
const dataLen = wavBuffer.readUInt32LE(40);
const numSamples = Math.floor(dataLen / 2);
if (totalBits > numSamples) return wavBuffer;
const out = Buffer.from(wavBuffer);
for (let i = 0; i < totalBits; i++) {
const byteIdx = i >> 3;
const bitIdx = i & 7;
const bit = (fullData[byteIdx] >> (7 - bitIdx)) & 1;
const sampleOffset = 44 + (i * 2);
let sample = out.readInt16LE(sampleOffset);
sample = (sample & ~1) | bit;
out.writeInt16LE(sample, sampleOffset);
}
return out;
}
function readBitsAsBuffer(wavBuffer, startBit, numBits) {
const bytes = Math.ceil(numBits / 8);
const out = Buffer.alloc(bytes, 0);
for (let i = 0; i < numBits; i++) {
const sampleOffset = 44 + ((startBit + i) * 2);
if (sampleOffset + 2 > wavBuffer.length) return null;
const sample = wavBuffer.readInt16LE(sampleOffset);
const bit = sample & 1;
const byteIdx = i >> 3;
const bitIdx = i & 7;
out[byteIdx] |= bit << (7 - bitIdx);
}
return out;
}
function extractTextFromWav(wavBuffer) {
if (!Buffer.isBuffer(wavBuffer) || wavBuffer.length < 44 + STEG_HEADER_BITS * 2) return null;
if (wavBuffer.slice(0, 4).toString('ascii') !== 'RIFF') return null;
if (wavBuffer.slice(8, 12).toString('ascii') !== 'WAVE') return null;
const magic = readBitsAsBuffer(wavBuffer, 0, 16);
if (!magic || magic[0] !== STEG_MAGIC[0] || magic[1] !== STEG_MAGIC[1]) return null;
const lenBuf = readBitsAsBuffer(wavBuffer, 16, 16);
if (!lenBuf) return null;
const len = lenBuf.readUInt16BE(0);
if (len === 0 || len > 4096) return null;
const payload = readBitsAsBuffer(wavBuffer, 32, len * 8);
if (!payload) return null;
try {
const text = payload.toString('utf8');
if (!text) return null;
return text;
} catch (_) { return null; }
}

View file

@ -0,0 +1,268 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const categories = require('../backend/opinion_categories');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { buildVoteTally } = require('../backend/vote_tally');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const hasBlob = async (ssbClient, url) => {
return new Promise(resolve => {
ssbClient.blobs.has(url, (err, has) => {
resolve(!err && has);
});
});
};
const validTypes = [
'bookmark', 'votes', 'transfer',
'feed', 'image', 'audio', 'video', 'document', 'torrent'
];
const getPreview = c => {
if (c.type === 'bookmark' && c.bookmark) return `🔖 ${c.bookmark}`;
return c.text || c.description || c.title || '';
};
const OPINION_MSG_TYPE = {
bookmark: 'bookmarkOpinion',
votes: 'votesOpinion',
transfer: 'transferOpinion',
feed: 'feedOpinion',
image: 'imageOpinion',
audio: 'audioOpinion',
video: 'videoOpinion',
document: 'documentOpinion',
torrent: 'torrentOpinion'
};
const createVote = async (contentId, category) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error("Invalid voting category.");
const msg = await new Promise((resolve, reject) =>
ssbClient.get(contentId, (err, value) => err ? reject(err) : resolve(value))
);
if (!msg || !msg.content) throw new Error("Opinion not found.");
const type = msg.content.type;
const otype = OPINION_MSG_TYPE[type];
if (!otype) throw new Error("Voting not allowed on this content type.");
if (msg.content.opinions_inhabitants?.includes(userId)) throw new Error("Already voted.");
const content = { type: otype, target: contentId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, result) => err ? reject(err) : resolve(result))
);
};
const listOpinions = async (filter = 'ALL', category = '') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const byId = new Map();
const opinionMsgs = [];
for (const msg of messages) {
const key = msg.key;
const c = msg.value?.content;
if (!c) continue;
if (c.type === 'tombstone') {
if (tombstoned.has(c.target)) byId.delete(c.target);
continue;
}
if (typeof c.type === 'string' && c.type.endsWith('Opinion') && c.target) {
opinionMsgs.push({ target: c.target, author: msg.value.author, category: c.category });
continue;
}
if (c.opinions && !tombstoned.has(key) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, key);
byId.set(key, {
key,
value: {
...msg.value,
content: c,
preview: getPreview(c)
}
});
}
if (c.type === 'feed' && !tombstoned.has(key) && !byId.has(key)) {
if (c.replaces) replaces.set(c.replaces, key);
byId.set(key, { key, value: { ...msg.value, content: c, preview: getPreview(c) } });
}
}
for (const [oldId, newId] of Array.from(replaces.entries())) {
const oldM = byId.get(oldId);
const newM = byId.get(newId);
if (!oldM) { replaces.delete(oldId); continue; }
if (!newM || String(newM.value.author) !== String(oldM.value.author)) {
replaces.delete(oldId);
byId.delete(newId);
}
}
for (const replacedId of replaces.keys()) {
byId.delete(replacedId);
}
const tipToChain = new Map();
for (const [oldId, newId] of replaces.entries()) {
let tip = newId; let g = 0;
while (replaces.has(tip) && g++ < 100000) tip = replaces.get(tip);
if (!tipToChain.has(tip)) tipToChain.set(tip, new Set([tip]));
tipToChain.get(tip).add(oldId); tipToChain.get(tip).add(newId);
}
const idToTip = new Map();
for (const [tip, chain] of tipToChain.entries()) for (const id of chain) idToTip.set(id, tip);
const opinionByTip = new Map();
for (const op of opinionMsgs) {
const tip = idToTip.get(op.target) || op.target;
if (!opinionByTip.has(tip)) opinionByTip.set(tip, { opinions: {}, voters: new Set() });
const agg = opinionByTip.get(tip);
if (agg.voters.has(op.author)) continue;
agg.voters.add(op.author);
if (op.category) agg.opinions[op.category] = (agg.opinions[op.category] || 0) + 1;
}
for (const [key, m] of byId.entries()) {
const agg = opinionByTip.get(key);
if (!agg) continue;
const c = m.value.content;
const mergedVoters = new Set([...(Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])]);
const mergedOpinions = { ...(c.opinions || {}) };
for (const [cat, n] of Object.entries(agg.opinions)) mergedOpinions[cat] = (mergedOpinions[cat] || 0) + n;
for (const v of agg.voters) mergedVoters.add(v);
m.value.content = { ...c, opinions: mergedOpinions, opinions_inhabitants: [...mergedVoters] };
}
let filtered = Array.from(byId.values()).filter(m => validTypes.includes(m.value?.content?.type));
const blobTypes = ['document', 'image', 'audio', 'video'];
const blobCheckCache = new Map();
filtered = await Promise.all(
filtered.map(async m => {
const c = m.value.content;
if (blobTypes.includes(c.type) && c.url) {
if (!blobCheckCache.has(c.url)) {
const valid = await hasBlob(ssbClient, c.url);
blobCheckCache.set(c.url, valid);
}
if (!blobCheckCache.get(c.url)) return null;
}
return m;
})
);
filtered = filtered.filter(Boolean);
const signatureOf = (m) => {
const c = m.value?.content || {};
switch (c.type) {
case 'document':
case 'image':
case 'audio':
case 'video':
return `${c.type}::${(c.url || '').trim()}`;
case 'bookmark': {
const u = (c.url || c.bookmark || '').trim().toLowerCase();
return `bookmark::${u}`;
}
case 'feed': {
const t = (c.text || '').replace(/\s+/g, ' ').trim();
return `feed::${t}`;
}
case 'votes': {
const q = (c.question || '').replace(/\s+/g, ' ').trim();
return `votes::${q}`;
}
case 'transfer': {
const concept = (c.concept || '').trim();
const amount = c.amount || '';
const from = c.from || '';
const to = c.to || '';
const deadline = c.deadline || '';
return `transfer::${concept}|${amount}|${from}|${to}|${deadline}`;
}
default:
return `key::${m.key}`;
}
};
const voteTally = buildVoteTally(messages);
const votesPrev = new Map();
for (const m of messages) {
const c = m.value?.content;
if (c && c.type === 'votes' && typeof c.replaces === 'string') votesPrev.set(m.key, c.replaces);
}
const resolveTallyKey = (k) => {
let cur = k;
let g = 0;
while (g++ < 1000 && !voteTally.has(cur) && votesPrev.has(cur)) cur = votesPrev.get(cur);
return cur;
};
filtered = filtered.map(m => {
if (m.value?.content?.type !== 'votes') return m;
const t = voteTally.get(resolveTallyKey(m.key));
return t ? { ...m, value: { ...m.value, content: { ...m.value.content, ...t } } } : m;
});
const bySig = new Map();
for (const m of filtered) {
const sig = `${m.value?.author || ''}::${signatureOf(m)}`;
const prev = bySig.get(sig);
if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
bySig.set(sig, m);
}
}
filtered = Array.from(bySig.values());
if (filter === 'MINE') {
filtered = filtered.filter(m => m.value.author === userId);
} else if (filter === 'RECENT') {
const now = Date.now();
filtered = filtered.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
} else if (filter === 'TOP') {
filtered = filtered.sort((a, b) => {
const sum = v => Object.values(v.content.opinions || {}).reduce((acc, x) => acc + x, 0);
return sum(b.value) - sum(a.value);
});
} else if (categories.includes(filter)) {
filtered = filtered
.filter(m => m.value.content.opinions?.[filter])
.sort((a, b) =>
(b.value.content.opinions[filter] || 0) - (a.value.content.opinions[filter] || 0)
);
}
return filtered;
};
const getMessageById = async id => {
const ssbClient = await openSsb();
return new Promise((resolve, reject) =>
ssbClient.get(id, (err, msg) =>
err ? reject(new Error("Error fetching opinion: " + err)) :
!msg?.content ? reject(new Error("Opinion not found")) :
resolve(msg)
)
);
};
return {
createVote,
listOpinions,
getMessageById,
categories
};
};

877
src/models/pads_model.js Normal file
View file

@ -0,0 +1,877 @@
const pull = require("../server/node_modules/pull-stream")
const crypto = require("crypto")
const fs = require("fs")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { collabContent, openInviteOf } = require('../backend/collab_content')
const padCollab = collabContent({ membersField: 'members', undecField: 'undecryptable', contentFields: ['title', 'deadline', 'status'], listFields: ['tags', 'invites'] })
const path = require("path")
const { getConfig } = require("../configs/config-manager.js")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const safeText = (v) => String(v || "").trim()
const normalizeTags = (raw) => {
if (!raw) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const INVITE_SALT = "SolarNET.HuB-pads"
const INVITE_BYTES = 16
const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const ownCrypto = padCrypto || tribeCrypto
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
const lookupKeys = (rid) => {
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
if (a.length) return a
return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
}
const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
let keyringPath = null
let migratedToTribeCrypto = false
const getLegacyKeyringPath = () => {
if (!keyringPath) {
const ssbConfig = require("../server/node_modules/ssb-config/inject")()
keyringPath = path.join(ssbConfig.path, "pad-keys.json")
}
return keyringPath
}
const migrateLegacyKeyring = () => {
if (migratedToTribeCrypto || !ownCrypto) { migratedToTribeCrypto = true; return }
migratedToTribeCrypto = true
try {
const p = getLegacyKeyringPath()
if (!fs.existsSync(p)) return
const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {}
for (const [rootId, keyHex] of Object.entries(legacy)) {
if (rootId && keyHex && !ownCrypto.getKey(rootId)) {
ownCrypto.setKey(rootId, keyHex, 1)
}
}
} catch (_) {}
}
const getPadKey = (rootId) => {
migrateLegacyKeyring()
if (ownCrypto) return lookupKey(rootId)
try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
}
const setPadKey = (rootId, keyHex) => {
migrateLegacyKeyring()
if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return }
let kr = {}
try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
kr[rootId] = keyHex
fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8")
}
const encryptField = (text, keyHex) => {
const key = Buffer.from(keyHex, "hex")
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
const authTag = cipher.getAuthTag()
return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
}
const decryptField = (encrypted, keyHex) => {
try {
const key = Buffer.from(keyHex, "hex")
const iv = Buffer.from(encrypted.slice(0, 24), "hex")
const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
const ciphertext = Buffer.from(encrypted.slice(56), "hex")
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
} catch (_) { return "" }
}
const tryDecryptField = (encrypted, keyHex) => {
const key = Buffer.from(keyHex, "hex")
const iv = Buffer.from(encrypted.slice(0, 24), "hex")
const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
const ciphertext = Buffer.from(encrypted.slice(56), "hex")
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
}
const getTribeKeysFor = async (tribeId) => {
if (!tribeCrypto || !tribesModel || !tribeId) return []
try {
const rootId = await tribesModel.getRootId(tribeId)
const keys = tribeCrypto.getKeys(rootId) || []
return keys
} catch (_) { return [] }
}
const decryptWithKeys = (c, keys) => {
if (!c.title || !keys.length) return null
for (const k of keys) {
try {
const title = tryDecryptField(c.title, k)
let deadline = ""
let tagsRaw = ""
try { deadline = c.deadline ? tryDecryptField(c.deadline, k) : "" } catch (_) {}
try { tagsRaw = c.tags ? tryDecryptField(c.tags, k) : "" } catch (_) {}
const ensureMemberKeys = async (ssbClient, messages, items) => {
if (!tribeCrypto) return
const distributed = new Map()
for (const m of messages) {
const c = m.value && m.value.content
if (!c || c.type !== "tribe-keys" || !c.tribeId) continue
const mk = c.memberKeys
if (!mk || typeof mk !== "object") continue
if (!distributed.has(c.tribeId)) distributed.set(c.tribeId, new Set())
for (const id of Object.keys(mk)) distributed.get(c.tribeId).add(id)
}
const ssbKeys = require("../server/node_modules/ssb-keys")
for (const item of (Array.isArray(items) ? items : [])) {
if (!item || item.undecryptable) continue
const rootId = item.rootId
if (!rootId) continue
const key = lookupKey(rootId)
if (!key) continue
const have = distributed.get(rootId) || new Set()
const missing = (Array.isArray(item.members) ? item.members : []).filter(m => m && m !== ssbClient.id && !have.has(m))
if (!missing.length) continue
const memberKeys = {}
for (const m of missing) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
}
if (!Object.keys(memberKeys).length) continue
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve())
})
}
}
return { title: safeText(title), deadline, tags: normalizeTags(tagsRaw) }
} catch (_) {}
}
return null
}
const encryptForInvite = (padKeyHex, code, saltHex) => {
const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
const derived = crypto.scryptSync(code, salt, 32)
return encryptField(padKeyHex, derived.toString("hex"))
}
const decryptFromInvite = (encryptedKey, code, saltHex) => {
const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
const derived = crypto.scryptSync(code, salt, 32)
return decryptField(encryptedKey, derived.toString("hex"))
}
const tryDecryptPublicInviteKey = (invites) => {
if (!Array.isArray(invites)) return null
for (const inv of invites) {
if (!inv || typeof inv !== "object") continue
if (inv.public !== true) continue
if (typeof inv.code !== "string" || typeof inv.ek !== "string") continue
try {
const key = decryptFromInvite(inv.ek, inv.code, inv.salt)
if (key) return key
} catch (_) {}
}
return null
}
const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
const rotatePadKey = async (rootId, remainingMembers) => {
if (!ownCrypto || !tribeCrypto || !rootId) return
const existing = getPadKey(rootId)
if (!existing) return
const newKey = crypto.randomBytes(32).toString("hex")
const newGen = ownCrypto.addNewKey(rootId, newKey)
if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
const ssbClient = await openSsb()
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
for (const m of remainingMembers) {
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
})
}
}
const ingestOwnTribeKeys = async () => {
if (!ownCrypto) return
try {
const ssbClient = await openSsb()
const ssbKeys = require("../server/node_modules/ssb-keys")
const config = require("../server/ssb_config")
const msgs = await readAll(ssbClient)
for (const m of msgs) {
const c = m.value && m.value.content
if (!c || c.type !== "tribe-keys") continue
const memberKeys = c.memberKeys
if (!memberKeys || typeof memberKeys !== "object") continue
const boxed = memberKeys[ssbClient.id]
if (!boxed) continue
try {
const unboxed = ssbKeys.unbox(boxed, config.keys)
const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
} catch (_) {}
}
} catch (_) {}
}
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
)
const buildIndex = (messages) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const strictParent = new Map()
const strictChild = new Map()
const authorByKey = new Map()
const tombRequests = []
const memberMsgs = []
for (const m of messages) {
const k = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
if (c.type === "padMember" && c.target) { memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, author: v.author, ts: v.timestamp || m.timestamp || 0, code: typeof c.code === "string" ? c.code : "" }); continue }
if (c.type === "pad") {
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
authorByKey.set(k, v.author)
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) { strictParent.set(k, t); strictChild.set(t, k) } }
}
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target)
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
}
const rootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur }
const tipOf = (id) => { let cur = id, g = 0; while (child.has(cur) && g++ < 100000) cur = child.get(cur); return cur }
const strictRootOf = (id) => { let cur = id, g = 0; while (strictParent.has(cur) && g++ < 100000) cur = strictParent.get(cur); return cur }
const contentTipOf = (root) => {
const rn = nodes.get(root)
if (!rn) return root
let cur = root, best = root, g = 0
const seen = new Set()
while (child.has(cur) && !seen.has(cur) && g++ < 100000) {
seen.add(cur)
const next = child.get(cur)
const n = nodes.get(next)
if (!n) break
if (n.author === rn.author && !tomb.has(next)) best = next
cur = next
}
return best
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(strictRootOf(id))
const memberByRoot = new Map()
const consumedByRoot = new Map()
for (const mm of memberMsgs) {
if (!nodes.has(mm.target)) continue
const r = strictRootOf(mm.target)
const ownerAuthor = nodes.get(r) && nodes.get(r).author
if (!ownerAuthor) continue
const self = mm.member === mm.author
const byOwner = mm.author === ownerAuthor
if (!self && !byOwner) continue
if (!mm.member) continue
if (!memberByRoot.has(r)) memberByRoot.set(r, new Map())
const m2 = memberByRoot.get(r)
const p = m2.get(mm.member)
if (!p || mm.ts >= p.ts) m2.set(mm.member, { on: mm.on, ts: mm.ts })
if (mm.on && mm.code) {
if (!consumedByRoot.has(r)) consumedByRoot.set(r, new Set())
consumedByRoot.get(r).add(mm.code)
}
}
const isCodeConsumed = (root, code) => {
if (!code) return false
const set = consumedByRoot.get(root)
return !!(set && set.has(code))
}
const resolveMembers = (root) => {
const ownerNode = nodes.get(root)
const oc = ownerNode ? ownerNode.c : {}
const set = new Set(Array.isArray(oc.members) ? oc.members.filter(x => typeof x === "string" && x) : [])
for (const [mem, st] of (memberByRoot.get(root) || new Map())) { if (st.on) set.add(mem); else set.delete(mem) }
return [...set]
}
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, contentTipOf(r))
return { tomb, nodes, parent, child, rootOf, tipOf, strictRootOf, contentTipOf, tipByRoot, resolveMembers, isCodeConsumed }
}
const decryptPadFields = (c, rootId, tribeKeys) => {
if (c.encrypted !== true) {
return { title: safeText(c.title), deadline: c.deadline ? String(c.deadline) : "", tags: normalizeTags(c.tags), _undec: false }
}
if (c.tribeId && Array.isArray(tribeKeys) && tribeKeys.length) {
const viaTribe = decryptWithKeys(c, tribeKeys)
if (viaTribe) return { ...viaTribe, _undec: false }
}
let keyHex = getPadKey(rootId)
if (!keyHex) keyHex = tryDecryptPublicInviteKey(c.invites)
if (!keyHex) return { title: "", deadline: "", tags: [], _undec: true }
const title = c.title ? decryptField(c.title, keyHex) : ""
const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : ""
const tags = normalizeTags(tagsRaw)
return { title, deadline, tags, _undec: false }
}
const buildPad = (node, rootId, tribeKeys, members) => {
const c = node.c || {}
if (c.type !== "pad") return null
const { title, deadline, tags, _undec } = decryptPadFields(c, rootId, tribeKeys)
return {
key: node.key,
rootId,
title,
status: c.status || "OPEN",
deadline,
tags,
author: c.author || node.author,
members: Array.isArray(members) ? members : (Array.isArray(c.members) ? c.members : []),
invites: Array.isArray(c.invites) ? c.invites : [],
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tribeId: c.tribeId || null,
encrypted: c.encrypted === true,
undecryptable: !!_undec
}
}
const isClosed = (pad) => {
if (pad.status === "CLOSED") return true
if (!pad.deadline) return false
return new Date(pad.deadline).getTime() <= Date.now()
}
const collectPads = async (idx) => {
const tribeKeyCache = new Map()
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "pad") continue
let tKeys = []
if (node.c.tribeId) {
if (!tribeKeyCache.has(node.c.tribeId)) tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId))
tKeys = tribeKeyCache.get(node.c.tribeId)
}
const pad = buildPad(node, rootId, tKeys, idx.resolveMembers(rootId))
if (!pad) continue
pad.isClosed = isClosed(pad)
items.push(pad)
}
return items
}
return {
type: "pad",
async decryptContent(content, rootId) {
const tKeys = content && content.tribeId ? await getTribeKeysFor(content.tribeId) : []
return decryptPadFields(content, rootId, tKeys)
},
decryptContentPublicSync(content) {
if (!content) return null
if (content.encrypted !== true) {
return { title: safeText(content.title), deadline: content.deadline ? String(content.deadline) : "", tags: normalizeTags(content.tags) }
}
if (content.tribeId) return null
const keyHex = tryDecryptPublicInviteKey(content.invites)
if (!keyHex) return null
try {
const title = content.title ? decryptField(content.title, keyHex) : ""
const deadline = content.deadline ? decryptField(content.deadline, keyHex) : ""
const tagsRaw = content.tags ? decryptField(content.tags, keyHex) : ""
return { title, deadline, tags: normalizeTags(tagsRaw) }
} catch (_) { return null }
},
async resolveRootId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const root = idx.strictRootOf(id)
const tip = idx.contentTipOf(root)
if (idx.tomb.has(tip)) throw new Error("Not found")
return root
},
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const root = idx.strictRootOf(id)
const tip = idx.contentTipOf(root)
if (idx.tomb.has(tip)) throw new Error("Not found")
return tip
},
async createPad(title, status, deadline, tagsRaw, tribeId) {
const ssbClient = await openSsb()
const now = new Date().toISOString()
const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
const userId = ssbClient.id
const tagsArr = normalizeTags(tagsRaw)
const isPublicOpen = validStatus === "OPEN" && !tribeId
if (isPublicOpen) {
const content = {
type: "pad",
title: safeText(title),
status: validStatus,
deadline: deadline ? String(deadline) : "",
tags: tagsArr,
author: userId,
members: [userId],
invites: [],
createdAt: now,
updatedAt: now,
encrypted: false
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
let keyHex = null
let usesTribeKey = false
if (tribeId) {
const tKeys = await getTribeKeysFor(tribeId)
if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
}
if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
const enc = (text) => encryptField(text, keyHex)
const content = {
type: "pad",
title: enc(safeText(title)),
status: validStatus,
deadline: deadline ? enc(String(deadline)) : "",
tags: enc(tagsArr.join(",")),
author: userId,
members: [userId],
invites: [],
createdAt: now,
updatedAt: now,
encrypted: true,
...(tribeId ? { tribeId } : {})
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => {
if (err) return reject(err)
if (!usesTribeKey) {
setPadKey(msg.key, keyHex)
if (tribeCrypto) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
return
} catch (_) {}
}
}
resolve(msg)
})
})
},
async updatePadById(id, data) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
const rootId = await this.resolveRootId(id)
return new Promise(async (resolve, reject) => {
ssbClient.get(tipId, async (err, item) => {
if (err || !item?.content) return reject(new Error("Pad not found"))
if (item.content.author !== userId) return reject(new Error("Not the author"))
const c = item.content
const isEncrypted = c.encrypted === true
let keyHex = null
let usesTribeKey = false
if (isEncrypted) {
if (c.tribeId) {
const tKeys = await getTribeKeysFor(c.tribeId)
if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
}
if (!keyHex) keyHex = getPadKey(rootId)
if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`))
}
const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text
const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw)
const updated = {
...c,
title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline,
tags: data.tags !== undefined ? tagsField(data.tags) : c.tags,
invites: data.invites !== undefined ? data.invites : c.invites,
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => {
if (e2) return reject(e2)
if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
resolve(res)
})
})
})
})
},
async closePadById(id) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
const rootId = await this.resolveRootId(id)
return new Promise(async (resolve, reject) => {
ssbClient.get(tipId, async (err, item) => {
if (err || !item?.content) return reject(new Error("Pad not found"))
if (item.content.author !== userId) return reject(new Error("Not the author"))
const c = item.content
let keyHex = null
let usesTribeKey = false
if (c.tribeId) {
const tKeys = await getTribeKeysFor(c.tribeId)
if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
}
if (!keyHex) keyHex = getPadKey(rootId)
const updated = {
...c,
status: "CLOSED",
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => {
if (e2) return reject(e2)
if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
resolve(res)
})
})
})
})
},
async leavePad(padId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const pad = await this.getPadById(padId)
if (!pad) throw new Error("Pad not found")
if (pad.author === userId) throw new Error("Author cannot leave their own pad")
if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return
const members = pad.members.filter(m => m !== userId)
const rootId = await this.resolveRootId(padId)
await new Promise((resolve, reject) => {
const content = { type: "padMember", target: rootId, member: userId, on: false, createdAt: new Date().toISOString() }
ssbClient.publish(content, (e) => e ? reject(e) : resolve())
})
try { await rotatePadKey(rootId, members) } catch (_) {}
},
async ingestKeys() { await ingestOwnTribeKeys() },
async pruneOrphanKeys() {
if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
try {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const live = new Set()
const tomb = buildValidatedTombstoneSet(messages)
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "pad") live.add(m.key)
}
const all = ownCrypto.getAllRootIds()
let removed = 0
for (const rid of all) {
if (!live.has(rid) || tomb.has(rid)) {
try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
}
}
return removed
} catch (_) { return 0 }
},
async addMemberToPad(padId, feedId, consumedCode) {
const tipId = await this.resolveCurrentId(padId)
const ssbClient = await openSsb()
const rootId = await this.resolveRootId(padId)
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Pad not found"))
const c = item.content
if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) {
const key = tryDecryptPublicInviteKey(c.invites)
if (key) setPadKey(rootId, key)
}
const content = { type: "padMember", target: rootId, member: feedId, on: true, createdAt: new Date().toISOString(), ...(typeof consumedCode === "string" && consumedCode ? { code: consumedCode } : {}) }
ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))
})
})
},
async deletePadById(id) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Pad not found"))
if (item.content.author !== userId) return reject(new Error("Not the author"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
})
})
},
async getPadById(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const root = idx.strictRootOf(id)
const tip = idx.contentTipOf(root)
if (idx.tomb.has(tip)) return null
const node = idx.nodes.get(tip)
if (!node || node.c.type !== "pad") return null
const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : []
const pad = buildPad(node, root, tKeys, idx.resolveMembers(root))
if (!pad) return null
pad.isClosed = isClosed(pad)
return padCollab.fold(pad, await collectPads(idx))
},
async listAll({ filter = "all", viewerId } = {}) {
const ssbClient = await openSsb()
const uid = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const now = Date.now()
let list = padCollab.visibleThenCollapsed(await collectPads(idx), uid)
if (filter === "mine") list = list.filter(p => p.author === uid)
else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
else if (filter === "open") list = list.filter(p => !p.isClosed)
else if (filter === "closed") list = list.filter(p => p.isClosed)
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
try { await ensureMemberKeys(ssbClient, messages, list) } catch (_) {}
return list
},
async generateInvite(padId, opts = {}) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const pad = await this.getPadById(padId)
if (!pad) throw new Error("Pad not found")
if (pad.author !== userId) throw new Error("Only the author can generate invites")
const rootId = await this.resolveRootId(padId)
const keyHex = getPadKey(rootId)
const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
let invite = code
const pubFlag = opts.public ? { public: true } : {}
if (keyHex) {
const inviteSalt = generateInviteSalt()
const ek = encryptForInvite(keyHex, code, inviteSalt)
invite = { code, ek, salt: inviteSalt, ...pubFlag }
}
if (opts.public && typeof invite !== "object") invite = { code, public: true }
const invites = [...pad.invites, invite]
await this.updatePadById(padId, { invites })
return code
},
async getOpenInvite(padId) {
return openInviteOf(await this.getPadById(padId).catch(() => null))
},
async generateOpenInvite(padId) {
const pad = await this.getPadById(padId)
if (!pad) throw new Error("Pad not found")
const existing = (Array.isArray(pad.invites) ? pad.invites : []).find(inv => typeof inv === "object" && inv.public === true)
if (existing) throw new Error("An open invitation already exists")
return this.generateInvite(padId, { public: true })
},
async removeOpenInvite(padId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const pad = await this.getPadById(padId)
if (!pad) throw new Error("Pad not found")
if (pad.author !== userId) throw new Error("Only the author can remove invites")
const invites = (Array.isArray(pad.invites) ? pad.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true))
await this.updatePadById(padId, { invites })
},
async joinByInvite(code) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const pads = await this.listAll()
let matchedPad = null
let matchedInvite = null
for (const p of pads) {
for (const inv of p.invites) {
if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
}
if (matchedPad) break
}
if (!matchedPad) throw new Error("Invalid or expired invite code")
if (matchedPad.members.includes(userId)) throw new Error("Already a member")
const isPublic = typeof matchedInvite === "object" && matchedInvite.public === true
let resolvedRootId = await this.resolveRootId(matchedPad.rootId)
if (!isPublic) {
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
if (idx.isCodeConsumed(resolvedRootId, code)) throw new Error("Invite already used")
}
let padKey = null
if (typeof matchedInvite === "object" && matchedInvite.ek) {
padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
setPadKey(resolvedRootId, padKey)
}
await this.addMemberToPad(matchedPad.rootId, userId, isPublic ? "" : code)
if (tribeCrypto && padKey && resolvedRootId) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
if (matchedPad.author && matchedPad.author !== userId) {
try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matchedPad.rootId
},
async addEntry(padId, text) {
const ssbClient = await openSsb()
const rootId = await this.resolveRootId(padId)
const pad = await this.getPadById(rootId)
const padIsEncrypted = !!(pad && pad.encrypted)
const now = new Date().toISOString()
const safeBody = safeText(text)
if (!padIsEncrypted) {
const content = {
type: "padEntry",
padId: rootId,
text: safeBody,
author: ssbClient.id,
createdAt: now,
encrypted: false,
...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
let keyHex = null
if (pad && pad.tribeId) {
const tKeys = await getTribeKeysFor(pad.tribeId)
if (tKeys.length) keyHex = tKeys[0]
}
if (!keyHex) keyHex = getPadKey(rootId)
if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
const content = {
type: "padEntry",
padId: rootId,
text: encryptField(safeBody, keyHex),
author: ssbClient.id,
createdAt: now,
encrypted: true,
...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async getEntries(padRootId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const wantRoot = idx.rootOf(padRootId)
const pad = await this.getPadById(padRootId)
const padKey = getPadKey(padRootId)
let tribeKeys = []
if (pad && pad.tribeId) {
tribeKeys = await getTribeKeysFor(pad.tribeId)
}
const entries = []
for (const m of messages) {
const v = m.value || {}
const c = v.content
if (!c || c.type !== "padEntry") continue
if (c.padId !== padRootId && idx.rootOf(c.padId) !== wantRoot) continue
let text = c.text || ""
if (c.encrypted && c.text) {
let decoded = ""
for (const k of tribeKeys) {
try { decoded = tryDecryptField(c.text, k); break } catch (_) {}
}
if (!decoded && padKey) decoded = decryptField(c.text, padKey)
text = decoded
}
entries.push({
key: m.key,
author: c.author || v.author,
text,
createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
})
}
entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
return entries
},
getMemberColor(members, feedId) {
const idx = members.indexOf(feedId)
return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
}
}
}

View file

@ -0,0 +1,15 @@
const os = require('os');
const fs = require('fs');
const path = require('path');
module.exports = {
removeSSB: async () => {
try {
const homeDir = os.homedir();
const ssbPath = path.join(homeDir, '.ssb');
await fs.promises.rm(ssbPath, { recursive: true, force: true });
} catch (error) {
throw new Error("Error deleting data: " + error.message);
}
}
};

File diff suppressed because it is too large Load diff

144
src/models/pixelia_model.js Normal file
View file

@ -0,0 +1,144 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getPixelByCoordinate = async (coordinateKey) => {
const ssbClient = await openSsb();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const byId = new Map();
for (const m of messages) {
const c = m.value?.content;
const k = m.key;
if (!c || c.type !== 'pixelia' || c.coordinateKey !== coordinateKey) continue;
if (tombstoned.has(k)) continue;
if (c.replaces) replaces.set(c.replaces, k);
byId.set(k, m);
}
for (const [replacedId, replacingId] of replaces.entries()) {
const orig = byId.get(replacedId);
if (!orig) continue;
const rep = byId.get(replacingId);
if (!rep || rep.value.author !== orig.value.author) {
byId.delete(replacingId);
continue;
}
byId.delete(replacedId);
}
return [...byId.values()][0] || null;
};
const paintPixel = async (x, y, color) => {
if (x < 1 || x > 50 || y < 1 || y > 200) {
throw new Error('Coordinates out of bounds. Please use x (1-50) and y (1-200)');
}
const ssbClient = await openSsb();
const userId = ssbClient.id;
const coordinateKey = `${x}:${y}`;
const existingPixel = await getPixelByCoordinate(coordinateKey);
if (existingPixel) {
const tombstone = {
type: 'tombstone',
target: existingPixel.key,
deletedAt: new Date().toISOString()
};
await new Promise((resolve, reject) =>
ssbClient.publish(tombstone, err => err ? reject(err) : resolve())
);
}
const contributors = existingPixel?.value?.content?.contributors_inhabitants || [];
const contributors_inhabitants = contributors.includes(userId)
? contributors
: [...contributors, userId];
const content = {
type: 'pixelia',
x: x - 1,
y: y - 1,
color,
author: userId,
contributors_inhabitants,
timestamp: Date.now(),
coordinateKey,
replaces: existingPixel?.key || null
};
await new Promise((resolve, reject) => {
ssbClient.publish(content, (err) => err ? reject(err) : resolve());
});
};
const listPixels = async () => {
const ssbClient = await openSsb();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const byKey = new Map();
for (const m of messages) {
const c = m.value?.content;
const k = m.key;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'pixelia') {
if (tombstoned.has(k)) continue;
if (c.replaces) replaces.set(c.replaces, k);
byKey.set(k, m);
}
}
for (const [replacedId, replacingId] of replaces.entries()) {
const orig = byKey.get(replacedId);
if (!orig) continue;
const rep = byKey.get(replacingId);
if (!rep || rep.value.author !== orig.value.author) {
byKey.delete(replacingId);
continue;
}
byKey.delete(replacedId);
}
return Array.from(byKey.values()).map(m => ({
x: m.value.content.x + 1,
y: m.value.content.y + 1,
color: m.value.content.color,
author: m.value.author,
contributors_inhabitants: m.value.content.contributors_inhabitants || [],
timestamp: m.value.timestamp
}));
};
return {
paintPixel,
listPixels
};
};

147
src/models/pm_model.js Normal file
View file

@ -0,0 +1,147 @@
const pull = require('../server/node_modules/pull-stream');
const util = require('../server/node_modules/util');
module.exports = ({ cooler }) => {
let ssb;
let userId;
const openSsb = async () => {
if (!ssb) {
ssb = await cooler.open();
userId = ssb.id;
}
return ssb;
};
function uniqueRecps(list) {
const out = [];
const seen = new Set();
for (const x of (list || [])) {
if (typeof x !== 'string') continue;
const id = x.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
return {
type: 'post',
async sendMessage(recipients = [], subject = '', text = '', crypter = false) {
const ssbClient = await openSsb();
const recps = uniqueRecps([userId, ...recipients]);
const content = {
type: 'post',
from: userId,
to: recps,
subject,
text,
sentAt: new Date().toISOString(),
private: true,
...(crypter ? { crypter: true } : {})
};
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(content, recps);
},
async deleteMessageById(messageId) {
const ssbClient = await openSsb();
const rawMsg = await new Promise((resolve, reject) =>
ssbClient.get(messageId, (err, m) =>
err ? reject(new Error("Error retrieving message.")) : resolve(m)
)
);
let decrypted;
try {
decrypted = ssbClient.private.unbox({
key: messageId,
value: rawMsg,
timestamp: rawMsg?.timestamp || Date.now()
});
} catch {
throw new Error("Malformed message.");
}
const content = decrypted?.value?.content;
const author = decrypted?.value?.author;
const originalRecps = Array.isArray(content?.to) ? content.to : [];
if (!content || !author) throw new Error("Malformed message.");
const isAuthor = author === userId;
const isRecipient = originalRecps.includes(userId);
if (!isAuthor && !isRecipient) throw new Error("Not authorized.");
if (content.type === 'tombstone') throw new Error("Message already deleted.");
const tombstone = {
type: 'tombstone',
target: messageId,
deletedAt: new Date().toISOString(),
private: true
};
const tombstoneRecps = isAuthor
? uniqueRecps([userId, author, ...originalRecps])
: uniqueRecps([userId]);
const publishAsync = util.promisify(ssbClient.private.publish);
return publishAsync(tombstone, tombstoneRecps);
},
async listAllPrivate() {
const ssbClient = await openSsb();
const raw = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ reverse: false }),
pull.collect((err, arr) => err ? reject(err) : resolve(arr))
);
});
const posts = [];
const tombed = new Set();
const tombClaims = new Map();
const authorByKey = new Map();
const recpsByKey = new Map();
for (const m of raw) {
if (!m || !m.value) continue;
const keyIn = m.key || m.value?.key || m.value?.hash || '';
const valueIn = m.value || m;
const tsIn = m.timestamp || m.value?.timestamp || Date.now();
let dec;
try {
dec = ssbClient.private.unbox({ key: keyIn, value: valueIn, timestamp: tsIn });
} catch {
continue;
}
const v = dec?.value || {};
const c = v.content || {};
const k = dec?.key || keyIn;
if (!c || c.private !== true || !k) continue;
if (c.type === 'tombstone' && c.target) {
const set = tombClaims.get(c.target) || new Set();
set.add(v.author);
tombClaims.set(c.target, set);
continue;
}
authorByKey.set(k, v.author);
if (c.type === 'post') {
const to = Array.isArray(c.to) ? c.to : [];
recpsByKey.set(k, to);
const author = v.author;
if (author === userId || to.includes(userId)) {
posts.push({
key: k,
value: { author, content: c },
timestamp: v.timestamp || tsIn
});
}
}
}
for (const [target, tombAuthors] of tombClaims.entries()) {
const origAuthor = authorByKey.get(target);
const origRecps = recpsByKey.get(target) || [];
for (const tombAuthor of tombAuthors) {
if (tombAuthor === origAuthor || tombAuthor === userId || origRecps.includes(tombAuthor)) {
tombed.add(target);
break;
}
}
}
return posts.filter(m => m && m.key && !tombed.has(m.key));
}
};
};

View file

@ -0,0 +1,601 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe')
const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const TYPE = "project"
const clampPercent = (n) => {
const x = parseInt(n, 10)
if (!Number.isFinite(x)) return 0
return Math.max(0, Math.min(100, x))
}
async function getAllMsgs(ssbClient) {
return new Promise((r, j) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((e, m) => (e ? j(e) : r(m)))
)
})
}
function extractBlobId(possibleMarkdownImage) {
return possibleMarkdownImage || null
}
function normalizeMilestonesFrom(data) {
if (Array.isArray(data.milestones)) {
return data.milestones
.map((m) => {
return {
title: String((m && m.title) || "").trim(),
description: (m && m.description) || "",
targetPercent: clampPercent(m && m.targetPercent),
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
done: !!(m && m.done)
}
})
.filter((m) => m.title)
}
const title = String((data["milestones[0][title]"] || data.milestoneTitle || "")).trim()
const description = data["milestones[0][description]"] || data.milestoneDescription || ""
const tpRaw = (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) != null
? (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent)
: 0
const targetPercent = clampPercent(tpRaw)
const dueRaw = data["milestones[0][dueDate]"] || data.milestoneDueDate || null
const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
const out = []
if (title) out.push({ title, description, targetPercent, dueDate, done: false })
return out
}
function safeMilestoneIndex(project, idx) {
const total = Array.isArray(project.milestones) ? project.milestones.length : 0
if (idx === null || idx === undefined || idx === "" || isNaN(idx)) return null
const n = parseInt(idx, 10)
if (!Number.isFinite(n)) return null
if (n < 0 || n >= total) return null
return n
}
function autoCompleteMilestoneIfReady(projectLike, milestoneIdx) {
if (milestoneIdx === null || milestoneIdx === undefined) {
return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
}
const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
if (!milestones[milestoneIdx]) {
return { milestones, progress: projectLike.progress || 0, changed: false }
}
const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
const related = bounties.filter((b) => b && b.milestoneIndex === milestoneIdx)
if (related.length === 0) {
return { milestones, progress: projectLike.progress || 0, changed: false }
}
const allDone = related.every((b) => !!(b && b.done))
let progress = projectLike.progress || 0
let changed = false
if (allDone && !milestones[milestoneIdx].done) {
milestones[milestoneIdx].done = true
const target = clampPercent(milestones[milestoneIdx].targetPercent || 0)
const pInt = parseInt(progress, 10)
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
changed = true
}
return { milestones, progress, changed }
}
const COLLAB_MSG = new Set(["projectOpinion", "projectFollow", "projectPledge", "projectClaim", "projectPledgeConfirm"])
function buildProjectIndex(messages) {
const tomb = buildValidatedTombstoneSet(messages)
const nodes = new Map()
const collab = { opinion: [], follow: [], pledge: [], claim: [], pledgeConfirm: [] }
for (const m of messages) {
const c = m && m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
if (c.type === "projectOpinion") { collab.opinion.push({ target: c.target, author: m.value.author, category: c.category, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "projectFollow") { collab.follow.push({ target: c.target, author: m.value.author, on: c.on !== false, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "projectPledge") { collab.pledge.push({ target: c.target, author: m.value.author, amount: Math.max(0, parseFloat(c.amount || 0) || 0), transferId: c.transferId || null, milestoneIndex: c.milestoneIndex != null ? c.milestoneIndex : null, bountyIndex: c.bountyIndex != null ? c.bountyIndex : null, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "projectPledgeConfirm") { collab.pledgeConfirm.push({ target: c.target, author: m.value.author, transferId: c.transferId || null, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "projectClaim") { collab.claim.push({ target: c.target, author: m.value.author, index: parseInt(c.index, 10), ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type !== TYPE) continue
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || 0, c, author: m.value.author })
}
const naiveNext = new Map(), naivePrev = new Map(), strictNext = new Map()
for (const [key, n] of nodes) {
const t = n.c.replaces
if (!t) continue
naiveNext.set(t, key); naivePrev.set(key, t)
const orig = nodes.get(t)
if (orig && orig.author === n.author) strictNext.set(t, key)
}
return { tomb, nodes, collab, naiveNext, naivePrev, strictNext }
}
function resolveProjectGroup(idx, root) {
const { nodes, collab, naiveNext, strictNext } = idx
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
const rootNode = nodes.get(root)
const rootAuthor = rootNode ? rootNode.author : null
let tip = followStrict(root)
const tn = nodes.get(tip)
if (!tn || tn.author !== rootAuthor) tip = root
const best = nodes.get(tip)
if (!best) return null
const groupKeys = []
{ let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } }
const project = { ...best.c, id: tip }
const followers = new Set()
const opinions = {}
const opinionSet = new Set()
let backers = []
const claimByIndex = new Map()
for (const k of groupKeys) {
const n = nodes.get(k); if (!n) continue
if (n.author !== rootAuthor) continue
const c = n.c
for (const f of (Array.isArray(c.followers) ? c.followers : [])) followers.add(f)
if (Array.isArray(c.backers) && c.backers.length > backers.length) backers = c.backers.slice()
for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) {
if (!opinionSet.has(v)) opinionSet.add(v)
}
if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) }
if (Array.isArray(c.bounties)) c.bounties.forEach((b, i) => { if (b && b.claimedBy && !claimByIndex.has(i)) claimByIndex.set(i, b.claimedBy) })
}
for (const f of collab.follow.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (f.on) followers.add(f.author); else followers.delete(f.author) }
for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
const confirmedTransfers = new Set(collab.pledgeConfirm.filter(x => x.target === root && x.author === rootAuthor && x.transferId).map(x => String(x.transferId)))
const pledgeMsgs = collab.pledge.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)
for (const p of pledgeMsgs) backers.push({ userId: p.author, amount: p.amount, at: new Date(p.ts || 0).toISOString(), transferId: p.transferId || null, milestoneIndex: p.milestoneIndex, bountyIndex: p.bountyIndex, confirmed: !!(p.transferId && confirmedTransfers.has(String(p.transferId))) })
for (const cl of collab.claim.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (Number.isInteger(cl.index) && !claimByIndex.has(cl.index)) claimByIndex.set(cl.index, cl.author) }
project.followers = [...followers]
project.opinions = opinions
project.opinions_inhabitants = [...opinionSet]
project.backers = backers
const pledged = backers.reduce((s, b) => s + (parseFloat(b && b.amount || 0) || 0), 0)
project.pledged = pledged
if (Array.isArray(project.bounties)) project.bounties = project.bounties.map((b, i) => ({ ...b, claimedBy: claimByIndex.has(i) ? claimByIndex.get(i) : (b && b.claimedBy) || null }))
const goalNum = parseFloat(project.goal || 0) || 0
if (goalNum > 0) project.progress = Math.max(clampPercent(project.progress), Math.min(100, Math.round((pledged / goalNum) * 100)))
return { tip, root, best, project, tombstoned: idx.tomb.has(tip) }
}
const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
async function resolveTipId(id) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
return g.tip
}
async function getById(id) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
return g.project
}
const CONTENT_COLLAB_FIELDS = ["followers", "backers", "opinions", "opinions_inhabitants", "pledged", "activity"]
function stripCollab(content) {
const out = { ...content }
delete out.id
for (const f of CONTENT_COLLAB_FIELDS) delete out[f]
out.followers = []
out.backers = []
out.opinions = {}
out.opinions_inhabitants = []
out.pledged = 0
if (Array.isArray(out.bounties)) out.bounties = out.bounties.map((b) => ({ ...b, claimedBy: null }))
return out
}
async function publishReplace(ssbClient, currentId, content) {
const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res())))
return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m))))
}
function isParticipant(project, uid) {
if (!project || !uid) return false
const backers = Array.isArray(project.backers) ? project.backers : []
if (backers.some((b) => b && b.userId === uid)) return true
const bounties = Array.isArray(project.bounties) ? project.bounties : []
if (bounties.some((b) => b && b.claimedBy === uid)) return true
return false
}
return {
type: TYPE,
async createProject(data) {
const ssbClient = await openSsb()
const blobId = extractBlobId(data.image)
const milestones = normalizeMilestonesFrom(data)
let goal = parseFloat(data.goal || 0) || 0
if (goal < 0) goal = 0
const deadlineISO = data.deadline ? new Date(data.deadline).toISOString() : null
const content = {
type: TYPE,
title: data.title,
description: data.description,
image: blobId || null,
goal,
pledged: parseFloat(data.pledged || 0) || 0,
deadline: deadlineISO,
progress: clampPercent(data.progress || 0),
status: String(data.status || "ACTIVE").toUpperCase(),
milestones,
bounties: Array.isArray(data.bounties)
? data.bounties
.map((b) => {
return {
title: String((b && b.title) || "").trim(),
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
description: (b && b.description) || "",
claimedBy: (b && b.claimedBy) || null,
done: !!(b && b.done),
milestoneIndex: b && b.milestoneIndex != null ? parseInt(b.milestoneIndex, 10) : null
}
})
.filter((b) => b.title)
: [],
followers: [],
backers: [],
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: null,
mapUrl: String(data.mapUrl || "").trim(),
clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on',
opinions: {},
opinions_inhabitants: []
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async updateProject(id, patch) {
const ssbClient = await openSsb()
const current = await getById(id)
if (current.author !== ssbClient.id) throw new Error("Unauthorized")
let blobId = patch.image === undefined ? current.image : patch.image
blobId = extractBlobId(blobId)
let milestones = patch.milestones === undefined ? current.milestones : patch.milestones
if (milestones != null) {
milestones = Array.isArray(milestones)
? milestones
.map((m) => {
return {
title: String((m && m.title) || "").trim(),
description: (m && m.description) || "",
targetPercent: clampPercent(m && m.targetPercent),
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
done: !!(m && m.done)
}
})
.filter((m) => m.title)
: current.milestones
}
let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
if (bounties != null) {
bounties = Array.isArray(bounties)
? bounties
.map((b) => {
return {
title: String((b && b.title) || "").trim(),
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
description: (b && b.description) || "",
claimedBy: (b && b.claimedBy) || null,
done: !!(b && b.done),
milestoneIndex: b && b.milestoneIndex != null ? safeMilestoneIndex({ milestones: milestones || current.milestones }, b.milestoneIndex) : null
}
})
.filter((b) => b.title)
: current.bounties
}
let deadline = patch.deadline === undefined ? current.deadline : patch.deadline
if (deadline != null && deadline !== "") deadline = new Date(deadline).toISOString()
else if (deadline === "") deadline = null
const updated = {
...current,
...patch,
image: blobId || null,
milestones,
bounties,
deadline,
progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress),
status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase(),
clearnetPublic: patch.clearnetPublic === undefined ? !!current.clearnetPublic : (patch.clearnetPublic === true || patch.clearnetPublic === 'true' || patch.clearnetPublic === 'on')
}
return publishReplace(ssbClient, current.id, updated)
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories')
if (!categories.includes(category)) throw new Error('Invalid opinion category')
const ssbClient = await openSsb()
const userId = ssbClient.id
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error('Project not found')
if ((g.project.opinions_inhabitants || []).includes(userId)) throw new Error('Already opined')
const content = { type: 'projectOpinion', target: g.root, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async deleteProject(id) {
const ssbClient = await openSsb()
const tip = await resolveTipId(id)
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const tomb = { type: "tombstone", target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => (e ? rej(e) : res(r))))
},
async updateProjectStatus(id, status) {
const s = String(status || "").toUpperCase()
return this.updateProject(id, { status: s })
},
async updateProjectProgress(id, progress) {
const p = clampPercent(progress)
return this.updateProject(id, { progress: p, ...(p >= 100 ? { status: "COMPLETED" } : {}) })
},
async followProject(id, uid) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
const content = { type: "projectFollow", target: g.root, on: true, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async unfollowProject(id, uid) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
const content = { type: "projectFollow", target: g.root, on: false, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async pledgeToProject(id, uid, amount, extra = {}) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
const amt = Math.max(0, parseFloat(amount || 0) || 0)
if (amt <= 0) throw new Error("Invalid amount")
const content = { type: "projectPledge", target: g.root, amount: amt, createdAt: new Date().toISOString() }
if (extra && extra.transferId) content.transferId = extra.transferId
if (extra && extra.milestoneIndex != null) content.milestoneIndex = extra.milestoneIndex
if (extra && extra.bountyIndex != null) content.bountyIndex = extra.bountyIndex
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async confirmPledge(id, transferId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
if (String(g.project.author) !== String(userId)) throw new Error("Only the project author can confirm pledges")
const content = { type: "projectPledgeConfirm", target: g.root, transferId: String(transferId || ""), createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async addBounty(id, bounty) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
const clean = {
title: String((bounty && bounty.title) || "").trim(),
amount: Math.max(0, parseFloat((bounty && bounty.amount) || 0) || 0),
description: (bounty && bounty.description) || "",
claimedBy: null,
done: false,
milestoneIndex: safeMilestoneIndex(project, bounty && bounty.milestoneIndex)
}
if (!clean.title) throw new Error("Bounty title required")
bounties.push(clean)
return publishReplace(ssbClient, project.id, { ...project, bounties })
},
async updateBounty(id, index, patch) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
if (!bounties[index]) throw new Error("Bounty not found")
if (patch.title !== undefined) bounties[index].title = String(patch.title || "").trim()
if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
if (patch.description !== undefined) bounties[index].description = patch.description || ""
if (patch.milestoneIndex !== undefined) bounties[index].milestoneIndex = safeMilestoneIndex(project, patch.milestoneIndex)
if (patch.done !== undefined) bounties[index].done = !!patch.done
return publishReplace(ssbClient, project.id, { ...project, bounties })
},
async addMilestone(id, milestone) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
const clean = {
title: String((milestone && milestone.title) || "").trim(),
description: (milestone && milestone.description) || "",
targetPercent: clampPercent(milestone && milestone.targetPercent),
dueDate: milestone && milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
done: false
}
if (!clean.title) throw new Error("Milestone title required")
milestones.push(clean)
return publishReplace(ssbClient, project.id, { ...project, milestones })
},
async updateMilestone(id, index, patch) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
if (!milestones[index]) throw new Error("Milestone not found")
if (patch.title !== undefined) milestones[index].title = String(patch.title || "").trim()
if (patch.description !== undefined) milestones[index].description = patch.description || ""
if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
let progress = project.progress
if (patch.done !== undefined) {
milestones[index].done = !!patch.done
if (milestones[index].done) {
const target = clampPercent(milestones[index].targetPercent || 0)
const pInt = parseInt(project.progress || 0, 10)
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
}
}
const updated = { ...project, milestones, ...(progress !== project.progress ? { progress, ...(progress >= 100 ? { status: "COMPLETED" } : {}) } : {}) }
return publishReplace(ssbClient, project.id, updated)
},
async claimBounty(id, index, uid) {
const ssbClient = await openSsb()
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
if (!g || g.tombstoned) throw new Error("Project not found")
const bounties = Array.isArray(g.project.bounties) ? g.project.bounties : []
if (!bounties[index]) throw new Error("Bounty not found")
if (bounties[index].claimedBy) throw new Error("Already claimed")
if (g.project.author === uid) throw new Error("Authors cannot claim")
const content = { type: "projectClaim", target: g.root, index: parseInt(index, 10), createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
},
async completeBounty(id, index, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== uid) throw new Error("Unauthorized")
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
if (!bounties[index]) throw new Error("Bounty not found")
bounties[index].done = true
const ac = autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex)
const patch = { ...project, bounties }
if (ac && ac.changed) {
patch.milestones = ac.milestones
patch.progress = ac.progress
if (ac.progress >= 100) patch.status = "COMPLETED"
}
return publishReplace(ssbClient, project.id, patch)
},
async completeMilestone(id, index, uid) {
const tip = await resolveTipId(id)
const ssbClient = await openSsb()
const project = await getById(tip)
if (project.author !== uid) throw new Error("Unauthorized")
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
if (!milestones[index]) throw new Error("Milestone not found")
milestones[index].done = true
const target = clampPercent(milestones[index].targetPercent || 0)
const pInt = parseInt(project.progress || 0, 10)
const progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
const patch = { ...project, milestones, progress }
if (progress >= 100) patch.status = "COMPLETED"
return publishReplace(ssbClient, project.id, patch)
},
async listProjects(filter) {
const ssbClient = await openSsb()
const currentUserId = ssbClient.id
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
const roots = new Set()
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
const out = []
for (const root of roots) {
const g = resolveProjectGroup(idx, root)
if (!g || g.tombstoned) continue
const c = g.project
out.push({
...c,
id: g.tip,
status: String(c.status || "ACTIVE").toUpperCase(),
createdAt: c.createdAt || new Date(g.best.ts).toISOString(),
deadline: c.deadline || null
})
}
let list = dedupeBy(out, p => p.title ? [norm(p.author), norm(p.title), norm(p.createdAt)].join('|') : null)
const F = String(filter || "ALL").toUpperCase()
if (F === "MINE") list = list.filter((p) => p && p.author === currentUserId)
else if (F === "APPLIED") list = list.filter((p) => p && p.author !== currentUserId && isParticipant(p, currentUserId))
else if (F === "ACTIVE") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "ACTIVE")
else if (F === "COMPLETED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "COMPLETED")
else if (F === "PAUSED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "PAUSED")
else if (F === "CANCELLED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "CANCELLED")
else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
if (F === "TOP") {
list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
} else {
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
}
return list
},
async getProjectById(id) {
return getById(id)
},
async getProjectTipId(id) {
return resolveTipId(id)
}
}
}

353
src/models/reports_model.js Normal file
View file

@ -0,0 +1,353 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const normU = (v) => String(v || '').trim().toUpperCase();
const normalizeStatus = (v) => normU(v).replace(/\s+/g, '_').replace(/-+/g, '_');
const normalizeSeverity = (v) => String(v || '').trim().toLowerCase();
const ensureArray = (v) => Array.isArray(v) ? v.filter(Boolean) : [];
const trimStr = (v) => String(v || '').trim();
const normalizeTemplate = (category, tpl) => {
const cat = normU(category);
const t = tpl && typeof tpl === 'object' ? tpl : {};
const pick = (keys) => {
const out = {};
for (const k of keys) {
const val = trimStr(t[k]);
if (val) out[k] = val;
}
return out;
};
if (cat === 'BUGS') {
const out = pick(['stepsToReproduce', 'expectedBehavior', 'actualBehavior', 'environment', 'reproduceRate']);
if (out.reproduceRate) out.reproduceRate = normU(out.reproduceRate);
return out;
}
if (cat === 'FEATURES') {
return pick(['problemStatement', 'userStory', 'acceptanceCriteria']);
}
if (cat === 'ABUSE') {
return pick(['whatHappened', 'reportedUser', 'evidenceLinks']);
}
if (cat === 'CONTENT') {
return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
}
return {};
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const confirms = [];
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'reportConfirm') { confirms.push({ target: c.target, author: v.author }); continue; }
if (c.type === 'reportOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== 'report') continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const confirmsByRoot = new Map(), opinionsByRoot = new Map();
for (const cf of confirms) { if (!nodes.has(cf.target)) continue; const r = rootOf(cf.target); if (!confirmsByRoot.has(r)) confirmsByRoot.set(r, []); confirmsByRoot.get(r).push(cf); }
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
if (!contentNode) return null;
const rootAuthor = nodes.get(root) ? nodes.get(root).author : null;
const lc = contentNode.c || {};
const opinions = { ...(lc.opinions || {}) };
const voterSet = new Set(ensureArray(lc.opinions_inhabitants));
const confirmSet = new Set(ensureArray(lc.confirmations));
const groupKeys = []; { let x = root, g = 0; groupKeys.push(x); while (child.has(x) && g++ < 100000) { x = child.get(x); groupKeys.push(x); } }
for (const k of groupKeys) {
const n = nodes.get(k); if (!n) continue; if (n.author !== rootAuthor) continue; const c = n.c || {};
for (const cb of ensureArray(c.confirmations)) confirmSet.add(cb);
for (const vv of ensureArray(c.opinions_inhabitants)) voterSet.add(vv);
if (c.opinions && typeof c.opinions === 'object') for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0);
}
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
for (const cf of (confirmsByRoot.get(root) || [])) confirmSet.add(cf.author);
return {
contentTip,
contentNode,
opinions,
opinions_inhabitants: [...voterSet],
confirmations: [...confirmSet],
tombstoned: tomb.has(contentTip)
};
};
return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup };
};
const buildReport = (node, agg) => {
const c = node.c || {};
const cat = normU(c.category);
const opinions = agg ? agg.opinions : (c.opinions || {});
const voters = agg ? agg.opinions_inhabitants : ensureArray(c.opinions_inhabitants);
const confirmations = agg ? agg.confirmations : ensureArray(c.confirmations);
return {
id: node.key,
...c,
category: cat,
status: normalizeStatus(c.status || 'OPEN'),
severity: normalizeSeverity(c.severity) || 'low',
confirmations,
tags: ensureArray(c.tags),
template: normalizeTemplate(cat, c.template || {}),
opinions,
opinions_inhabitants: voters
};
};
return {
async createReport(title, description, category, image, tagsRaw = [], severity = 'low', template = {}) {
const ssb = await openSsb();
const userId = ssb.id;
let blobId = null;
if (image) {
blobId = String(image).trim() || null;
}
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const cat = normU(category);
const content = {
type: 'report',
title,
description,
category: cat,
createdAt: new Date().toISOString(),
author: userId,
image: blobId,
tags,
confirmations: [],
severity: normalizeSeverity(severity) || 'low',
status: 'OPEN',
template: normalizeTemplate(cat, template),
opinions: {},
opinions_inhabitants: []
};
return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async updateReportById(id, updatedContent) {
const ssb = await openSsb();
const userId = ssb.id;
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) throw new Error('Report not found');
const report = { content: agg.contentNode.c };
const tipId = agg.contentTip;
if (agg.contentNode.author !== userId) throw new Error('Not the author');
const tags = Object.prototype.hasOwnProperty.call(updatedContent, 'tags')
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
: ensureArray(report.content.tags);
let blobId = report.content.image || null;
if (updatedContent.image) {
blobId = String(updatedContent.image).trim() || null;
}
const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
? normalizeStatus(updatedContent.status)
: normalizeStatus(report.content.status || 'OPEN');
const nextSeverity = Object.prototype.hasOwnProperty.call(updatedContent, 'severity')
? (normalizeSeverity(updatedContent.severity) || 'low')
: (normalizeSeverity(report.content.severity) || 'low');
const nextCategory = Object.prototype.hasOwnProperty.call(updatedContent, 'category')
? normU(updatedContent.category)
: normU(report.content.category);
const confirmations = ensureArray(agg.confirmations);
const baseTemplate = Object.prototype.hasOwnProperty.call(updatedContent, 'template')
? updatedContent.template
: (report.content.template || {});
const nextTemplate = normalizeTemplate(nextCategory, baseTemplate);
const updated = {
...report.content,
...updatedContent,
type: 'report',
replaces: tipId,
image: blobId,
tags,
confirmations,
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants,
severity: nextSeverity,
status: nextStatus,
category: nextCategory,
template: nextTemplate,
updatedAt: new Date().toISOString(),
author: report.content.author
};
return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
},
async deleteReportById(id) {
const ssb = await openSsb();
const userId = ssb.id;
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) throw new Error('Report not found');
if (agg.contentNode.author !== userId) throw new Error('Not the author');
const tombstone = { type: 'tombstone', target: agg.contentTip, deletedAt: new Date().toISOString(), author: userId };
return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
},
async getReportById(id) {
const ssb = await openSsb();
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) throw new Error('Report not found');
return buildReport(agg.contentNode, agg);
},
async confirmReportById(id) {
const ssb = await openSsb();
const userId = ssb.id;
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) throw new Error('Report not found');
if (agg.contentNode.author === userId) throw new Error('Cannot confirm own report');
if (ensureArray(agg.confirmations).includes(userId)) throw new Error('Already confirmed');
const content = { type: 'reportConfirm', target: root, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssb = await openSsb();
const userId = ssb.id;
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) throw new Error('Report not found');
if (ensureArray(agg.opinions_inhabitants).includes(userId)) throw new Error('Already opined');
const content = { type: 'reportOpinion', target: root, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async listAll() {
const ssb = await openSsb();
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const reports = [];
for (const root of idx.tipByRoot.keys()) {
const agg = idx.resolveGroup(root);
if (!agg || agg.tombstoned) continue;
reports.push(buildReport(agg.contentNode, agg));
}
return dedupeBy(reports, x => x.title ? [norm(x.author), norm(x.title), norm(x.category)].join('|') : null);
}
};
};

406
src/models/search_model.js Normal file
View file

@ -0,0 +1,406 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
const tryDecryptStandalone = (content) => {
if (!tribeCrypto || !content || !content.encryptedPayload) return null;
const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
for (const cand of rootCandidates) {
const keys = tribeCrypto.getKeys(cand);
if (!keys || !keys.length) continue;
try {
const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
}
return null;
};
const tryDecryptTribe = async (content) => {
if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
try {
const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
return null;
};
const searchableTypes = [
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document',
'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
];
const getRelevantFields = (type, content) => {
switch (type) {
case 'post':
return [content?.text, content?.contentWarning, ...(content?.tags || [])];
case 'about':
return [content?.about, content?.name, content?.description];
case 'feed':
return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
case 'event':
return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic];
case 'votes':
return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
case 'tribe':
return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
case 'audio':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'image':
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
case 'video':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'document':
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
case 'torrent':
return [content?.title, content?.description, ...(content?.tags || []), content?.url];
case 'market':
return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
case 'bookmark':
return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
case 'task':
return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
case 'report':
return [content?.title, content?.description, content?.category, content?.createdAt, (content?.isAnonymous ? null : content?.author), content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status];
case 'transfer':
return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length];
case 'curriculum':
return [content?.author, content?.name, content?.description, content?.photo, ...(content?.personalSkills || []), ...(content?.personalExperiences || []), ...(content?.oasisExperiences || []), ...(content?.oasisSkills || []), ...(content?.educationExperiences || []), ...(content?.educationalSkills || []), ...(content?.languages || []), ...(content?.professionalExperiences || []), ...(content?.professionalSkills || []), content?.location, content?.status, content?.preferences, content?.createdAt];
case 'bankWallet':
return [content?.address];
case 'bankClaim':
return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
case 'project':
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
case 'job':
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
case 'forum':
return [content?.root, content?.category, content?.title, content?.text, content?.key];
case 'vote':
return [content?.vote?.link];
case 'contact':
return [content?.contact];
case 'pub':
return [content?.address?.host, content?.address?.key];
case 'map':
return [content?.title, content?.description, content?.mapType, ...(content?.tags || []), content?.lat, content?.lng];
case 'shop':
return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url];
case 'shopProduct':
return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId];
case 'chat':
return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author];
case 'pad':
return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author];
case 'gameScore':
return [content?.game, content?.player];
default:
return [];
}
};
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const getDedupeKey = (msg) => {
const c = msg?.value?.content || {};
const t = c?.type || 'unknown';
const author = c.author || msg?.value?.author || '';
if (t === 'post') return `post:${msg.key}`;
if (t === 'about') return `about:${c.about || author || msg.key}`;
if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
if (t === 'contact') return `contact:${c.contact || msg.key}`;
if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
if (t === 'tribe') {
return [
'tribe',
author,
norm(c.title),
norm(c.location),
norm(c.image)
].join('|');
}
if (t === 'event') {
return [
'event',
c.organizer || author,
norm(c.title),
norm(c.date),
norm(c.location)
].join('|');
}
if (t === 'task') {
return [
'task',
c.author || author,
norm(c.title),
norm(c.startTime),
norm(c.endTime),
norm(c.location)
].join('|');
}
if (t === 'report') {
return [
'report',
c.author || author,
norm(c.title),
norm(c.category),
norm(c.severity)
].join('|');
}
if (t === 'votes') {
return [
'votes',
c.createdBy || author,
norm(c.question),
norm(c.deadline)
].join('|');
}
if (t === 'market') {
return [
'market',
c.seller || author,
norm(c.title),
norm(c.deadline),
norm(c.item_type),
norm(c.image)
].join('|');
}
if (t === 'transfer') {
const txid = c.txid || c.transactionId || c.id;
if (txid) return `transfer:${txid}`;
return [
'transfer',
norm(c.from),
norm(c.to),
norm(c.amount),
norm(c.concept),
norm(c.deadline)
].join('|');
}
if (t === 'feed') {
return [
'feed',
c.author || author,
norm(c.text)
].join('|');
}
if (t === 'project') {
return [
'project',
c.activityActor || author,
norm(c.title),
norm(c.deadline),
norm(c.goal)
].join('|');
}
if (t === 'job') {
return [
'job',
author,
norm(c.title),
norm(c.location),
norm(c.salary),
norm(c.job_type)
].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}
if (t === 'map') {
return ['map', author, norm(c.title), norm(c.description), norm(c.lat), norm(c.lng)].join('|');
}
if (t === 'shop') {
return ['shop', author, norm(c.title), norm(c.location)].join('|');
}
if (t === 'shopProduct') {
return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|');
}
if (t === 'pad') {
return ['pad', author, norm(c.title), norm(c.deadline)].join('|');
}
return `${t}:${msg.key}`;
};
const dedupeKeepLatest = (msgs) => {
const map = new Map();
for (const msg of msgs) {
const k = getDedupeKey(msg);
const prev = map.get(k);
const ts = msg?.value?.timestamp || 0;
const pts = prev?.value?.timestamp || 0;
if (!prev || ts > pts) map.set(k, msg);
}
return Array.from(map.values());
};
const search = async ({ query, types = [], resultsPerPage = "10" }) => {
const ssbClient = await openSsb();
const viewerId = ssbClient.id;
const queryLower = String(query || '').toLowerCase();
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? rej(err) : res(msgs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replacesMap = new Map();
const latestByKey = new Map();
for (const msg of messages) {
const k = msg.key;
const c = msg?.value?.content;
const t = c?.type;
if (!t || !searchableTypes.includes(t)) continue;
if (tombstoned.has(k)) continue;
if (c.replaces) replacesMap.set(c.replaces, k);
latestByKey.set(k, msg);
}
for (const [oldId, newId] of Array.from(replacesMap.entries())) {
const orig = latestByKey.get(oldId);
const repl = latestByKey.get(newId);
if (!orig) { replacesMap.delete(oldId); continue; }
if (!repl || String(repl?.value?.author) !== String(orig?.value?.author)) {
replacesMap.delete(oldId);
latestByKey.delete(newId);
}
}
for (const oldId of replacesMap.keys()) {
latestByKey.delete(oldId);
}
const viewerTribeIds = new Set();
if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') {
try {
const myTribes = await tribesModel.listTribesForViewer(viewerId);
for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key));
} catch (_) {}
}
for (const [k, msg] of Array.from(latestByKey.entries())) {
const c = msg?.value?.content;
if (!c) { latestByKey.delete(k); continue; }
if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) {
latestByKey.delete(k);
continue;
}
if (c.encryptedPayload) {
let dec = null;
if (c.tribeId) dec = await tryDecryptTribe(c);
if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
if (keys && keys.length) {
try {
const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
if (out && !out._undecryptable) dec = out;
} catch (_) {}
}
}
if (!dec) dec = tryDecryptStandalone(c);
if (!dec) { latestByKey.delete(k); continue; }
msg.value.content = { ...c, ...dec, encryptedPayload: undefined };
}
}
if (padsModel) {
for (const msg of latestByKey.values()) {
const c = msg?.value?.content;
if (c?.type === 'pad') {
const rootId = c.replaces ? msg.key : msg.key;
try {
const decrypted = await padsModel.decryptContent(c, rootId);
if (decrypted && typeof decrypted === 'object') {
if (decrypted.title) c.title = decrypted.title;
if (decrypted.deadline) c.deadline = decrypted.deadline;
if (Array.isArray(decrypted.tags) && decrypted.tags.length) c.tags = decrypted.tags;
}
} catch (_) {}
}
}
}
let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content;
const t = c?.type;
if (!t || (types.length > 0 && !types.includes(t))) return false;
if (t === 'market') {
if (c.stock === 0 && c.status !== 'SOLD') return false;
}
if (!queryLower) return true;
if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
const fields = getRelevantFields(t, c);
if (queryLower.startsWith('#') && queryLower.length > 1) {
const tag = queryLower.substring(1);
const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
return tagArr.some(x => String(x).toLowerCase() === tag);
}
return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
});
filtered = dedupeKeepLatest(filtered);
filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
const grouped = filtered.reduce((acc, msg) => {
const t = msg?.value?.content?.type || 'unknown';
if (!acc[t]) acc[t] = [];
acc[t].push(msg);
return acc;
}, {});
if (resultsPerPage !== "all") {
const limit = parseInt(resultsPerPage, 10);
for (const key in grouped) grouped[key] = grouped[key].slice(0, limit);
}
return grouped;
};
return { search };
};

954
src/models/shops_model.js Normal file
View file

@ -0,0 +1,954 @@
const pull = require("../server/node_modules/pull-stream")
const { getConfig } = require("../configs/config-manager.js")
const categories = require("../backend/opinion_categories")
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeByPreferring, norm } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const safeArr = (v) => (Array.isArray(v) ? v : [])
const safeText = (v) => String(v || "").trim()
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
)
const SELLER_STATUSES = ["ACCEPTED", "REJECTED", "PAID", "SHIPPED"]
const BUYER_STATUSES = ["RECEIVED"]
const ORDER_STATUSES = [...SELLER_STATUSES, ...BUYER_STATUSES]
const buildOrderStatusMap = (ssbClient, messages) => {
const map = new Map()
for (const m of messages) {
if (typeof m.value?.content !== "string") continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
const dc = dec?.value?.content
if (!dc || dc.type !== "shop-purchase-status" || !dc.orderId) continue
const ts = dec.value.timestamp || m.timestamp || 0
const prev = map.get(dc.orderId)
if (!prev || ts >= prev.ts) map.set(dc.orderId, { status: String(dc.status || "").toUpperCase(), ts })
} catch (_) {}
}
return map
}
const decryptBuyers = (val, key) => {
if (Array.isArray(val)) return val
if (typeof val === 'string' && tribeCrypto && key) {
try { return JSON.parse(tribeCrypto.decryptWithKey(val, key)) } catch {}
}
return []
}
const isEncrypted = (c) => !!(c && c.encryptedPayload)
const productShopId = (c) => (c && (c.shopId || c.tribeId)) || ""
const keysForRoot = (rootId) => {
if (!tribeCrypto || !rootId) return []
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
if (ks.length) return ks
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
return k ? [k] : []
}
const decryptShopContent = (c, keyRootId) => {
if (!isEncrypted(c)) return c
if (!tribeCrypto) return { ...c, _undecryptable: true }
const keys = keysForRoot(keyRootId)
if (!keys.length) return { ...c, _undecryptable: true }
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
}
const encryptForShop = (plainContent, shopRootId) => {
const keys = keysForRoot(shopRootId)
if (!keys.length) throw new Error("Missing shop key")
return tribeCrypto.encryptContent(plainContent, [keys[0]], true)
}
const buildIndex = (messages) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const strictChild = new Map()
const opinionMsgs = []
const purchaseMsgs = []
for (const m of messages) {
const k = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
if (c.type === "shopOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue }
if (c.type === "shopPurchase" && c.target) { purchaseMsgs.push({ target: c.target, author: v.author }); continue }
if (c.type === "shop" || c.type === "shopProduct") {
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k) }
}
const rootOf = (id) => { let cur = id, g = 0; while (parent.has(cur) && g++ < 100000) cur = parent.get(cur); return cur }
const tipOf = (id) => { let cur = id, g = 0; while (child.has(cur) && g++ < 100000) cur = child.get(cur); return cur }
const strictTipOf = (id) => {
const root = rootOf(id)
let cur = root, g = 0
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur)
const n = nodes.get(cur), rn = nodes.get(root)
return (n && rn && n.author === rn.author) ? cur : root
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const opinionsByRoot = new Map()
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op) }
const purchasesByRoot = new Map()
for (const pu of purchaseMsgs) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); if (!purchasesByRoot.has(r)) purchasesByRoot.set(r, []); purchasesByRoot.get(r).push(pu) }
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, strictTipOf(r))
const aggregateFor = (rootId, decrypted) => {
const opinions = { ...((decrypted && decrypted.opinions) || {}) }
const voters = safeArr(decrypted && decrypted.opinions_inhabitants).slice()
const voterSet = new Set(voters)
for (const op of (opinionsByRoot.get(rootId) || [])) {
if (voterSet.has(op.author)) continue
voterSet.add(op.author); voters.push(op.author)
if (op.category) opinions[op.category] = (opinions[op.category] || 0) + 1
}
return { opinions, opinions_inhabitants: voters }
}
const purchaseCountFor = (rootId) => (purchasesByRoot.get(rootId) || []).length
const purchaseAuthorsFor = (rootId) => (purchasesByRoot.get(rootId) || []).map(p => p.author)
return { tomb, nodes, parent, child, strictChild, rootOf, tipOf, strictTipOf, tipByRoot, aggregateFor, purchaseCountFor, purchaseAuthorsFor }
}
const buildShop = (node, rootId, idx) => {
const raw = node.c || {}
if (raw.type !== "shop") return null
const encrypted = isEncrypted(raw)
const c = encrypted ? decryptShopContent(raw, rootId) : raw
const undecryptable = !!c._undecryptable
const agg = idx ? idx.aggregateFor(rootId, c) : { opinions: c.opinions || {}, opinions_inhabitants: safeArr(c.opinions_inhabitants) }
return {
key: node.key,
rootId,
title: c.title || "",
shortDescription: c.shortDescription || "",
description: c.description || "",
image: c.image || null,
url: c.url || "",
location: c.location || "",
tags: safeArr(c.tags),
visibility: c.visibility || (encrypted ? "CLOSED" : "OPEN"),
clearnetPublic: !!c.clearnetPublic,
author: raw.author || c.author || node.author,
createdAt: c.createdAt || raw.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || raw.updatedAt || null,
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants,
mapUrl: c.mapUrl || "",
encrypted,
undecryptable
}
}
const buildProduct = (node, rootId, idx) => {
const raw = node.c || {}
if (raw.type !== "shopProduct") return null
const encrypted = isEncrypted(raw)
const shopRoot = productShopId(raw)
const c = encrypted ? decryptShopContent(raw, shopRoot) : raw
const undecryptable = !!c._undecryptable
const agg = idx ? idx.aggregateFor(rootId, c) : { opinions: c.opinions || {}, opinions_inhabitants: safeArr(c.opinions_inhabitants) }
const sellerStock = Number(c.stock) || 0
const purchaseCount = idx ? idx.purchaseCountFor(rootId) : 0
const sellerBuyers = encrypted ? safeArr(c.buyers) : decryptBuyers(c.buyers, tribeCrypto ? tribeCrypto.getKey(rootId) : null)
const buyers = Array.from(new Set(sellerBuyers.concat(idx ? idx.purchaseAuthorsFor(rootId) : [])))
return {
key: node.key,
rootId,
shopId: shopRoot,
title: c.title || "",
description: c.description || "",
image: c.image || null,
price: c.price || "0.000000",
stock: Math.max(0, sellerStock - purchaseCount),
featured: !!c.featured,
author: raw.author || c.author || node.author,
createdAt: c.createdAt || raw.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || raw.updatedAt || null,
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants,
buyers,
encrypted,
undecryptable
}
}
const countProductsFromIndex = (idx, shopRootId) => {
let count = 0
for (const tipId of idx.tipByRoot.values()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "shopProduct") continue
if (productShopId(node.c) === shopRootId) count++
}
return count
}
return {
type: "shop",
async resolveRootId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tip = idx.strictTipOf(id)
if (idx.tomb.has(tip)) throw new Error("Not found")
return idx.rootOf(tip)
},
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tip = idx.strictTipOf(id)
if (idx.tomb.has(tip)) throw new Error("Not found")
return tip
},
async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl, clearnetPublic) {
const ssbClient = await openSsb()
const blobId = image ? String(image).trim() || null : null
const tags = normalizeTags(tagsRaw)
const vis = String(visibility || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN"
const now = new Date().toISOString()
const content = {
type: "shop",
title: safeText(title),
shortDescription: safeText(shortDescription),
description: safeText(description),
image: blobId,
url: safeText(url),
location: safeText(location),
tags,
visibility: vis,
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
mapUrl: safeText(mapUrl),
author: ssbClient.id,
createdAt: now,
updatedAt: now,
opinions: {},
opinions_inhabitants: []
}
const wantsEncryption = vis === "CLOSED" && !!tribeCrypto
return new Promise((resolve, reject) => {
if (wantsEncryption) {
const shopKey = tribeCrypto.generateTribeKey()
const encrypted = tribeCrypto.encryptContent(content, [shopKey], true)
ssbClient.publish(encrypted, (err, msg) => {
if (err) return reject(err)
if (msg && msg.key) tribeCrypto.setKey(msg.key, shopKey, 1)
resolve(msg)
})
} else {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
}
})
},
async updateShopById(id, data) {
const tipId = await this.resolveCurrentId(id)
const rootId = await this.resolveRootId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Shop not found"))
const raw = item.content
if (raw.author !== userId) return reject(new Error("Not the author"))
const encrypted = isEncrypted(raw)
const c = encrypted ? decryptShopContent(raw, rootId) : raw
if (c._undecryptable) return reject(new Error("Cannot decrypt shop"))
const plain = {
...c,
title: data.title !== undefined ? safeText(data.title) : c.title,
shortDescription: data.shortDescription !== undefined ? safeText(data.shortDescription) : c.shortDescription,
description: data.description !== undefined ? safeText(data.description) : c.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
url: data.url !== undefined ? safeText(data.url) : c.url,
location: data.location !== undefined ? safeText(data.location) : c.location,
tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
visibility: encrypted ? "CLOSED" : c.visibility,
clearnetPublic: data.clearnetPublic !== undefined ? (data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on') : !!c.clearnetPublic,
updatedAt: new Date().toISOString(),
replaces: tipId
}
delete plain._decrypted
delete plain._undecryptable
delete plain.encryptedPayload
let updated
try {
updated = encrypted ? encryptForShop(plain, rootId) : plain
} catch (e) { return reject(e) }
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
})
})
},
async deleteShopById(id) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Shop not found"))
if (item.content.author !== userId) return reject(new Error("Not the author"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
})
})
},
async getShopById(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tip = idx.strictTipOf(id)
if (idx.tomb.has(tip)) return null
const node = idx.nodes.get(tip)
if (!node || node.c.type !== "shop") return null
const root = idx.rootOf(tip)
const shop = buildShop(node, root, idx)
if (!shop) return null
shop.productCount = countProductsFromIndex(idx, root)
return shop
},
async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
const ssbClient = await openSsb()
const uid = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "shop") continue
const shop = buildShop(node, rootId, idx)
if (!shop) continue
if (shop.encrypted) {
if (shop.undecryptable) continue
} else if (shop.visibility === "CLOSED" && shop.author !== uid) continue
shop.productCount = countProductsFromIndex(idx, rootId)
items.push(shop)
}
let list = dedupeByPreferring(items, (s) => (s.author && s.createdAt) ? norm(s.author) + "|" + norm(s.createdAt) : null, (s) => (Number(s.productCount) || 0) + voteSum(s.opinions))
const now = Date.now()
if (filter === "mine") list = list.filter(s => s.author === uid)
else if (filter === "recent") list = list.filter(s => new Date(s.createdAt).getTime() >= now - 86400000)
else if (filter === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
if (q) {
const qq = q.toLowerCase()
list = list.filter(s => {
const t = String(s.title || "").toLowerCase()
const d = String(s.description || "").toLowerCase()
const loc = String(s.location || "").toLowerCase()
const tags = safeArr(s.tags).join(" ").toLowerCase()
return t.includes(qq) || d.includes(qq) || loc.includes(qq) || tags.includes(qq)
})
}
if (filter !== "top") {
if (sort === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
else list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
}
return list
},
async createProduct(shopId, title, description, image, price, stock, featured) {
const ssbClient = await openSsb()
const blobId = image ? String(image).trim() || null : null
const p = parseFloat(String(price || "").replace(",", "."))
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
const s = parseInt(String(stock || "1"), 10)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
const now = new Date().toISOString()
const content = {
type: "shopProduct",
shopId,
title: safeText(title),
description: safeText(description),
image: blobId,
price: p.toFixed(6),
stock: s,
featured: !!featured,
author: ssbClient.id,
createdAt: now,
updatedAt: now,
opinions: {},
opinions_inhabitants: []
}
const shopKeys = keysForRoot(shopId)
const e2e = shopKeys.length > 0
if (e2e) content.tribeId = shopId
return new Promise((resolve, reject) => {
if (e2e) {
let enc
try { enc = tribeCrypto.encryptContent(content, [shopKeys[0]], true) } catch (e) { return reject(e) }
ssbClient.publish(enc, (err, msg) => err ? reject(err) : resolve(msg))
} else {
ssbClient.publish(content, (err, msg) => {
if (err) return reject(err)
if (msg && msg.key && tribeCrypto) {
const key = tribeCrypto.generateTribeKey()
tribeCrypto.setKey(msg.key, key, 1)
}
resolve(msg)
})
}
})
},
async updateProductById(id, data) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Product not found"))
const raw = item.content
if (raw.author !== userId) return reject(new Error("Not the author"))
const encrypted = isEncrypted(raw)
const shopRoot = productShopId(raw)
const c = encrypted ? decryptShopContent(raw, shopRoot) : raw
if (c._undecryptable) return reject(new Error("Cannot decrypt product"))
const pRaw = data.price !== undefined ? parseFloat(String(data.price || "").replace(",", ".")) : null
const sRaw = data.stock !== undefined ? parseInt(String(data.stock || "0"), 10) : null
const plain = {
...c,
title: data.title !== undefined ? safeText(data.title) : c.title,
description: data.description !== undefined ? safeText(data.description) : c.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
price: pRaw !== null && Number.isFinite(pRaw) && pRaw > 0 ? pRaw.toFixed(6) : c.price,
stock: sRaw !== null && Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : c.stock,
featured: data.featured !== undefined ? !!data.featured : !!c.featured,
updatedAt: new Date().toISOString(),
replaces: tipId
}
delete plain._decrypted
delete plain._undecryptable
delete plain.encryptedPayload
let updated
try {
updated = encrypted ? encryptForShop({ ...plain, tribeId: shopRoot }, shopRoot) : plain
} catch (e) { return reject(e) }
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
})
})
},
async deleteProductById(id) {
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item?.content) return reject(new Error("Product not found"))
if (item.content.author !== userId) return reject(new Error("Not the author"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
})
})
},
async getProductById(id) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tip = idx.strictTipOf(id)
if (idx.tomb.has(tip)) return null
const node = idx.nodes.get(tip)
if (!node || node.c.type !== "shopProduct") return null
const root = idx.rootOf(tip)
return buildProduct(node, root, idx)
},
async listProducts(shopRootId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "shopProduct") continue
if (productShopId(node.c) !== shopRootId) continue
const prod = buildProduct(node, rootId, idx)
if (prod && !prod.undecryptable) items.push(prod)
}
return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async listFeaturedProducts(shopRootId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "shopProduct") continue
if (productShopId(node.c) !== shopRootId) continue
if (!node.c.featured) continue
const prod = buildProduct(node, rootId, idx)
if (prod && !prod.undecryptable) items.push(prod)
}
return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 4)
},
async listAllProducts({ filter = "all", sort = "recent" } = {}) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node || node.c.type !== "shopProduct") continue
const prod = buildProduct(node, rootId, idx)
if (prod && !prod.undecryptable) items.push(prod)
}
if (filter === "top") return items.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async buyProduct(productId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tipId = idx.strictTipOf(productId)
if (idx.tomb.has(tipId)) throw new Error("Product not found")
const rootId = idx.rootOf(tipId)
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Product not found")
const raw = node.c
if (raw.author === userId) throw new Error("Cannot buy your own product")
const encrypted = isEncrypted(raw)
const shopRoot = productShopId(raw)
const c = encrypted ? decryptShopContent(raw, shopRoot) : raw
if (c._undecryptable) throw new Error("Cannot access product")
const stock = Math.max(0, (Number(c.stock) || 0) - idx.purchaseCountFor(rootId))
if (stock <= 0) throw new Error("Out of stock")
const content = { type: "shopPurchase", target: rootId, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async createPurchaseOrder(productId, deliveryDetails = {}) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tipId = idx.strictTipOf(productId)
if (idx.tomb.has(tipId)) throw new Error("Product not found")
const rootId = idx.rootOf(tipId)
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Product not found")
const raw = node.c
const shopOwner = raw.author
if (shopOwner === userId) throw new Error("Cannot buy your own product")
const shopRoot = productShopId(raw)
const c = isEncrypted(raw) ? decryptShopContent(raw, shopRoot) : raw
const content = {
type: "shop-purchase",
productId: rootId,
productTipId: tipId,
shopId: shopRoot,
seller: shopOwner,
title: String(c.title || ""),
price: c.price || "",
deliveryAddress: String(deliveryDetails.deliveryAddress || ""),
contact: String(deliveryDetails.contact || ""),
notes: String(deliveryDetails.notes || ""),
createdAt: new Date().toISOString()
}
const recps = [userId, shopOwner]
return new Promise((res, rej) => ssbClient.private.publish(content, recps, (e, m) => e ? rej(e) : res(m)))
},
async listMyPurchases() {
const ssbClient = await openSsb()
const me = ssbClient.id
const messages = await readAll(ssbClient)
const out = []
for (const m of messages) {
if (typeof m.value?.content !== "string") continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
if (!dec?.value?.content) continue
const dc = dec.value.content
if (dc.type !== "shop-purchase") continue
if (dec.value.author !== me) continue
out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 })
} catch (_) {}
}
const statusMap = buildOrderStatusMap(ssbClient, messages)
const sellerCache = new Map()
for (const o of out) {
if (!o.seller && o.shopId) {
if (!sellerCache.has(o.shopId)) {
const shop = await this.getShopById(o.shopId).catch(() => null)
sellerCache.set(o.shopId, (shop && shop.author) || null)
}
o.seller = sellerCache.get(o.shopId) || null
}
}
return out
.map(o => ({ ...o, status: (statusMap.get(o.id) || {}).status || "PENDING" }))
.sort((a, b) => b.ts - a.ts)
},
async listShopOrders(shopRootId) {
const ssbClient = await openSsb()
const me = ssbClient.id
const shop = await this.getShopById(shopRootId).catch(() => null)
if (!shop) throw new Error("Shop not found")
if (shop.author !== me) throw new Error("Not the shop owner")
const messages = await readAll(ssbClient)
const out = []
for (const m of messages) {
if (typeof m.value?.content !== "string") continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
if (!dec?.value?.content) continue
const dc = dec.value.content
if (dc.type !== "shop-purchase") continue
if (dc.shopId !== shopRootId) continue
out.push({ id: m.key, ...dc, buyer: dec.value.author, ts: dec.value.timestamp || m.timestamp || 0 })
} catch (_) {}
}
const statusMap = buildOrderStatusMap(ssbClient, messages)
return out
.map(o => ({ ...o, status: (statusMap.get(o.id) || {}).status || "PENDING" }))
.sort((a, b) => b.ts - a.ts)
},
async setOrderStatus(orderId, status) {
const ssbClient = await openSsb()
const me = ssbClient.id
const st = String(status || "").toUpperCase()
if (!ORDER_STATUSES.includes(st)) throw new Error("Invalid status")
const messages = await readAll(ssbClient)
let order = null
for (const m of messages) {
if (m.key !== orderId) continue
if (typeof m.value?.content !== "string") break
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
const dc = dec?.value?.content
if (dc && dc.type === "shop-purchase") order = { ...dc, buyer: dec.value.author }
} catch (_) {}
break
}
if (!order) throw new Error("Order not found")
const seller = order.seller || (await this.getShopById(order.shopId).catch(() => null) || {}).author || null
if (BUYER_STATUSES.includes(st)) {
if (me !== order.buyer) throw new Error("Only the buyer can set this status")
} else {
if (me !== seller) throw new Error("Only the seller can set this status")
}
const recps = Array.from(new Set([order.buyer, seller].filter(Boolean)))
const content = { type: "shop-purchase-status", orderId, shopId: order.shopId, status: st, updatedAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.private.publish(content, recps, (e, m) => e ? rej(e) : res(m)))
},
async countPendingOrders(shopRootId) {
try {
const orders = await this.listShopOrders(shopRootId)
return orders.filter(o => (o.status || "PENDING") === "PENDING").length
} catch (_) { return 0 }
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const tipId = idx.strictTipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Not found")
const rootId = idx.rootOf(tipId)
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Not found")
const raw = node.c
const encrypted = isEncrypted(raw)
const keyRoot = raw.type === "shop" ? rootId : productShopId(raw)
const c = encrypted ? decryptShopContent(raw, keyRoot) : raw
if (c._undecryptable) throw new Error("Cannot decrypt item")
const voters = idx.aggregateFor(rootId, c).opinions_inhabitants
if (voters.includes(userId)) throw new Error("Already voted")
const myOrders = await this.listMyPurchases()
const received = myOrders.some(o => o.productId === rootId && String(o.status || "").toUpperCase() === "RECEIVED")
if (!received) throw new Error("You can rate only after confirming you received the item")
const content = { type: "shopOpinion", target: rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async generateInvite(shopId) {
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
const rootId = await this.resolveRootId(shopId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content)))
if (!raw) throw new Error("Shop not found")
if (!isEncrypted(raw)) throw new Error("Only private shops use invitation codes")
if (raw.author !== userId) throw new Error("Only the author can generate invites")
const keys = keysForRoot(rootId)
if (!keys.length) throw new Error("Missing shop key")
const code = require("crypto").randomBytes(16).toString("hex")
const salt = tribeCrypto.generateInviteSalt()
const ek = tribeCrypto.encryptForInvite(keys[0], code, salt)
await new Promise((resolve, reject) =>
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt) }, (e) => e ? reject(e) : resolve())
)
return { code, shopId: rootId }
},
async getOpenInvite(shopId) {
const ssbClient = await openSsb()
const rootId = await this.resolveRootId(shopId).catch(() => shopId)
const messages = await readAll(ssbClient)
const markerTomb = new Set()
const invTomb = new Set()
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "shop-open-invite-tombstone" && typeof c.target === "string") markerTomb.add(c.target)
if (c.type === "shop-invite-tombstone" && typeof c.target === "string") invTomb.add(c.target)
}
let best = null
for (const m of messages) {
const c = m.value && m.value.content
if (!c || c.type !== "shop-open-invite" || c.v !== 1) continue
if (c.target !== rootId || typeof c.code !== "string") continue
if (markerTomb.has(m.key)) continue
if (c.inviteKey && invTomb.has(c.inviteKey)) continue
const ts = (m.value && m.value.timestamp) || 0
if (!best || ts > best.ts) best = { code: c.code, by: c.by || m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts }
}
return best ? { code: best.code, by: best.by, markerKey: best.markerKey, inviteKey: best.inviteKey } : null
},
async generateOpenInvite(shopId) {
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
const rootId = await this.resolveRootId(shopId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content)))
if (!raw) throw new Error("Shop not found")
if (!isEncrypted(raw)) throw new Error("Only private shops use invitation codes")
if (raw.author !== userId) throw new Error("Only the author can generate invites")
if (await this.getOpenInvite(shopId)) throw new Error("An open invitation already exists")
const keys = keysForRoot(rootId)
if (!keys.length) throw new Error("Missing shop key")
const code = require("crypto").randomBytes(16).toString("hex")
const salt = tribeCrypto.generateInviteSalt()
const ek = tribeCrypto.encryptForInvite(keys[0], code, salt)
const invitePub = await new Promise((resolve, reject) =>
ssbClient.publish({ type: "shop-invite", target: rootId, ek, salt, codeHash: tribeCrypto.hashInviteCode(code, salt), multi: 1 }, (e, r) => e ? reject(e) : resolve(r))
)
await new Promise((resolve, reject) =>
ssbClient.publish({ type: "shop-open-invite", v: 1, target: rootId, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (e) => e ? reject(e) : resolve())
)
return { code, shopId: rootId }
},
async removeOpenInvite(shopId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const rec = await this.getOpenInvite(shopId)
if (!rec) return
let author = null
try {
const rootId = await this.resolveRootId(shopId)
const raw = await new Promise((res, rej) => ssbClient.get(rootId, (e, m) => e ? rej(e) : res(m && m.content)))
author = raw && raw.author
} catch (_) {}
if (rec.by !== userId && author !== userId) throw new Error("Not allowed to remove this invitation")
await new Promise((resolve, reject) => ssbClient.publish({ type: "shop-open-invite-tombstone", target: rec.markerKey, ts: new Date().toISOString() }, (e) => e ? reject(e) : resolve()))
if (rec.inviteKey) await new Promise((resolve, reject) => ssbClient.publish({ type: "shop-invite-tombstone", target: rec.inviteKey, ts: new Date().toISOString() }, (e) => e ? reject(e) : resolve()))
},
async joinByCode(code) {
if (!tribeCrypto) throw new Error("Shop crypto unavailable")
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const invTomb = new Set()
for (const m of messages) {
const c = m.value && m.value.content
if (c && c.type === "shop-invite-tombstone" && typeof c.target === "string") invTomb.add(c.target)
}
let matched = null
for (const m of messages) {
const c = m.value && m.value.content
if (!c || c.type !== "shop-invite") continue
if (invTomb.has(m.key)) continue
try {
if (tribeCrypto.hashInviteCode(code, c.salt) === c.codeHash) { matched = c; break }
} catch (_) {}
}
if (!matched) throw new Error("Invalid or expired invite code")
const shopKey = tribeCrypto.decryptFromInvite(matched.ek, code, matched.salt)
if (!shopKey) throw new Error("Could not decrypt invite")
if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(matched.target, shopKey)
else tribeCrypto.setKey(matched.target, shopKey, 1)
return { ok: true, shopId: matched.target }
},
async setShopVisibility(id, target) {
const tgt = String(target || "").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN"
const rootId = await this.resolveRootId(id)
const tipId = await this.resolveCurrentId(id)
const ssbClient = await openSsb()
const userId = ssbClient.id
const shopRaw = await new Promise((res, rej) => ssbClient.get(tipId, (e, m) => e ? rej(e) : res(m && m.content)))
if (!shopRaw) throw new Error("Shop not found")
if (shopRaw.author !== userId) throw new Error("Not the author")
const curShop = isEncrypted(shopRaw) ? decryptShopContent(shopRaw, rootId) : shopRaw
if (curShop._undecryptable) throw new Error("Cannot decrypt shop")
const wantEncrypted = tgt === "CLOSED" && !!tribeCrypto
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
const products = []
for (const tip of idx.tipByRoot.values()) {
if (idx.tomb.has(tip)) continue
const node = idx.nodes.get(tip)
if (!node || node.c.type !== "shopProduct") continue
if (productShopId(node.c) !== rootId) continue
products.push({ tip, raw: node.c })
}
if (wantEncrypted) {
const newKey = tribeCrypto.generateTribeKey()
if (tribeCrypto.addNewKey) tribeCrypto.addNewKey(rootId, newKey)
else tribeCrypto.setKey(rootId, newKey, 1)
}
const publish = (c) => new Promise((res, rej) => ssbClient.publish(c, (e, m) => e ? rej(e) : res(m)))
const tombstone = (t) => ({ type: "tombstone", target: t, deletedAt: new Date().toISOString(), author: userId })
const shopPlain = { ...curShop, visibility: tgt, updatedAt: new Date().toISOString(), replaces: tipId }
delete shopPlain._decrypted
delete shopPlain._undecryptable
delete shopPlain.encryptedPayload
delete shopPlain.tribeId
const shopMsg = wantEncrypted ? encryptForShop(shopPlain, rootId) : shopPlain
await publish(tombstone(tipId))
await publish(shopMsg)
for (const p of products) {
const pPlain0 = isEncrypted(p.raw) ? decryptShopContent(p.raw, rootId) : p.raw
if (pPlain0._undecryptable) continue
const pPlain = { ...pPlain0, shopId: rootId, updatedAt: new Date().toISOString(), replaces: p.tip }
delete pPlain._decrypted
delete pPlain._undecryptable
delete pPlain.encryptedPayload
delete pPlain.tribeId
let pMsg
if (wantEncrypted) {
pPlain.tribeId = rootId
pMsg = encryptForShop(pPlain, rootId)
} else {
pMsg = pPlain
}
await publish(tombstone(p.tip))
await publish(pMsg)
}
return { ok: true, shopId: rootId, visibility: tgt, encrypted: wantEncrypted }
}
}
}

622
src/models/stats_model.js Normal file
View file

@ -0,0 +1,622 @@
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(os.homedir(), '.ssb', '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',
'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;
}
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(`${os.homedir()}/.ssb/secret`);
const createdAt = secretStat.birthtime.toLocaleString();
const folderSize = getFolderSize(`${os.homedir()}/.ssb`);
const flumeSize = getFolderSize(`${os.homedir()}/.ssb/flume`);
const blobsSize = getFolderSize(`${os.homedir()}/.ssb/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 };
};

207
src/models/tags_model.js Normal file
View file

@ -0,0 +1,207 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, padsModel, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, '');
const tagKey = (tag) => normalizeTag(tag).toLowerCase();
const getDedupeKey = (msg) => {
const c = msg?.value?.content || {};
const t = c?.type || 'unknown';
const author = msg?.value?.author || c.author || '';
if (t === 'post') return `post:${msg.key}`;
if (t === 'about') return `about:${c.about || author || msg.key}`;
if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
if (t === 'contact') return `contact:${c.contact || msg.key}`;
if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
if (t === 'document') return `document:${author}|${c.key || c.url || norm(c.title) || msg.key}`;
if (t === 'image') return `image:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'audio') return `audio:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'video') return `video:${author}|${c.url || `${norm(c.title)}|${norm(c.description)}` || msg.key}`;
if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
if (t === 'tribe') {
return ['tribe', author, norm(c.title), norm(c.location), norm(c.image)].join('|');
}
if (t === 'event') {
return ['event', c.organizer || author, norm(c.title), norm(c.date), norm(c.location)].join('|');
}
if (t === 'task') {
return ['task', c.author || author, norm(c.title), norm(c.startTime), norm(c.endTime), norm(c.location)].join('|');
}
if (t === 'report') {
return ['report', c.author || author, norm(c.title), norm(c.category), norm(c.severity)].join('|');
}
if (t === 'votes') {
return ['votes', c.createdBy || author, norm(c.question), norm(c.deadline)].join('|');
}
if (t === 'market') {
return ['market', c.seller || author, norm(c.title), norm(c.deadline), norm(c.item_type), norm(c.image)].join('|');
}
if (t === 'transfer') {
const txid = c.txid || c.transactionId || c.id;
if (txid) return `transfer:${txid}`;
return ['transfer', norm(c.from), norm(c.to), norm(c.amount), norm(c.concept), norm(c.deadline)].join('|');
}
if (t === 'feed') {
return ['feed', c.author || author, norm(c.text)].join('|');
}
if (t === 'project') {
return ['project', c.activityActor || author, norm(c.title), norm(c.deadline), norm(c.goal)].join('|');
}
if (t === 'job') {
return ['job', author, norm(c.title), norm(c.location), norm(c.salary), norm(c.job_type)].join('|');
}
if (t === 'forum') {
return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
}
return `${t}:${msg.key}`;
};
const dedupeKeepLatest = (msgs) => {
const map = new Map();
for (const msg of msgs) {
const k = getDedupeKey(msg);
const prev = map.get(k);
const ts = msg?.value?.timestamp || 0;
const pts = prev?.value?.timestamp || 0;
if (!prev || ts > pts) map.set(k, msg);
}
return Array.from(map.values());
};
return {
async listTags(filter = 'all') {
const ssbClient = await openSsb();
const messages = await new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replacesMap = new Map();
const latestByKey = new Map();
for (const msg of messages) {
const k = msg?.key;
const c = msg?.value?.content;
const t = c?.type;
if (!k || !c || !t) continue;
if (tombstoned.has(k)) continue;
if (t === 'tombstone') continue;
if (c.replaces) replacesMap.set(c.replaces, k);
latestByKey.set(k, msg);
}
for (const [oldId, newId] of Array.from(replacesMap.entries())) {
const oldMsg = latestByKey.get(oldId);
if (!oldMsg) { replacesMap.delete(oldId); continue; }
const newMsg = latestByKey.get(newId);
if (!newMsg || String(newMsg?.value?.author) !== String(oldMsg?.value?.author)) {
replacesMap.delete(oldId);
latestByKey.delete(newId);
}
}
for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
const viewerId = ssbClient.id;
const viewerVisibleTribeIds = new Set();
if (tribesModel) {
const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []);
for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id);
}
let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content;
if (!c || c.type === 'tombstone') return false;
if (tombstoned.has(msg.key)) return false;
if (c.encryptedPayload) return false;
if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false;
if (c.tribeId && !viewerVisibleTribeIds.has(c.tribeId)) return false;
if (c.type === 'event' && c.isPublic === 'private') return false;
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false;
if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
return true;
});
filtered = dedupeKeepLatest(filtered);
const counts = new Map();
for (const record of filtered) {
const tagsArr = record?.value?.content?.tags || [];
const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean));
for (const k of uniqueTags) {
const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k;
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1 });
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
}
}
if (padsModel) {
const ssbClient2 = await openSsb();
const viewerId = ssbClient2.id;
const pads = await padsModel.listAll({ filter: 'all', viewerId }).catch(() => []);
for (const pad of pads) {
if (pad.status === 'INVITE-ONLY' && pad.author !== viewerId && !(Array.isArray(pad.members) && pad.members.includes(viewerId))) continue;
if (!Array.isArray(pad.tags)) continue;
const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
for (const k of uniquePadTags) {
const display = normalizeTag(pad.tags.find(t => tagKey(t) === k) || k) || k;
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1 });
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
}
}
}
let tags = Array.from(counts.values());
if (filter === 'top') {
tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
} else if (filter === 'cloud') {
const max = Math.max(...tags.map(t => t.count), 1);
tags = tags.map(t => ({ ...t, weight: t.count / max }));
} else {
tags.sort((a, b) => a.name.localeCompare(b.name));
}
return tags;
}
};
};

369
src/models/tasks_model.js Normal file
View file

@ -0,0 +1,369 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, pmModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const uniq = (arr) => Array.from(new Set((Array.isArray(arr) ? arr : []).filter(x => typeof x === 'string' && x.trim().length)));
const normalizeVisibility = (v) => {
const vv = String(v || 'PUBLIC').toUpperCase();
return (vv === 'PUBLIC' || vv === 'PRIVATE') ? vv : 'PUBLIC';
};
const normalizeStatus = (v, fallback) => {
const vv = String(v || '').toUpperCase();
if (vv === 'OPEN' || vv === 'IN-PROGRESS' || vv === 'CLOSED') return vv;
return fallback;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
const assignMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'taskOpinion') { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type === 'taskAssign') { assignMsgs.push({ target: c.target, author: v.author, on: c.on !== false, ts: v.timestamp || m.timestamp || 0 }); continue; }
if (c.type !== 'task') continue;
const ts = v.timestamp || m.timestamp || 0;
nodes.set(k, { key: k, ts, c, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const assignByRoot = new Map();
for (const as of assignMsgs) { if (!nodes.has(as.target)) continue; const r = rootOf(as.target); if (!assignByRoot.has(r)) assignByRoot.set(r, new Map()); const m2 = assignByRoot.get(r); const p = m2.get(as.author); if (!p || as.ts >= p.ts) m2.set(as.author, { on: as.on, ts: as.ts }); }
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const ownerNode = nodes.get(root);
const oc = ownerNode ? ownerNode.c : {};
const opinions = { ...(oc.opinions || {}) };
const voters = uniq(oc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
const creator = ownerNode ? ownerNode.author : null;
const asg = new Set(uniq(oc.assignees));
if (creator) asg.add(creator);
for (const [au, st] of (assignByRoot.get(root) || new Map())) { if (st.on) asg.add(au); else if (au !== creator) asg.delete(au); }
const assignees = [...asg];
return { contentTip, contentNode, opinions, voters, assignees };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup };
};
const buildTask = (node, rootId, agg, now) => {
const c = node.c || {};
const opinions = agg ? agg.opinions : (c.opinions || {});
const voters = agg ? agg.voters : (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : []);
const assignees = agg ? agg.assignees : (Array.isArray(c.assignees) ? c.assignees : []);
const status = c.status === 'OPEN' && moment(c.endTime).isBefore(now) ? 'CLOSED' : c.status;
return {
id: node.key,
rootId,
...c,
assignees,
opinions,
opinions_inhabitants: voters,
status
};
};
return {
async createTask(title, description, startTime, endTime, priority, location = '', tagsRaw = [], isPublic) {
const ssb = await openSsb();
const userId = ssb.id;
const start = moment(startTime);
const end = moment(endTime);
if (!start.isValid() || !end.isValid()) throw new Error('Invalid dates');
const nowFloor = moment().startOf('minute');
if (start.isBefore(nowFloor) || end.isBefore(start)) throw new Error('Invalid time range');
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const visibility = normalizeVisibility(isPublic);
const content = {
type: 'task',
title,
description,
startTime: start.toISOString(),
endTime: end.toISOString(),
priority,
location,
tags,
isPublic: visibility,
assignees: [userId],
createdAt: new Date().toISOString(),
status: 'OPEN',
author: userId,
opinions: {},
opinions_inhabitants: []
};
return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async deleteTaskById(taskId) {
const ssb = await openSsb();
const userId = ssb.id;
const task = await new Promise((res, rej) => ssb.get(taskId, (err, task) => err ? rej(new Error('Task not found')) : res(task)));
if (task.content.author !== userId) throw new Error('Not the author');
const tombstone = { type: 'tombstone', target: taskId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
},
async updateTaskById(taskId, updatedData) {
const ssb = await openSsb();
const userId = ssb.id;
const keys = Object.keys(updatedData || {}).filter(k => updatedData[k] !== undefined);
if (keys.length === 1 && keys[0] === 'assignees') return this.toggleAssignee(taskId);
const old = await new Promise((res, rej) =>
ssb.get(taskId, (err, msg) => err || !msg ? rej(new Error('Task not found')) : res(msg))
);
const c = old.content;
if (c.type !== 'task') throw new Error('Invalid type');
const taskCreator = c.author || old.author;
if (taskCreator !== userId) throw new Error('Not the author');
if (c.status === 'CLOSED') throw new Error('Cannot edit a closed task');
const nextAssignees = Array.isArray(c.assignees) ? uniq(c.assignees) : [];
let newStart = c.startTime;
if (updatedData.startTime != null && updatedData.startTime !== '') {
const m = moment(updatedData.startTime);
if (!m.isValid()) throw new Error('Invalid startTime');
newStart = m.toISOString();
}
let newEnd = c.endTime;
if (updatedData.endTime != null && updatedData.endTime !== '') {
const m = moment(updatedData.endTime);
if (!m.isValid()) throw new Error('Invalid endTime');
newEnd = m.toISOString();
}
if (moment(newEnd).isBefore(moment(newStart))) throw new Error('Invalid time range');
let newTags = c.tags || [];
if (updatedData.tags !== undefined) {
if (Array.isArray(updatedData.tags)) newTags = updatedData.tags.filter(Boolean);
else if (typeof updatedData.tags === 'string') newTags = updatedData.tags.split(',').map(t => t.trim()).filter(Boolean);
else newTags = [];
}
let newVisibility = c.isPublic;
if (updatedData.isPublic !== undefined) {
newVisibility = normalizeVisibility(updatedData.isPublic);
}
let newStatus = c.status;
if (updatedData.status !== undefined) {
const normalized = normalizeStatus(updatedData.status, null);
if (!normalized) throw new Error('Invalid status');
newStatus = normalized;
}
const updated = {
...c,
title: updatedData.title ?? c.title,
description: updatedData.description ?? c.description,
startTime: newStart,
endTime: newEnd,
priority: updatedData.priority ?? c.priority,
location: updatedData.location ?? c.location,
tags: newTags,
isPublic: newVisibility,
status: newStatus,
assignees: nextAssignees,
updatedAt: new Date().toISOString(),
replaces: taskId
};
return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
},
async updateTaskStatus(taskId, status) {
const normalized = String(status || '').toUpperCase();
if (!['OPEN', 'IN-PROGRESS', 'CLOSED'].includes(normalized)) throw new Error('Invalid status');
return this.updateTaskById(taskId, { status: normalized });
},
async getTaskById(taskId) {
const ssb = await openSsb();
const now = moment();
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const rootId = idx.rootOf(taskId);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error('Task not found');
return buildTask(agg.contentNode, rootId, agg, now);
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssb = await openSsb();
const userId = ssb.id;
const task = await this.getTaskById(id);
const voters = Array.isArray(task.opinions_inhabitants) ? task.opinions_inhabitants : [];
if (voters.includes(userId)) throw new Error('Already opined');
const content = { type: 'taskOpinion', target: task.rootId, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async toggleAssignee(taskId) {
const ssb = await openSsb();
const userId = ssb.id;
const task = await this.getTaskById(taskId);
if (task.status === 'CLOSED') throw new Error('Cannot assign users to a closed task');
const on = !(Array.isArray(task.assignees) && task.assignees.includes(userId));
const content = { type: 'taskAssign', target: task.rootId, on, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssb.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async listAll() {
const ssb = await openSsb();
const now = moment();
const messages = await getAllMessages(ssb);
const idx = buildIndex(messages);
const tasks = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
tasks.push(buildTask(node, rootId, idx.resolveGroup(rootId), now));
}
return dedupeBy(tasks, t => t.title ? [norm(t.author), norm(t.title), norm(t.startTime)].join('|') : null);
},
async checkDueReminders() {
if (!pmModel) return;
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const now = Date.now();
const sent = new Set();
for (const m of messages) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === 'taskReminderSent' && c.target) { sent.add(c.target); continue; }
}
const idx = buildIndex(messages);
const tasks = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
tasks.push(buildTask(node, rootId, idx.resolveGroup(rootId)));
}
const publishMarker = (target) => new Promise((resolve, reject) => {
ssbClient.publish({ type: 'taskReminderSent', target, sentAt: new Date().toISOString() }, err => err ? reject(err) : resolve());
});
for (const t of tasks) {
if (sent.has(t.id)) continue;
const status = String(t.status || '').toUpperCase();
if (status !== 'OPEN') continue;
const endTime = t.endTime || t.deadline;
if (!endTime) continue;
const endTs = new Date(endTime).getTime();
if (!endTs || endTs > now) continue;
const assignees = Array.isArray(t.assignees) ? t.assignees.filter(a => typeof a === 'string' && a.length > 0) : [];
if (assignees.length === 0) continue;
const subject = `Task Reminder: ${t.title || 'Task'}`;
const text =
`Task: ${t.title || ''}\n` +
(t.description ? `Description: ${t.description}\n` : '') +
`Deadline: ${endTime}\n` +
(t.priority ? `Priority: ${t.priority}\n` : '') +
`\nVisit Task: /tasks/${t.id}`;
try {
const chunkSize = 6;
for (let i = 0; i < assignees.length; i += chunkSize) {
await pmModel.sendMessage(assignees.slice(i, i + chunkSize), subject, text);
}
await publishMarker(t.id);
} catch (_) {}
}
}
};
};

View file

@ -0,0 +1,39 @@
function buildValidatedTombstoneSet(messages) {
if (!Array.isArray(messages)) return new Set();
const tombClaims = new Map();
const targetAuthors = new Map();
const targetRoots = new Map();
for (const m of messages) {
if (!m || !m.value) continue;
const v = m.value;
const c = v.content;
if (!c) continue;
if (typeof c === 'object' && c.type === 'tombstone' && typeof c.target === 'string') {
const ts = v.timestamp || 0;
const prev = tombClaims.get(c.target);
if (!prev || ts > prev.ts) tombClaims.set(c.target, { author: v.author, ts, rootId: c._rootId || null });
continue;
}
if (m.key) {
targetAuthors.set(m.key, v.author);
if (typeof c === 'object' && c._rootId) targetRoots.set(m.key, c._rootId);
}
}
const out = new Set();
for (const [target, { author, rootId }] of tombClaims.entries()) {
if (targetAuthors.get(target) !== author) continue;
if (rootId) {
const targetRoot = targetRoots.get(target);
if (targetRoot !== rootId) continue;
}
out.add(target);
}
return out;
}
function isTombstoneFromAuthor(tombstoneMsg, targetAuthor) {
if (!tombstoneMsg || !tombstoneMsg.value) return false;
return tombstoneMsg.value.author === targetAuthor;
}
module.exports = { buildValidatedTombstoneSet, isTombstoneFromAuthor };

View file

@ -0,0 +1,378 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const { dedupeBy, norm } = require('../backend/dedupe');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\((&[^)]+\.sha256)\)/);
if (match) return match[1];
const fallback = s.match(/\(([^)]+)\)/g);
return fallback ? fallback[fallback.length - 1].slice(1, -1) : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null;
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, 'torrent') : msgs;
const tombFor = async (target, tribeId, author) => tribeHelpers ? tribeHelpers.encryptTombstone(target, tribeId, author) : { type: 'tombstone', target, deletedAt: new Date().toISOString(), author };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve) => {
ssbClient.get(key, (err, msg) => (err ? resolve(null) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = new Set();
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const authorByKey = new Map();
const tombRequests = [];
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone" && c.target) {
tombRequests.push({ target: c.target, author: v.author });
continue;
}
if (c.type === "torrentOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "torrent") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
authorByKey.set(k, v.author);
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
for (const t of tombRequests) {
const targetAuthor = authorByKey.get(t.target);
if (targetAuthor && t.author === targetAuthor) tomb.add(t.target);
}
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildTorrent = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const undec = c.encryptedPayload && c._decrypted === false;
const opinions = agg ? agg.opinions : (c.opinions || {});
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: undec ? "" : c.url,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: safeArr(c.tags),
author: node.author,
title: undec ? "" : (c.title || ""),
description: undec ? "" : (c.description || ""),
size: c.size || 0,
opinions,
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
tribeId: c.tribeId || null,
encrypted: !!undec,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "torrent",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Torrent not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Torrent not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createTorrent(blobMarkdown, tagsRaw, title, description, size, tribeId) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
let content = {
type: "torrent",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
size: Number(size) || 0,
opinions: {},
opinions_inhabitants: [],
...(tribeId ? { tribeId } : {})
};
content = await encryptIfTribe(content);
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateTorrentById(id, blobMarkdown, tagsRaw, title, description) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg) throw new Error("Torrent not found");
const oldDec = await decryptIfTribe(oldMsg.content);
if (!oldDec || oldDec.type !== "torrent") throw new Error("Torrent not found");
assertReadable(oldDec, "Torrent");
if ((oldDec.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
const aggTorrent = await this.getTorrentById(id, userId);
if (aggTorrent && Object.keys(aggTorrent.opinions || {}).some(k => (aggTorrent.opinions[k] || 0) > 0)) throw new Error("Cannot edit torrent after it has received opinions.");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldDec.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
let updated = {
type: "torrent",
replaces: tipId,
url: blobId || oldDec.url,
tags,
title: title !== undefined ? title || "" : oldDec.title || "",
description: description !== undefined ? description || "" : oldDec.description || "",
size: oldDec.size || 0,
opinions: oldDec.opinions || {},
opinions_inhabitants: oldDec.opinions_inhabitants || [],
author: oldDec.author || userId,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
createdAt: oldDec.createdAt,
updatedAt: now
};
updated = await encryptIfTribe(updated);
const result = await new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
});
const tombstone = await tombFor(tipId, oldDec.tribeId || oldMsg.content.tribeId, userId);
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return result;
},
async deleteTorrentById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg) throw new Error("Torrent not found");
const dec = await decryptIfTribe(msg.content);
if (!dec || dec.type !== "torrent") throw new Error("Torrent not found");
if ((dec.author || msg.content.author) !== userId) throw new Error("Not the author");
const tombstone = await tombFor(tipId, dec.tribeId || msg.content.tribeId, userId);
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
await decryptIndexNodes(idx);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildTorrent(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((a) => {
const title = String(a.title || "").toLowerCase();
const desc = String(a.description || "").toLowerCase();
const tags = safeArr(a.tags).join(" ").toLowerCase();
const author = String(a.author || "").toLowerCase();
return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getTorrentById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(unwrapForIndex(messages));
await decryptIndexNodes(idx);
const root = idx.rootOf(id);
const agg = idx.resolveGroup(root);
if (agg.contentNode && !idx.tomb.has(agg.contentTip)) return buildTorrent(agg.contentNode, root, viewer, agg);
const tip = idx.tipByRoot.get(root) || id;
if (idx.tomb.has(tip)) throw new Error("Torrent not found");
const msg = await getMsg(ssbClient, tip);
if (!msg) throw new Error("Torrent not found");
let c = msg.content;
if (tribeCrypto && (c?.encryptedPayload || tribeCrypto.isTribeMsg(c)) && tribesModel) {
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel);
c = dec && !dec._undecryptable ? { ...dec, _decrypted: true } : { ...c, _decrypted: false };
}
if (!c || c.type !== "torrent") throw new Error("Torrent not found");
return buildTorrent({ key: tip, ts: msg.timestamp || 0, c, author: msg.author }, root, viewer, agg);
},
async createOpinion(id, category) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error("Invalid voting category");
const torrent = await this.getTorrentById(id, userId);
if (!torrent) throw new Error("Torrent not found");
if (safeArr(torrent.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "torrentOpinion", target: torrent.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
}
};
};

View file

@ -0,0 +1,430 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const categories = require("../backend/opinion_categories")
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeByPreferring, norm } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const isValidId = (to) => /^@[A-Za-z0-9+/]+={0,2}\.ed25519$/.test(String(to || ""))
const parseNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const CATEGORIES = ["ECONOMIC", "TIME", "TRUST"]
const normalizeCategory = (raw) => {
const c = String(raw || "ECONOMIC").trim().toUpperCase()
return CATEGORIES.includes(c) ? c : "ECONOMIC"
}
module.exports = ({ cooler }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
})
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => err ? reject(err) : resolve(msg))
})
const buildIndex = (messages) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const strictChild = new Map()
const confirms = []
const opinionMsgs = []
const ubiByPub = new Map()
const ubiByUser = new Map()
const ubiClaimNodes = []
for (const m of messages) {
const k = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "tombstone" && c.target) {
tomb.add(c.target)
continue
}
if (c.type === "transferConfirm") { confirms.push({ target: c.target, author: v.author, ts: v.timestamp || 0 }); continue }
if (c.type === "transferOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category, ts: v.timestamp || 0 }); continue }
if (c.type === "transfer") {
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) {
parent.set(k, c.replaces)
child.set(c.replaces, k)
}
const tags = Array.isArray(c.tags) ? c.tags.map(t => String(t).toUpperCase()) : []
if (tags.includes("UBI") && c.to && c.concept) {
const key = `${c.to}::${c.concept}`
if (v.author === c.from) ubiByPub.set(key, k)
else ubiByUser.set(key, k)
}
}
if (c.type === "ubiClaim") {
ubiClaimNodes.push({ k, v, c, ts: v.timestamp || m.timestamp || 0 })
}
}
for (const [key, userMsgKey] of ubiByUser.entries()) {
if (ubiByPub.has(key)) tomb.add(userMsgKey)
}
for (const { k, v, c, ts } of ubiClaimNodes) {
if (tomb.has(k)) continue
const claimantId = v.author
const epochId = c.epochId || ""
const concept = `UBI ${epochId} ${claimantId}`.trim()
const key = `${claimantId}::${concept}`
if (ubiByPub.has(key) || ubiByUser.has(key)) continue
const synthetic = {
type: "transfer",
from: c.pubId || "",
to: claimantId,
concept,
amount: String(c.amount || 0),
createdAt: c.claimedAt || new Date(ts).toISOString(),
updatedAt: c.claimedAt || new Date(ts).toISOString(),
deadline: null,
confirmedBy: [c.pubId || ""].filter(Boolean),
status: "UNCONFIRMED",
tags: ["UBI", "PENDING"],
opinions: {},
opinions_inhabitants: []
}
nodes.set(k, { key: k, ts, c: synthetic, author: claimantId })
}
for (const [k, t] of Array.from(parent.entries())) {
const cn = nodes.get(k)
const pn = nodes.get(t)
if (!pn) { parent.delete(k); if (child.get(t) === k) child.delete(t); continue }
if (!cn || cn.author !== pn.author) { parent.delete(k); if (child.get(t) === k) child.delete(t); nodes.delete(k) }
}
for (const [k, node] of nodes) {
const t = node.c.replaces
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k) }
}
const rootOf = (id) => {
let cur = id, g = 0
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur)
return cur
}
const contentTipOf = (root) => {
let cur = root, g = 0
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur)
const n = nodes.get(cur), rn = nodes.get(root)
return (n && rn && n.author === rn.author) ? cur : root
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const confirmsByRoot = new Map(), opinionsByRoot = new Map()
for (const cf of confirms) { if (!nodes.has(cf.target)) continue; const r = rootOf(cf.target); if (!confirmsByRoot.has(r)) confirmsByRoot.set(r, []); confirmsByRoot.get(r).push(cf) }
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op) }
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, contentTipOf(r))
const resolveGroup = (root) => {
const contentTip = contentTipOf(root)
const best = nodes.get(contentTip) || nodes.get(root)
if (!best) return null
const rootAuthor = nodes.get(root) ? nodes.get(root).author : null
const groupKeys = []; { let x = root, g = 0; groupKeys.push(x); while (child.has(x) && g++ < 100000) { x = child.get(x); groupKeys.push(x) } }
const confirmedBy = new Set()
const opinions = {}; const opinionSet = new Set()
for (const k of groupKeys) {
const n = nodes.get(k); if (!n) continue; if (n.author !== rootAuthor) continue; const c = n.c
for (const cb of (Array.isArray(c.confirmedBy) ? c.confirmedBy : [])) confirmedBy.add(cb)
for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) opinionSet.add(v)
if (c.opinions && typeof c.opinions === "object") for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0)
}
if (best.c.from) confirmedBy.add(best.c.from)
for (const cf of (confirmsByRoot.get(root) || [])) confirmedBy.add(cf.author)
for (const op of (opinionsByRoot.get(root) || [])) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
return { contentTip, best, confirmedBy: [...confirmedBy], opinions, opinions_inhabitants: [...opinionSet], tombstoned: tomb.has(contentTip) }
}
return { tomb, nodes, parent, child, rootOf, tipByRoot, resolveGroup }
}
const deriveStatus = (t) => {
const status = String(t.status || "").toUpperCase()
const required = t.from === t.to ? 1 : 2
const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
if (status === "DISCARDED") return "DISCARDED"
if (confirmedCount >= required) return "CLOSED"
const dl = t.deadline ? moment(t.deadline) : null
if (dl && dl.isValid() && dl.isBefore(moment())) return "DISCARDED"
return status === "CLOSED" ? "CLOSED" : "UNCONFIRMED"
}
const buildTransfer = (node, agg) => {
const c = node.c || {}
const confirmedBy = agg ? agg.confirmedBy : (Array.isArray(c.confirmedBy) ? c.confirmedBy : [])
const opinions = agg ? agg.opinions : (c.opinions || {})
const opinions_inhabitants = agg ? agg.opinions_inhabitants : (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])
return {
id: node.key,
author: node.author,
from: c.from,
to: c.to,
concept: c.concept,
amount: c.amount,
category: normalizeCategory(c.category),
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
deadline: c.deadline,
confirmedBy,
status: deriveStatus({ ...c, confirmedBy }),
tags: Array.isArray(c.tags) ? c.tags : [],
opinions,
opinions_inhabitants
}
}
return {
type: "transfer",
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id
if (idx.tomb.has(tip)) throw new Error("Not found")
return tip
},
async createTransfer(to, concept, amount, deadline, tagsRaw = [], category) {
const ssbClient = await openSsb()
const userId = ssbClient.id
if (!isValidId(to)) throw new Error("Invalid recipient ID")
const num = parseNum(amount)
if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
const dl = moment(deadline, moment.ISO_8601, true)
if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
const tags = normalizeTags(tagsRaw)
const cat = normalizeCategory(category)
const isSelf = to === userId
const now = new Date().toISOString()
const content = {
type: "transfer",
from: userId,
to,
concept: String(concept || ""),
amount: num.toFixed(6),
category: cat,
createdAt: now,
updatedAt: now,
deadline: dl.toISOString(),
confirmedBy: [userId],
status: isSelf ? "CLOSED" : "UNCONFIRMED",
tags,
opinions: {},
opinions_inhabitants: []
}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async updateTransferById(id, to, concept, amount, deadline, tagsRaw = [], category) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const old = await getMsg(ssbClient, tipId)
if (!old?.content || old.content.type !== "transfer") throw new Error("Transfer not found")
const current = old.content
const idxU = buildIndex(await getAllMessages(ssbClient))
const gU = idxU.resolveGroup(idxU.rootOf(tipId))
const aggU = gU ? buildTransfer(gU.best, gU) : buildTransfer({ key: tipId, c: current })
const currentStatus = aggU.status
if (Object.keys(aggU.opinions || {}).some(k => (aggU.opinions[k] || 0) > 0)) throw new Error("Cannot edit transfer after it has received opinions.")
if (current.from !== userId) throw new Error("Not the author")
if (currentStatus !== "UNCONFIRMED") throw new Error("Can only edit unconfirmed")
const dlOld = current.deadline ? moment(current.deadline) : null
if (dlOld && dlOld.isValid() && dlOld.isBefore(moment())) throw new Error("Cannot edit expired")
if (!isValidId(to)) throw new Error("Invalid recipient ID")
const num = parseNum(amount)
if (!Number.isFinite(num) || num <= 0) throw new Error("Amount must be positive")
const dl = moment(deadline, moment.ISO_8601, true)
if (!dl.isValid() || dl.isBefore(moment())) throw new Error("Deadline must be in the future")
const tags = normalizeTags(tagsRaw)
const cat = normalizeCategory(category !== undefined ? category : current.category)
const isSelf = to === userId
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((res, rej) => ssbClient.publish(tombstone, (err) => err ? rej(err) : res()))
const updated = {
type: "transfer",
from: userId,
to,
concept: String(concept || ""),
amount: num.toFixed(6),
category: cat,
createdAt: current.createdAt,
deadline: dl.toISOString(),
confirmedBy: [userId],
status: isSelf ? "CLOSED" : "UNCONFIRMED",
tags,
opinions: {},
opinions_inhabitants: [],
updatedAt: new Date().toISOString(),
replaces: tipId
}
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, msg) => err ? reject(err) : resolve(msg))
})
},
async confirmTransferById(id) {
const ssbClient = await openSsb()
const userId = ssbClient.id
let tipId
try { tipId = await this.resolveCurrentId(id) } catch (_) { tipId = id }
const msg = await getMsg(ssbClient, tipId)
if (!msg?.content) throw new Error("Not found")
if (msg.content.type !== "transfer") throw new Error("Not found")
const idx = buildIndex(await getAllMessages(ssbClient))
const root = idx.rootOf(tipId)
const g = idx.resolveGroup(root)
const t = g ? buildTransfer(g.best, g) : buildTransfer({ key: tipId, c: msg.content })
if (t.status !== "UNCONFIRMED") throw new Error("Not unconfirmed")
if (t.to !== userId) throw new Error("Not the recipient")
const dl = t.deadline ? moment(t.deadline) : null
if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Expired")
if ((Array.isArray(t.confirmedBy) ? t.confirmedBy : []).includes(userId)) throw new Error("Already confirmed")
const content = { type: "transferConfirm", target: root, createdAt: new Date().toISOString() }
return new Promise((resolve, reject) => {
ssbClient.publish(content, (e2, result) => e2 ? reject(e2) : resolve(result))
})
},
async deleteTransferById(id) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const tipId = await this.resolveCurrentId(id)
const msg = await getMsg(ssbClient, tipId)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
const idxD = buildIndex(await getAllMessages(ssbClient))
const gD = idxD.resolveGroup(idxD.rootOf(tipId))
const t = gD ? buildTransfer(gD.best, gD) : buildTransfer({ key: tipId, c: msg.content })
const st = t.status
const confirmedCount = Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0
const required = t.from === t.to ? 1 : 2
if (t.from !== userId) throw new Error("Not the author")
if (st !== "UNCONFIRMED") throw new Error("Not editable")
if (confirmedCount >= required) throw new Error("Not editable")
const dl = t.deadline ? moment(t.deadline) : null
if (dl && dl.isValid() && dl.isBefore(moment())) throw new Error("Cannot delete expired")
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err) => err ? reject(err) : resolve())
})
},
async listAll(filter = "all") {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
const out = []
for (const root of idx.tipByRoot.keys()) {
const g = idx.resolveGroup(root)
if (!g || g.tombstoned) continue
out.push(buildTransfer(g.best, g))
}
return dedupeByPreferring(out, (t) => (t.author && t.createdAt) ? norm(t.author) + "|" + norm(t.createdAt) : null, (t) => (Array.isArray(t.confirmedBy) ? t.confirmedBy.length : 0))
},
async getTransferById(id) {
const ssbClient = await openSsb()
const messages = await getAllMessages(ssbClient)
const idx = buildIndex(messages)
const root = idx.rootOf(id)
const g = idx.resolveGroup(root)
if (g && !g.tombstoned) return buildTransfer(g.best, g)
if (g && g.tombstoned) throw new Error("Not found")
const msg = await getMsg(ssbClient, id)
if (!msg?.content || msg.content.type !== "transfer") throw new Error("Not found")
const tmpNode = { key: id, ts: msg.timestamp || 0, c: msg.content, author: msg.author }
return buildTransfer(tmpNode)
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const idx = buildIndex(await getAllMessages(ssbClient))
const root = idx.rootOf(id)
const g = idx.resolveGroup(root)
if (!g || g.tombstoned) throw new Error("Transfer not found")
if ((g.opinions_inhabitants || []).includes(userId)) throw new Error("Already voted")
const content = { type: "transferOpinion", target: root, category, createdAt: new Date().toISOString() }
return new Promise((resolve, reject) => {
ssbClient.publish(content, (e2, result) => e2 ? reject(e2) : resolve(result))
})
}
}
}

View file

@ -0,0 +1,226 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const opinionCategories = require('../backend/opinion_categories');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { buildVoteTally } = require('../backend/vote_tally');
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const hasBlob = async (ssbClient, url) => {
return new Promise(resolve => {
ssbClient.blobs.has(url, (err, has) => resolve(!err && has));
});
};
const types = [
'bookmark', 'votes', 'feed',
'image', 'audio', 'video', 'document', 'torrent', 'transfer'
];
const categories = opinionCategories;
const listTrending = async (filter = 'ALL') => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, xs) => err ? rej(err) : res(xs))
);
});
const tombstoned = buildValidatedTombstoneSet(messages);
const replaces = new Map();
const itemsById = new Map();
for (const m of messages) {
const k = m.key;
const c = m.value?.content;
if (!c) continue;
if (c.type === 'tombstone' && c.target) {
tombstoned.add(c.target);
itemsById.delete(c.target);
continue;
}
if (c.opinions && !tombstoned.has(k) && !['task', 'event', 'report'].includes(c.type)) {
if (c.replaces) replaces.set(c.replaces, k);
itemsById.set(k, m);
}
if (c.type === 'feed' && !tombstoned.has(k) && !itemsById.has(k)) {
if (c.replaces) replaces.set(c.replaces, k);
itemsById.set(k, m);
}
}
for (const [replacedId, newId] of replaces.entries()) {
const oldMsg = itemsById.get(replacedId);
const newMsg = itemsById.get(newId);
if (!oldMsg) continue;
if (!newMsg || String(newMsg.value?.author) !== String(oldMsg.value?.author)) {
itemsById.delete(newId);
continue;
}
itemsById.delete(replacedId);
}
let rawItems = Array.from(itemsById.values()).filter(m => types.includes(m.value?.content?.type));
const blobTypes = ['document', 'image', 'audio', 'video'];
let items = await Promise.all(
rawItems.map(async m => {
const c = m.value?.content;
if (blobTypes.includes(c.type) && c.url) {
const valid = await hasBlob(ssbClient, c.url);
if (!valid) return null;
}
return m;
})
);
items = items.filter(Boolean);
const signatureOf = (m) => {
const c = m.value?.content || {};
switch (c.type) {
case 'document':
case 'image':
case 'audio':
case 'video':
return `${c.type}::${(c.url || '').trim()}`;
case 'bookmark':
return `bookmark::${(c.url || '').trim().toLowerCase()}`;
case 'feed':
return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
case 'votes':
return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
case 'transfer':
return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
default:
return `key::${m.key}`;
}
};
const voteTally = buildVoteTally(messages);
const votesPrev = new Map();
for (const m of messages) {
const c = m.value?.content;
if (c && c.type === 'votes' && typeof c.replaces === 'string') votesPrev.set(m.key, c.replaces);
}
const resolveTallyKey = (k) => {
let cur = k;
let g = 0;
while (g++ < 1000 && !voteTally.has(cur) && votesPrev.has(cur)) cur = votesPrev.get(cur);
return cur;
};
items = items.map(m => {
if (m.value?.content?.type !== 'votes') return m;
const t = voteTally.get(resolveTallyKey(m.key));
return t ? { ...m, value: { ...m.value, content: { ...m.value.content, ...t } } } : m;
});
const bySig = new Map();
for (const m of items) {
const sig = signatureOf(m);
const prev = bySig.get(sig);
if (!prev || (m.value?.timestamp || 0) > (prev.value?.timestamp || 0)) {
bySig.set(sig, m);
}
}
items = Array.from(bySig.values());
if (filter === 'MINE') {
items = items.filter(m => m.value.author === userId);
} else if (filter === 'RECENT') {
const now = Date.now();
items = items.filter(m => now - m.value.timestamp < 24 * 60 * 60 * 1000);
}
if (types.includes(filter)) {
items = items.filter(m => m.value.content.type === filter);
}
if (filter !== 'ALL' && !types.includes(filter)) {
items = items.filter(m => (m.value.content.opinions_inhabitants || []).length > 0);
}
if (filter === 'TOP') {
items.sort((a, b) => {
const aLen = (a.value.content.opinions_inhabitants || []).length;
const bLen = (b.value.content.opinions_inhabitants || []).length;
if (bLen !== aLen) return bLen - aLen;
return b.value.timestamp - a.value.timestamp;
});
} else {
items.sort((a, b) => {
const aLen = (a.value.content.opinions_inhabitants || []).length;
const bLen = (b.value.content.opinions_inhabitants || []).length;
return bLen - aLen;
});
}
return { filtered: items };
};
const getMessageById = async (id) => {
const ssbClient = await openSsb();
return new Promise((res, rej) => {
ssbClient.get(id, (err, msg) => err ? rej(err) : res(msg));
});
};
const createVote = async (contentId, category) => {
const ssbClient = await openSsb();
const userId = ssbClient.id;
if (!categories.includes(category)) throw new Error('Invalid voting category');
const msg = await getMessageById(contentId);
if (!msg || !msg.content) throw new Error('Content not found');
const type = msg.content.type;
if (!types.includes(type) || ['task', 'event', 'report'].includes(type)) {
throw new Error('Voting not allowed on this content type');
}
const inhabitants = Array.isArray(msg.content.opinions_inhabitants) ? msg.content.opinions_inhabitants : [];
if (inhabitants.includes(userId)) throw new Error('Already voted');
const tombstone = {
type: 'tombstone',
target: contentId,
deletedAt: new Date().toISOString(),
author: userId
};
const updated = {
...msg.content,
opinions: {
...(msg.content.opinions || {}),
[category]: ((msg.content.opinions && msg.content.opinions[category]) || 0) + 1
},
opinions_inhabitants: inhabitants.concat(userId),
updatedAt: new Date().toISOString(),
replaces: contentId
};
await new Promise((res, rej) => {
ssbClient.publish(tombstone, err => err ? rej(err) : res());
});
return new Promise((res, rej) => {
ssbClient.publish(updated, (err, result) => err ? rej(err) : res(result));
});
};
return { listTrending, getMessageById, createVote, types, categories };
};

View file

@ -0,0 +1 @@
module.exports = (configPath) => require('./crypto')(configPath, 'tribes');

View file

@ -0,0 +1,362 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
const categories = require('../backend/opinion_categories');
const VALID_STATUSES = ['OPEN', 'CLOSED', 'IN-PROGRESS'];
const VALID_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const readLog = async () => {
const client = await openSsb();
return new Promise((resolve, reject) =>
pull(
client.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
);
};
const fingerprintsForRoot = (rootId) => {
const fps = new Set();
for (const k of tribeCrypto.getKeys(rootId)) fps.add(tribeCrypto.fingerprint(k));
return fps;
};
const wrapAndPublishContent = async (rootId, body) => {
const client = await openSsb();
const key = tribeCrypto.getKey(rootId);
if (!key) throw new Error('Missing tribe key for ' + rootId);
const envelope = tribeCrypto.wrapMsg(body, key);
return new Promise((resolve, reject) =>
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
);
};
const decodeContentMsgs = async (msgs, opts) => {
const fpIdx = tribeCrypto.buildFingerprintIndex();
const targetRootId = opts && opts.rootId ? opts.rootId : null;
const wantType = opts && opts.contentType ? opts.contentType : null;
const allowedFps = targetRootId ? fingerprintsForRoot(targetRootId) : null;
const content = new Map();
const tombRequests = [];
const collabMsgs = [];
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c) continue;
if (!tribeCrypto.isTribeMsg(c)) continue;
if (allowedFps && !allowedFps.has(c.fp)) continue;
const r = tribeCrypto.unwrapMsg(c, fpIdx);
if (!r || !r.body) continue;
const b = r.body;
if (b.k === 'tombstone') {
tombRequests.push({ target: b.target, author: m.value.author });
continue;
}
if (b.k === 'tc-collab') {
collabMsgs.push({ sub: b.sub, target: b.target, author: m.value.author, ts: m.value.timestamp || 0, option: b.option, on: b.on, category: b.category });
continue;
}
if (b.k !== 'tribe-content') continue;
content.set(m.key, { author: m.value.author, body: b, ts: m.value.timestamp });
}
const naiveNext = new Map();
const strictNext = new Map();
for (const [key, node] of content) {
const t = node.body.replaces;
if (!t) continue;
naiveNext.set(t, key);
const orig = content.get(t);
if (orig && orig.author === node.author) strictNext.set(t, key);
}
for (const [t, key] of Array.from(naiveNext.entries())) {
const orig = content.get(t);
if (!orig) { naiveNext.delete(t); continue; }
const node = content.get(key);
if (!node || node.author !== orig.author) { naiveNext.delete(t); content.delete(key); }
}
const naivePrev = new Map();
for (const [t, key] of naiveNext) naivePrev.set(key, t);
const followTo = (start, nextMap) => { let x = start, g = 0; while (nextMap.has(x) && g++ < 100000) x = nextMap.get(x); return x; };
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && content.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; };
const roots = new Set();
for (const [key, node] of content) { const t = node.body.replaces; if (!t || !content.has(t)) roots.add(key); }
const tombstoned = new Set();
for (const t of tombRequests) { const orig = content.get(t.target); if (orig && t.author === orig.author) tombstoned.add(t.target); }
const collabByRoot = new Map();
const getCB = (root) => { if (!collabByRoot.has(root)) collabByRoot.set(root, { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() }); return collabByRoot.get(root); };
for (const cm of collabMsgs) {
if (!content.has(cm.target)) continue;
const a = getCB(rootOf(cm.target));
if (cm.sub === 'vote' && Number.isInteger(cm.option)) { if (!a.vote.has(cm.author)) a.vote.set(cm.author, cm.option); }
else if (cm.sub === 'attend') { const p = a.attend.get(cm.author); if (!p || cm.ts >= p.ts) a.attend.set(cm.author, { on: cm.on !== false, ts: cm.ts }); }
else if (cm.sub === 'assign') { const p = a.assign.get(cm.author); if (!p || cm.ts >= p.ts) a.assign.set(cm.author, { on: cm.on !== false, ts: cm.ts }); }
else if (cm.sub === 'refeed') { a.refeed.add(cm.author); }
else if (cm.sub === 'opinion' && cm.category) { if (!a.opinion.has(cm.author)) a.opinion.set(cm.author, cm.category); }
}
const items = new Map();
const tipOf = new Map();
const EMPTY = { vote: new Map(), attend: new Map(), assign: new Map(), refeed: new Set(), opinion: new Map() };
for (const root of roots) {
const contentTip = followTo(root, strictNext);
const tipNode = content.get(contentTip);
if (!tipNode) continue;
const body = tipNode.body;
const lb = tipNode.body;
const a = collabByRoot.get(root) || EMPTY;
const item = { id: contentTip, ...body, author: tipNode.author, _ts: tipNode.ts };
const voteAuthors = new Set(); const votes = {};
const legacyVotes = lb.votes && typeof lb.votes === 'object' ? lb.votes : {};
for (const k of Object.keys(legacyVotes)) { const arr = Array.isArray(legacyVotes[k]) ? legacyVotes[k] : []; for (const au of arr) if (!voteAuthors.has(au)) { voteAuthors.add(au); (votes[k] = votes[k] || []).push(au); } }
for (const [au, opt] of a.vote) if (!voteAuthors.has(au)) { voteAuthors.add(au); const k = String(opt); (votes[k] = votes[k] || []).push(au); }
item.votes = votes;
const att = new Set(Array.isArray(lb.attendees) ? lb.attendees : []);
for (const [au, st] of a.attend) { if (st.on) att.add(au); else att.delete(au); }
item.attendees = [...att];
const asg = new Set(Array.isArray(lb.assignees) ? lb.assignees : []);
for (const [au, st] of a.assign) { if (st.on) asg.add(au); else asg.delete(au); }
item.assignees = [...asg];
const rf = new Set(Array.isArray(lb.refeeds_inhabitants) ? lb.refeeds_inhabitants : []);
for (const au of a.refeed) rf.add(au);
item.refeeds_inhabitants = [...rf]; item.refeeds = rf.size;
const opInh = new Set(Array.isArray(lb.opinions_inhabitants) ? lb.opinions_inhabitants : []);
const opinions = { ...(lb.opinions && typeof lb.opinions === 'object' ? lb.opinions : {}) };
for (const [au, cat] of a.opinion) if (!opInh.has(au)) { opInh.add(au); opinions[cat] = (opinions[cat] || 0) + 1; }
item.opinions_inhabitants = [...opInh]; item.opinions = opinions;
let cur = root, g = 0; tipOf.set(root, contentTip);
while (naiveNext.has(cur) && g++ < 100000) { cur = naiveNext.get(cur); tipOf.set(cur, contentTip); }
if (tombstoned.has(contentTip)) continue;
if (targetRootId && body.rootId !== targetRootId) continue;
if (wantType && body.contentType !== wantType) continue;
items.set(contentTip, item);
}
return { items, tipOf, tombstoned };
};
return {
async create(tribeId, contentType, data) {
if (!VALID_CONTENT_TYPES.includes(contentType)) throw new Error('Invalid content type');
if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
const client = await openSsb();
const rootId = await tribesModel.getRootId(tribeId);
const now = new Date().toISOString();
const body = {
k: 'tribe-content',
rootId,
contentType,
replaces: null,
title: data.title || '',
description: data.description || '',
status: data.status || 'OPEN',
date: data.date || null,
location: data.location || null,
price: data.price || null,
salary: data.salary || null,
priority: data.priority || null,
assignees: data.assignees || [],
options: data.options || [],
votes: data.votes || {},
category: data.category || null,
parentId: data.parentId || null,
tags: data.tags || [],
image: data.image || null,
mediaType: data.mediaType || null,
url: data.url || null,
attendees: data.attendees || [],
deadline: data.deadline || null,
goal: data.goal || null,
funded: data.funded || 0,
refeeds: data.refeeds || 0,
refeeds_inhabitants: data.refeeds_inhabitants || [],
opinions: data.opinions || {},
opinions_inhabitants: data.opinions_inhabitants || [],
author: client.id,
createdAt: now,
updatedAt: now
};
return wrapAndPublishContent(rootId, body);
},
async update(contentId, data, existing) {
if (!existing) existing = await this.getById(contentId);
if (!existing) throw new Error('Content not found');
if (data.status && !VALID_STATUSES.includes(data.status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
if (data.priority && !VALID_PRIORITIES.includes(data.priority)) throw new Error('Invalid priority. Must be LOW, MEDIUM, HIGH, or CRITICAL');
const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId));
const now = new Date().toISOString();
const body = {
k: 'tribe-content',
rootId,
contentType: existing.contentType,
replaces: contentId,
title: data.title !== undefined ? data.title : existing.title,
description: data.description !== undefined ? data.description : existing.description,
status: data.status !== undefined ? data.status : existing.status,
date: data.date !== undefined ? data.date : existing.date,
location: data.location !== undefined ? data.location : existing.location,
price: data.price !== undefined ? data.price : existing.price,
salary: data.salary !== undefined ? data.salary : existing.salary,
priority: data.priority !== undefined ? data.priority : existing.priority,
assignees: data.assignees !== undefined ? data.assignees : existing.assignees,
options: data.options !== undefined ? data.options : existing.options,
votes: data.votes !== undefined ? data.votes : existing.votes,
category: data.category !== undefined ? data.category : existing.category,
parentId: data.parentId !== undefined ? data.parentId : existing.parentId,
tags: data.tags !== undefined ? data.tags : existing.tags,
image: data.image !== undefined ? data.image : existing.image,
mediaType: data.mediaType !== undefined ? data.mediaType : existing.mediaType,
url: data.url !== undefined ? data.url : existing.url,
attendees: data.attendees !== undefined ? data.attendees : existing.attendees,
deadline: data.deadline !== undefined ? data.deadline : existing.deadline,
goal: data.goal !== undefined ? data.goal : existing.goal,
funded: data.funded !== undefined ? data.funded : existing.funded,
refeeds: data.refeeds !== undefined ? data.refeeds : existing.refeeds,
refeeds_inhabitants: data.refeeds_inhabitants !== undefined ? data.refeeds_inhabitants : existing.refeeds_inhabitants,
opinions: data.opinions !== undefined ? data.opinions : existing.opinions,
opinions_inhabitants: data.opinions_inhabitants !== undefined ? data.opinions_inhabitants : existing.opinions_inhabitants,
author: existing.author,
createdAt: existing.createdAt,
updatedAt: now
};
return wrapAndPublishContent(rootId, body);
},
async deleteById(contentId) {
const existing = await this.getById(contentId);
if (!existing) throw new Error('Content not found');
const rootId = existing.rootId || (await tribesModel.getRootId(existing.tribeId || existing.rootId));
const client = await openSsb();
const body = {
k: 'tombstone',
rootId,
target: contentId,
author: client.id,
deletedAt: new Date().toISOString()
};
return wrapAndPublishContent(rootId, body);
},
async getById(contentId) {
const msgs = await readLog();
const { items, tipOf } = await decodeContentMsgs(msgs, {});
const tip = tipOf.get(contentId) || contentId;
return items.get(tip) || null;
},
async listByTribe(tribeId, contentType, filter) {
const rootId = await tribesModel.getRootId(tribeId).catch(() => tribeId);
const msgs = await readLog();
const { items } = await decodeContentMsgs(msgs, { rootId, contentType });
let result = [...items.values()];
if (filter === 'open') result = result.filter(i => i.status === 'OPEN');
else if (filter === 'closed') result = result.filter(i => i.status === 'CLOSED');
else if (filter === 'in-progress') result = result.filter(i => i.status === 'IN-PROGRESS');
return result.sort((a, b) => {
const ta = Date.parse(a.updatedAt || a.createdAt) || a._ts || 0;
const tb = Date.parse(b.updatedAt || b.createdAt) || b._ts || 0;
return tb - ta;
});
},
async toggleAttendee(contentId) {
const client = await openSsb();
const userId = client.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const on = !(Array.isArray(item.attendees) && item.attendees.includes(userId));
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'attend', rootId: item.rootId, target: item.id, on });
},
async toggleAssignee(contentId) {
const client = await openSsb();
const userId = client.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
const on = !(Array.isArray(item.assignees) && item.assignees.includes(userId));
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'assign', rootId: item.rootId, target: item.id, on });
},
async updateStatus(contentId, status) {
if (!VALID_STATUSES.includes(status)) throw new Error('Invalid status. Must be OPEN, CLOSED, or IN-PROGRESS');
return this.update(contentId, { status });
},
async castVote(votationId, optionIndex) {
const client = await openSsb();
const userId = client.id;
const item = await this.getById(votationId);
if (!item) throw new Error('Votation not found');
if (item.status === 'CLOSED') throw new Error('Votation is closed');
const options = item.options || [];
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
throw new Error('Invalid option index');
}
const votes = item.votes || {};
for (const key of Object.keys(votes)) {
const arr = Array.isArray(votes[key]) ? votes[key] : [];
if (arr.includes(userId)) throw new Error('Already voted');
}
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'vote', rootId: item.rootId, target: item.id, option: optionIndex });
},
async toggleRefeed(contentId) {
const client = await openSsb();
const userId = client.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
if (Array.isArray(item.refeeds_inhabitants) && item.refeeds_inhabitants.includes(userId)) return item;
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'refeed', rootId: item.rootId, target: item.id });
},
async castOpinion(contentId, category) {
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const client = await openSsb();
const userId = client.id;
const item = await this.getById(contentId);
if (!item) throw new Error('Content not found');
if (Array.isArray(item.opinions_inhabitants) && item.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
return wrapAndPublishContent(item.rootId, { k: 'tc-collab', sub: 'opinion', rootId: item.rootId, target: item.id, category });
},
async getThread(forumId) {
const msgs = await readLog();
const { items } = await decodeContentMsgs(msgs, {});
const all = [...items.values()];
const parent = all.find(i => i.id === forumId);
if (!parent) return { parent: null, replies: [] };
const replies = all
.filter(i => i.parentId === forumId && i.contentType === 'forum-reply')
.sort((a, b) => (Date.parse(a.createdAt) || 0) - (Date.parse(b.createdAt) || 0));
return { parent, replies };
}
};
};

939
src/models/tribes_model.js Normal file
View file

@ -0,0 +1,939 @@
const pull = require('../server/node_modules/pull-stream');
const crypto = require('crypto');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const INVITE_CODE_BYTES = 16;
const VALID_INVITE_MODES = ['strict', 'open'];
const STRUCTURAL_FIELDS = ['title', 'description', 'image', 'location', 'tags', 'isAnonymous', 'inviteMode', 'status', 'parentTribeId', 'mapUrl'];
module.exports = ({ cooler, tribeCrypto }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
let tribeIndex = null;
let tribeIndexTs = 0;
const arraysEqual = (a, b) => {
const aa = Array.isArray(a) ? a : [];
const bb = Array.isArray(b) ? b : [];
if (aa.length !== bb.length) return false;
for (let i = 0; i < aa.length; i++) if (aa[i] !== bb[i]) return false;
return true;
};
const validMembershipDelta = (prev, next, author) => {
const a = Array.isArray(prev) ? prev : [];
const b = Array.isArray(next) ? next : [];
const added = b.filter(m => !a.includes(m));
const removed = a.filter(m => !b.includes(m));
if (added.length === 0 && removed.length === 0) return true;
if (added.length === 1 && removed.length === 0 && added[0] === author) return true;
if (removed.length === 1 && added.length === 0 && removed[0] === author) return true;
return false;
};
const validInvitesDelta = (prev, next, author, rootAuthor) => {
if (author === rootAuthor) return true;
const prevHashes = new Set((prev || []).map(i => i && i.codeHash).filter(Boolean));
const nextHashes = new Set((next || []).map(i => i && i.codeHash).filter(Boolean));
for (const h of nextHashes) if (!prevHashes.has(h)) return false;
return true;
};
const structuralFieldsEqual = (a, b) => {
for (const f of STRUCTURAL_FIELDS) {
const x = a[f];
const y = b[f];
if (Array.isArray(x) || Array.isArray(y)) { if (!arraysEqual(x, y)) return false; continue; }
if (x !== y && !(x == null && y == null)) return false;
}
return true;
};
const streamLog = async () => {
const client = await openSsb();
return new Promise((resolve, reject) => {
pull(
client.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
);
});
};
const buildTribeIndex = async () => {
subscribeInvalidation().catch(() => {});
if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
const fpIdx = tribeCrypto.buildFingerprintIndex();
const msgs = await streamLog();
const tribeMsgs = new Map();
const tombstones = new Map();
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c) continue;
const author = m.value.author;
const ts = m.value.timestamp;
let body = null;
if (tribeCrypto.isTribeMsg(c)) {
const r = tribeCrypto.unwrapMsg(c, fpIdx);
if (!r || !r.body || !r.body.k) continue;
body = r.body;
} else if (c.type === 'tribe' && typeof c === 'object') {
body = {
k: 'tribe',
op: c.op || (c.replaces ? 'update' : 'create'),
rootId: c.rootId || null,
replaces: c.replaces || null,
title: c.title,
description: c.description,
image: c.image,
location: c.location,
tags: c.tags,
isAnonymous: c.isAnonymous,
members: c.members,
invites: c.invites,
inviteMode: c.inviteMode,
status: c.status,
parentTribeId: c.parentTribeId,
mapUrl: c.mapUrl,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
author: c.author
};
} else if (c.type === 'tombstone' && c.target) {
tombstones.set(c.target, { author, ts });
continue;
} else {
continue;
}
if (body.k === 'tombstone' && body.target) {
tombstones.set(body.target, { author, ts });
continue;
}
if (body.k === 'tribe') {
tribeMsgs.set(m.key, {
id: m.key,
rootId: body.rootId || null,
replaces: body.replaces || null,
op: body.op || (body.rootId ? 'update' : 'create'),
content: body,
author,
_ts: ts
});
}
}
const tribes = new Map();
const parent = new Map();
const child = new Map();
const rootByTip = new Map();
for (const [k, entry] of tribeMsgs.entries()) {
if (!entry.replaces) {
tribes.set(k, entry);
rootByTip.set(k, k);
}
}
let progress = true;
while (progress) {
progress = false;
const candidatesByReplaces = new Map();
for (const [k, entry] of tribeMsgs.entries()) {
if (tribes.has(k)) continue;
const replaces = entry.replaces;
if (!replaces) continue;
const parentEntry = tribes.get(replaces);
if (!parentEntry) continue;
if (child.has(replaces)) continue;
const root = rootByTip.get(replaces);
const rootEntry = tribes.get(root);
const rootAuthor = rootEntry && rootEntry.author;
const isRootAuthor = entry.author === rootAuthor;
const prevMembers = Array.isArray(parentEntry.content.members) ? parentEntry.content.members : [];
if (!isRootAuthor) {
if (!prevMembers.includes(entry.author) && !(entry.content.members || []).includes(entry.author)) continue;
if (!validMembershipDelta(prevMembers, entry.content.members, entry.author)) continue;
if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
}
if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root });
}
for (const [replaces, candidates] of candidatesByReplaces.entries()) {
if (child.has(replaces)) continue;
let winner = candidates[0];
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i];
if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; }
if (winner.isRootAuthor && !c.isRootAuthor) continue;
const wt = winner.entry._ts || 0;
const ct = c.entry._ts || 0;
if (ct < wt) winner = c;
else if (ct === wt && c.k < winner.k) winner = c;
}
parent.set(winner.k, replaces);
child.set(replaces, winner.k);
tribes.set(winner.k, winner.entry);
rootByTip.set(winner.k, winner.root);
progress = true;
}
}
const tombstoned = new Set();
for (const [target, t] of tombstones.entries()) {
const e = tribes.get(target);
if (!e) continue;
const root = rootByTip.get(target);
const rootAuthor = tribes.get(root) && tribes.get(root).author;
if (t.author === rootAuthor) tombstoned.add(target);
}
const rootOf = (id) => rootByTip.get(id) || id;
const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
const tipByRoot = new Map();
for (const k of tribes.keys()) {
const root = rootOf(k);
const tip = tipOf(root);
tipByRoot.set(root, tip);
}
const effectivelyTombstoned = new Set(tombstoned);
let cascade = true;
const seen = new Set();
while (cascade) {
cascade = false;
for (const k of tribes.keys()) {
if (effectivelyTombstoned.has(k)) continue;
const root = rootOf(k);
if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascade = true; continue; }
const e = tribes.get(k);
const pid = e && e.content && e.content.parentTribeId;
if (!pid) continue;
if (seen.has(`${k}:${pid}`)) continue;
seen.add(`${k}:${pid}`);
const parentRoot = rootOf(pid);
if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) {
effectivelyTombstoned.add(k);
cascade = true;
}
}
}
tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip };
tribeIndexTs = Date.now();
return tribeIndex;
};
const subscribeInvalidation = (() => {
let started = false;
return async () => {
if (started) return;
started = true;
try {
const client = await openSsb();
pull(
client.createLogStream({ live: true, old: false }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c) return;
if (typeof c === 'string' && c.endsWith('.box')) { tribeIndex = null; return; }
if (tribeCrypto.isTribeMsg(c)) { tribeIndex = null; return; }
if (c.type === 'tribe-invite-msg' || c.type === 'tribe-invite-tombstone') tribeIndex = null;
}, () => { started = false; })
);
} catch (_) { started = false; }
};
})();
const normalizeTribe = (entry) => {
const c = entry.content;
return {
id: entry.id,
title: c.title || '',
description: c.description || '',
image: c.image || null,
location: c.location || null,
tags: Array.isArray(c.tags) ? c.tags : [],
isAnonymous: c.isAnonymous !== false,
members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [],
inviteMode: c.inviteMode || 'strict',
status: c.status || 'OPEN',
parentTribeId: c.parentTribeId || null,
mapUrl: c.mapUrl || '',
createdAt: c.createdAt,
updatedAt: c.updatedAt,
author: c.author || entry.author,
_ts: entry._ts
};
};
const wrapAndPublish = async (rootId, body) => {
const client = await openSsb();
const key = tribeCrypto.getKey(rootId);
if (!key) throw new Error('Missing tribe key for ' + rootId);
const envelope = tribeCrypto.wrapMsg(body, key);
const result = await new Promise((resolve, reject) =>
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
);
tribeIndex = null;
return result;
};
const scanOpenInviteMarkers = async () => {
const msgs = await streamLog();
const tombstoned = new Set();
for (const m of msgs) {
const c = m.value && m.value.content;
if (!c) continue;
if (c.type === 'tribe-open-invite-tombstone' && typeof c.target === 'string') tombstoned.add(c.target);
if (c.type === 'tribe-invite-tombstone' && typeof c.target === 'string') tombstoned.add('inv:' + c.target);
}
const byRoot = new Map();
for (const m of msgs) {
const v = m.value;
const c = v && v.content;
if (!c || c.type !== 'tribe-open-invite' || c.v !== 1) continue;
if (typeof c.rootId !== 'string' || typeof c.code !== 'string') continue;
if (tombstoned.has(m.key)) continue;
if (c.inviteKey && tombstoned.has('inv:' + c.inviteKey)) continue;
const ts = (v && v.timestamp) || 0;
const prev = byRoot.get(c.rootId);
if (!prev || ts > prev.ts) byRoot.set(c.rootId, { code: c.code, by: c.by || v.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts });
}
return byRoot;
};
return {
type: 'tribe',
async createTribe(title, description, image, location, tagsRaw = [], isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
if (!VALID_INVITE_MODES.includes(inviteMode)) throw new Error('Invalid invite mode. Must be "strict" or "open"');
const client = await openSsb();
const userId = client.id;
const blobId = image ? (String(image).trim() || null) : null;
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const isPrivate = Boolean(isAnonymous);
const newKey = tribeCrypto.generateTribeKey();
const now = new Date().toISOString();
const baseFields = {
title,
description,
image: blobId,
location,
tags,
isAnonymous: isPrivate,
members: [userId],
invites: [],
inviteMode,
status: status || 'OPEN',
parentTribeId: parentTribeId || null,
mapUrl: String(mapUrl || '').trim(),
createdAt: now,
updatedAt: now,
author: userId
};
let envelope;
if (isPrivate) {
envelope = tribeCrypto.wrapMsg({ k: 'tribe', op: 'create', rootId: null, replaces: null, ...baseFields }, newKey);
} else {
envelope = { type: 'tribe', op: 'create', ...baseFields };
}
const result = await new Promise((resolve, reject) =>
client.publish(envelope, (err, r) => err ? reject(err) : resolve(r))
);
tribeCrypto.setKey(result.key, newKey, 1);
tribeIndex = null;
subscribeInvalidation().catch(() => {});
return result;
},
async getRootId(tribeId) {
const idx = await buildTribeIndex();
let cur = tribeId;
while (idx.parent.has(cur)) cur = idx.parent.get(cur);
return cur;
},
async getChainIds(tribeId) {
const idx = await buildTribeIndex();
let root = tribeId;
while (idx.parent.has(root)) root = idx.parent.get(root);
const ids = [root];
let cur = root;
while (idx.child.has(cur)) { cur = idx.child.get(cur); ids.push(cur); }
return ids;
},
async getAncestryChain(tribeId) {
const rootId = await this.getRootId(tribeId);
let tribe;
try { tribe = await this.getTribeById(tribeId); } catch (_) { return [rootId]; }
const chain = [rootId];
let cur = tribe;
const seen = new Set([rootId]);
while (cur && cur.parentTribeId) {
const pRoot = await this.getRootId(cur.parentTribeId).catch(() => null);
if (!pRoot || seen.has(pRoot)) break;
chain.push(pRoot);
seen.add(pRoot);
try { cur = await this.getTribeById(cur.parentTribeId); } catch (_) { break; }
}
return chain;
},
async getTribeById(tribeId) {
const idx = await buildTribeIndex();
let latestId = tribeId;
while (idx.child.has(latestId)) latestId = idx.child.get(latestId);
if (idx.tombstoned.has(latestId) || idx.effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found');
const entry = idx.tribes.get(latestId);
if (!entry) throw new Error('Tribe not found');
return normalizeTribe(entry);
},
async listAll() {
const idx = await buildTribeIndex();
const items = [];
for (const [root, tip] of idx.tipByRoot) {
if (idx.tombstoned.has(root) || idx.tombstoned.has(tip)) continue;
if (idx.effectivelyTombstoned.has(root) || idx.effectivelyTombstoned.has(tip)) continue;
const entry = idx.tribes.get(tip);
if (!entry) continue;
items.push(normalizeTribe(entry));
}
return items;
},
async listTribesForViewer(userId) {
const all = await this.listAll();
const out = [];
for (const t of all) {
if (!t.isAnonymous) { out.push(t); continue; }
if (t.author === userId || (Array.isArray(t.members) && t.members.includes(userId))) out.push(t);
}
return out;
},
async getViewerTribeScope(userId) {
const all = await this.listAll();
const memberOf = new Set();
const createdBy = new Set();
for (const t of all) {
if (t.author === userId) { createdBy.add(t.id); memberOf.add(t.id); continue; }
if (Array.isArray(t.members) && t.members.includes(userId)) memberOf.add(t.id);
}
return { memberOf, createdBy, allTribes: all };
},
async listSubTribes(parentId, userId) {
const idx = await buildTribeIndex();
const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
const parentRoot = rootOf(parentId);
const all = await this.listAll();
const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
if (!userId) return subs;
const out = [];
for (const sub of subs) {
const ok = await this.canAccessTribe(userId, sub.id).catch(() => false);
if (ok) out.push(sub);
}
return out;
},
async isTribeMember(userId, tribeId) {
if (!userId || !tribeId) return false;
try {
const tribe = await this.getTribeById(tribeId);
if (!tribe) return false;
if (tribe.author === userId) return true;
return Array.isArray(tribe.members) && tribe.members.includes(userId);
} catch (_) { return false; }
},
async canAccessTribe(userId, tribeId) {
if (!userId || !tribeId) return false;
try {
const tribe = await this.getTribeById(tribeId);
if (!tribe) return false;
if (tribe.author === userId) return true;
if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
const effective = await this.getEffectiveStatus(tribeId);
return !effective.isPrivate;
} catch (_) { return false; }
},
async getEffectiveStatus(tribeId) {
let current;
try { current = await this.getTribeById(tribeId); } catch (_) { return { isPrivate: true, chain: [] }; }
const chain = [{ id: current.id, isAnonymous: !!current.isAnonymous, author: current.author }];
let cursor = current;
const seen = new Set([current.id]);
while (cursor.parentTribeId && !seen.has(cursor.parentTribeId)) {
seen.add(cursor.parentTribeId);
try {
cursor = await this.getTribeById(cursor.parentTribeId);
chain.push({ id: cursor.id, isAnonymous: !!cursor.isAnonymous, author: cursor.author });
} catch (_) { break; }
}
const isPrivate = chain.some(c => c.isAnonymous);
return { isPrivate, chain };
},
async updateTribeById(tribeId, updatedContent) {
const tribe = await this.getTribeById(tribeId);
if (!tribe) throw new Error('Tribe not found');
const rootId = await this.getRootId(tribeId);
const tipId = tribe.id;
const now = new Date().toISOString();
const fields = {
title: updatedContent.title !== undefined ? updatedContent.title : tribe.title,
description: updatedContent.description !== undefined ? updatedContent.description : tribe.description,
image: updatedContent.image !== undefined ? updatedContent.image : tribe.image,
location: updatedContent.location !== undefined ? updatedContent.location : tribe.location,
tags: updatedContent.tags !== undefined ? updatedContent.tags : tribe.tags,
isAnonymous: updatedContent.isAnonymous !== undefined ? updatedContent.isAnonymous : tribe.isAnonymous,
members: updatedContent.members !== undefined ? updatedContent.members : tribe.members,
invites: updatedContent.invites !== undefined ? updatedContent.invites : tribe.invites,
inviteMode: updatedContent.inviteMode !== undefined ? updatedContent.inviteMode : tribe.inviteMode,
status: updatedContent.status !== undefined ? updatedContent.status : tribe.status,
parentTribeId: updatedContent.parentTribeId !== undefined ? updatedContent.parentTribeId : tribe.parentTribeId,
mapUrl: updatedContent.mapUrl !== undefined ? updatedContent.mapUrl : tribe.mapUrl,
createdAt: tribe.createdAt,
updatedAt: now,
author: tribe.author
};
if (fields.isAnonymous) {
return wrapAndPublish(rootId, { k: 'tribe', op: 'update', rootId, replaces: tipId, ...fields });
}
const client = await openSsb();
const content = { type: 'tribe', op: 'update', rootId, replaces: tipId, ...fields };
const result = await new Promise((resolve, reject) =>
client.publish(content, (err, r) => err ? reject(err) : resolve(r))
);
tribeIndex = null;
return result;
},
async publishUpdatedTribe(tribeId, updated) {
return this.updateTribeById(tribeId, updated);
},
async updateTribeMembers(tribeId, members) {
const tribe = await this.getTribeById(tribeId);
const old = tribe.members || [];
await this.updateTribeById(tribeId, { members });
const removed = old.filter(m => !members.includes(m));
const added = members.filter(m => !old.includes(m));
if (removed.length > 0) {
await this.rotateTribeKey(tribeId, members);
} else if (added.length > 0) {
await this.distributeTribeKey(tribeId, added);
}
},
async updateTribeInvites(tribeId, invites) {
return this.updateTribeById(tribeId, { invites });
},
async generateInvite(tribeId) {
const client = await openSsb();
const userId = client.id;
const tribe = await this.getTribeById(tribeId);
if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
throw new Error('Only the author can generate invites in strict mode');
}
if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
throw new Error('Only tribe members can generate invites in open mode');
}
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
const targetRoot = await this.getRootId(tribeId);
if (!targetRoot) throw new Error('Cannot resolve tribe root');
const salt = tribeCrypto.generateInviteSalt();
const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt);
if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys');
const codeHash = tribeCrypto.hashInviteCode(code, salt);
const inviteMsg = {
type: 'tribe-invite-msg',
v: 1,
ch: codeHash,
s: salt,
ek: ekChain
};
const invitePub = await new Promise((resolve, reject) =>
client.publish(inviteMsg, (err, r) => err ? reject(err) : resolve(r))
);
const inviteRef = {
codeHash,
salt,
gen: tribeCrypto.getGen(targetRoot),
msgKey: invitePub.key
};
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, inviteRef] : [inviteRef];
await this.updateTribeInvites(tribeId, invites);
return code;
},
async getOpenInvite(tribeId) {
const root = await this.getRootId(tribeId).catch(() => tribeId);
const map = await scanOpenInviteMarkers();
const rec = map.get(root);
return rec ? { code: rec.code, by: rec.by, markerKey: rec.markerKey, inviteKey: rec.inviteKey } : null;
},
async enrichOpenInvites(tribes) {
const list = Array.isArray(tribes) ? tribes : [];
if (!list.length) return list;
const map = await scanOpenInviteMarkers();
const idx = await buildTribeIndex();
for (const t of list) {
const root = idx.rootByTip.get(t.id) || t.id;
const rec = map.get(root);
if (rec) { t.openInviteCode = rec.code; t.openInviteBy = rec.by; }
}
return list;
},
async generateOpenInvite(tribeId) {
const client = await openSsb();
const userId = client.id;
const tribe = await this.getTribeById(tribeId);
if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
throw new Error('Only the author can generate invites in strict mode');
}
if (tribe.inviteMode === 'open' && tribe.author !== userId && !tribe.members.includes(userId)) {
throw new Error('Only tribe members can generate invites in open mode');
}
const existing = await this.getOpenInvite(tribeId);
if (existing) throw new Error('An open invitation already exists');
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
const targetRoot = await this.getRootId(tribeId);
if (!targetRoot) throw new Error('Cannot resolve tribe root');
const salt = tribeCrypto.generateInviteSalt();
const ekChain = tribeCrypto.encryptChainForInvite([targetRoot], code, salt);
if (!ekChain) throw new Error('Cannot encrypt invite chain — missing keys');
const codeHash = tribeCrypto.hashInviteCode(code, salt);
const invitePub = await new Promise((resolve, reject) =>
client.publish({ type: 'tribe-invite-msg', v: 1, multi: 1, ch: codeHash, s: salt, ek: ekChain }, (err, r) => err ? reject(err) : resolve(r))
);
await new Promise((resolve, reject) =>
client.publish({ type: 'tribe-open-invite', v: 1, rootId: targetRoot, code, inviteKey: invitePub.key, by: userId, createdAt: new Date().toISOString() }, (err, r) => err ? reject(err) : resolve(r))
);
tribeIndex = null;
return code;
},
async removeOpenInvite(tribeId) {
const client = await openSsb();
const userId = client.id;
const tribe = await this.getTribeById(tribeId).catch(() => null);
const rec = await this.getOpenInvite(tribeId);
if (!rec) return;
if (rec.by !== userId && !(tribe && tribe.author === userId)) {
throw new Error('Not allowed to remove this invitation');
}
await new Promise((resolve, reject) =>
client.publish({ type: 'tribe-open-invite-tombstone', v: 1, target: rec.markerKey, ts: new Date().toISOString() }, (err, r) => err ? reject(err) : resolve(r))
);
if (rec.inviteKey) await this.publishInviteTombstone(rec.inviteKey).catch(() => {});
tribeIndex = null;
},
async joinByInvite(rawCode) {
const code = String(rawCode || '').trim();
if (!code) throw new Error('Invalid or expired invite code');
const client = await openSsb();
const userId = client.id;
const msgs = await streamLog();
const inviteTombstoned = new Set();
for (const m of msgs) {
const v = m.value;
const c = v && v.content;
if (!c || c.type !== 'tribe-invite-tombstone' || typeof c.target !== 'string') continue;
inviteTombstoned.add(c.target);
}
let matched = null;
for (const m of msgs) {
const v = m.value;
const c = v && v.content;
if (!c || c.type !== 'tribe-invite-msg' || c.v !== 1) continue;
if (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue;
if (inviteTombstoned.has(m.key)) continue;
if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) continue;
const chain = tribeCrypto.decryptChainFromInvite(c.ek, code, c.s);
if (Array.isArray(chain) && chain.length) {
matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true };
break;
}
}
if (!matched) throw new Error('Invalid or expired invite code');
for (const entry of matched.chain) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
}
tribeIndex = null;
const rootId = matched.chain[0].rootId;
let tribe;
try { tribe = await this.getTribeById(rootId); } catch (_) { tribe = null; }
if (!tribe) throw new Error('Tribe not found after key import');
if (tribe.members.includes(userId)) throw new Error('Already a member of this tribe');
const members = [...tribe.members, userId];
if (matched.multi) {
await this.updateTribeById(tribe.id, { members });
} else {
const invites = (tribe.invites || []).filter(inv => inv.codeHash !== matched.codeHash);
await this.updateTribeById(tribe.id, { members, invites });
await this.publishInviteTombstone(matched.msgKey).catch(() => {});
}
await this.ensureFollowTribeMembers(tribe.id).catch(() => {});
subscribeInvalidation().catch(() => {});
return rootId;
},
async publishInviteTombstone(inviteMsgKey) {
const client = await openSsb();
return new Promise((resolve, reject) =>
client.publish({
type: 'tribe-invite-tombstone',
v: 1,
target: inviteMsgKey,
ts: new Date().toISOString()
}, (err, r) => err ? reject(err) : resolve(r))
);
},
async leaveTribe(tribeId, opts = {}) {
const client = await openSsb();
const userId = client.id;
const tribe = await this.getTribeById(tribeId);
if (!tribe) throw new Error('Tribe not found');
const isAuthor = tribe.author === userId;
if (isAuthor && !opts.force) throw new Error('Tribe author cannot leave their own tribe');
const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
const idx = members.indexOf(userId);
if (idx === -1) throw new Error('User is not a member of this tribe');
members.splice(idx, 1);
if (isAuthor && members.length === 0) {
await this.publishTombstone(tribeId).catch(() => {});
return;
}
await this.updateTribeById(tribeId, { members });
if (members.length > 0) {
await this.rotateTribeKey(tribeId, members).catch(() => {});
}
},
async distributeTribeKey(tribeId, toMembers) {
if (!Array.isArray(toMembers) || !toMembers.length) return;
const client = await openSsb();
const rootId = await this.getRootId(tribeId);
const keys = tribeCrypto.getKeys(rootId);
const gen = tribeCrypto.getGen(rootId);
if (!keys.length) return;
const payload = tribeCrypto.buildKeyDistribPayload(rootId, keys, gen);
const batch = tribeCrypto.KEY_DISTRIB_BATCH;
for (let i = 0; i < toMembers.length; i += batch) {
const recps = toMembers.slice(i, i + batch);
await new Promise((resolve, reject) =>
client.publish({ ...payload, recps }, (err) => err ? reject(err) : resolve())
);
}
},
async rotateTribeKey(tribeId, remainingMembers) {
const rootId = await this.getRootId(tribeId);
const oldKey = tribeCrypto.getKey(rootId);
if (!oldKey) return;
const newKey = tribeCrypto.generateTribeKey();
tribeCrypto.addNewKey(rootId, newKey);
if (Array.isArray(remainingMembers) && remainingMembers.length > 0) {
await this.distributeTribeKey(tribeId, remainingMembers).catch(() => {});
}
},
async ensureTribeKeyDistribution(tribeId) {
const client = await openSsb();
const userId = client.id;
let tribe;
try { tribe = await this.getTribeById(tribeId); } catch (_) { return; }
if (!tribe || tribe.author !== userId) return;
const rootId = await this.getRootId(tribeId);
if (!tribeCrypto.getKey(rootId)) return;
const others = (tribe.members || []).filter(m => m !== userId);
if (!others.length) return;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const msgs = await streamLog();
const distributed = new Set();
for (const m of msgs) {
if (m.value && m.value.author !== userId) continue;
const c = m.value && m.value.content;
const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
if (!dec || dec.rootId !== rootId) continue;
const recps = Array.isArray(dec.recps) ? dec.recps : [];
for (const r of recps) distributed.add(r);
}
const missing = others.filter(m => !distributed.has(m));
if (missing.length > 0) await this.distributeTribeKey(tribeId, missing).catch(() => {});
},
async processIncomingKeys() {
const client = await openSsb();
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const msgs = await streamLog();
const byTribe = new Map();
for (const m of msgs) {
const c = m.value && m.value.content;
const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
if (!dec) continue;
const list = byTribe.get(dec.rootId) || [];
list.push({ generation: dec.gen || dec.keys.length, keys: dec.keys, ts: m.value.timestamp });
byTribe.set(dec.rootId, list);
}
for (const [rootId, entries] of byTribe.entries()) {
entries.sort((a, b) => b.generation - a.generation);
const top = entries[0];
if (top && Array.isArray(top.keys) && top.keys.length) {
tribeCrypto.mergeKeys(rootId, top.keys, top.generation);
}
}
},
async ensureFollowTribeMembers(tribeId) {
const client = await openSsb();
const me = client.id;
let tribe;
try { tribe = await this.getTribeById(tribeId); } catch (_) { return; }
const fpIdx = tribeCrypto.buildFingerprintIndex();
const rootId = await this.getRootId(tribeId).catch(() => tribeId);
const tribeChainIds = await this.getChainIds(tribeId).catch(() => [tribeId]);
const tribeRootSet = new Set([rootId]);
const tribeChainSet = new Set(tribeChainIds);
tribeChainSet.add(tribeId);
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const discovered = new Set();
const myFollows = new Map();
const myInviteMsgKeys = new Set();
const msgs = await streamLog();
for (const m of msgs) {
const v = m.value;
if (!v) continue;
const c = v.content;
if (!c) continue;
if (v.author === me && c.type === 'tribe-invite-msg') {
myInviteMsgKeys.add(m.key);
}
if (v.author === me && c && c.type === 'contact' && c.contact && typeof c.following === 'boolean') {
myFollows.set(c.contact, c.following);
continue;
}
if (c.type === 'tribe-invite-tombstone' && typeof c.target === 'string' && v.author && v.author !== me) {
if (myInviteMsgKeys.has(c.target)) discovered.add(v.author);
continue;
}
const dec = tribeCrypto.tryUnboxKeyDistrib(c, config.keys, ssbKeys);
if (dec && tribeRootSet.has(dec.rootId)) {
const recps = Array.isArray(dec.recps) ? dec.recps : [];
for (const r of recps) discovered.add(r);
if (v.author) discovered.add(v.author);
continue;
}
if (tribeCrypto.isTribeMsg(c)) {
const r = tribeCrypto.unwrapMsg(c, fpIdx);
if (!r || !r.body) continue;
if (r.body.k !== 'tribe') continue;
if (!tribeChainSet.has(m.key) && !tribeChainSet.has(r.body.replaces || '') && r.rootId !== rootId) continue;
const mems = Array.isArray(r.body.members) ? r.body.members : [];
for (const fid of mems) if (fid) discovered.add(fid);
if (v.author) discovered.add(v.author);
}
}
for (const m of msgs) {
const v = m.value;
if (!v || v.author !== me) continue;
const c = v.content;
if (!c || c.type !== 'tribe-invite-msg') continue;
for (const m2 of msgs) {
const v2 = m2.value;
if (!v2) continue;
const c2 = v2.content;
if (!c2 || c2.type !== 'tribe-invite-tombstone') continue;
if (c2.target === m.key && v2.author && v2.author !== me) discovered.add(v2.author);
}
}
const baseMembers = Array.isArray(tribe.members) ? tribe.members : [];
for (const fid of baseMembers) discovered.add(fid);
if (tribe.author) discovered.add(tribe.author);
discovered.delete(me);
const members = [...discovered].filter(Boolean);
if (!members.length) return;
for (const memberId of members) {
if (myFollows.get(memberId) === true) continue;
await new Promise((resolve) =>
client.publish({ type: 'contact', contact: memberId, following: true }, () => resolve())
);
}
},
async forceSync() {
try {
const client = await openSsb();
if (client.replicate && typeof client.replicate.upto === 'function') {
await new Promise((resolve) => { try { client.replicate.upto(() => resolve()); } catch (_) { resolve(); } });
}
} catch (_) {}
},
async deleteTribeById(tribeId) {
return this.publishTombstone(tribeId);
},
async publishTombstone(tribeId) {
const client = await openSsb();
const userId = client.id;
const rootId = await this.getRootId(tribeId);
const idx = await buildTribeIndex();
let tipId = rootId;
while (idx.child.has(tipId)) tipId = idx.child.get(tipId);
let tribe;
try { tribe = await this.getTribeById(tribeId); } catch (_) { tribe = null; }
const isPrivate = tribe ? !!tribe.isAnonymous : !!tribeCrypto.getKey(rootId);
let result;
if (isPrivate) {
result = await wrapAndPublish(rootId, { k: 'tombstone', rootId, target: tipId, author: userId, deletedAt: new Date().toISOString() });
} else {
const tomb = { type: 'tombstone', target: tipId, deletedAt: new Date().toISOString(), author: userId };
result = await new Promise((resolve, reject) =>
client.publish(tomb, (err, r) => err ? reject(err) : resolve(r))
);
}
tribeIndex = null;
return result;
},
async pruneOrphanKeys() {
if (!tribeCrypto || typeof tribeCrypto.getAllRootIds !== 'function') return 0;
const idx = await buildTribeIndex();
const all = tribeCrypto.getAllRootIds();
let removed = 0;
for (const rid of all) {
if (!idx.tribes.has(rid) || idx.effectivelyTombstoned.has(rid)) {
try { tribeCrypto.dropKey(rid); removed++; } catch (_) {}
}
}
return removed;
}
};
};

332
src/models/videos_model.js Normal file
View file

@ -0,0 +1,332 @@
const pull = require("../server/node_modules/pull-stream");
const { getConfig } = require("../configs/config-manager.js");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone") continue;
if (c.type === "videoOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "video") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildVideo = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const opinions = agg ? agg.opinions : (c.opinions || {});
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: safeArr(c.tags),
author: c.author,
title: c.title || "",
description: c.description || "",
mapUrl: c.mapUrl || "",
opinions,
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "video",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Video not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Video not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createVideo(blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "video",
url: blobId,
createdAt: now,
updatedAt: now,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
mapUrl: mapUrl || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateVideoById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "video") throw new Error("Video not found");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const aggV = await this.getVideoById(id, userId);
if (aggV && Object.keys(aggV.opinions || {}).some(k => (aggV.opinions[k] || 0) > 0)) throw new Error("Cannot edit video after it has received opinions.");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
});
},
async deleteVideoById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "video") throw new Error("Video not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err2, res) => (err2 ? reject(err2) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildVideo(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((v) => String(v.author) === String(viewerId));
else if (filter === "recent") list = list.filter((v) => new Date(v.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
}
if (q) {
list = list.filter((v) => {
const t = String(v.title || "").toLowerCase();
const d = String(v.description || "").toLowerCase();
const tags = safeArr(v.tags).join(" ").toLowerCase();
const a = String(v.author || "").toLowerCase();
return t.includes(q) || d.includes(q) || tags.includes(q) || a.includes(q);
});
}
if (sort === "top") {
list = list
.slice()
.sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getVideoById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Video not found");
return buildVideo(agg.contentNode, rootId, viewer, agg);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const video = await this.getVideoById(id, userId);
if (!video) throw new Error("Video not found");
if (safeArr(video.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "videoOpinion", target: video.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};

View file

@ -0,0 +1,130 @@
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const COOLDOWN_MS = 5 * 60 * 1000;
const statePath = path.join(__dirname, '../configs/follow_state.json');
const readJson = (p, fallback) => {
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return fallback; }
};
const writeJson = (p, data) => {
try { fs.writeFileSync(p, JSON.stringify(data, null, 2)); } catch (e) {}
};
const loadState = () => {
const s = readJson(statePath, { pending: [], accepted: [], lastAcceptMs: 0 });
if (!Array.isArray(s.pending)) s.pending = [];
if (!Array.isArray(s.accepted)) s.accepted = [];
if (typeof s.lastAcceptMs !== 'number') s.lastAcceptMs = 0;
return s;
};
const saveState = (s) => writeJson(statePath, s);
const wishMutualsOnly = () => getConfig().wish === 'mutuals';
const pmMutualsOnly = () => getConfig().pmVisibility === 'mutuals';
const isFrictionActive = () => wishMutualsOnly() || pmMutualsOnly();
const listPending = () => loadState().pending;
const enqueuePending = (followerId, extra = {}) => {
if (!followerId) return false;
const s = loadState();
if (s.pending.some(x => x.followerId === followerId)) return false;
s.pending.push({ followerId, at: new Date().toISOString(), ...extra });
saveState(s);
return true;
};
const removePending = (followerId) => {
const s = loadState();
s.pending = s.pending.filter(x => x.followerId !== followerId);
saveState(s);
};
const loadAccepted = () => loadState().accepted;
const isAccepted = (followerId) => loadState().accepted.includes(followerId);
const addAccepted = (followerId) => {
if (!followerId) return;
const s = loadState();
if (!s.accepted.includes(followerId)) { s.accepted.push(followerId); saveState(s); }
};
const removeAccepted = (followerId) => {
const s = loadState();
s.accepted = s.accepted.filter(x => x !== followerId);
saveState(s);
};
const canAutoAcceptNow = () => (Date.now() - loadState().lastAcceptMs) >= COOLDOWN_MS;
const markAutoAccept = () => {
const s = loadState();
s.lastAcceptMs = Date.now();
saveState(s);
};
const makeMutualCache = (friendModel) => {
const cache = new Map();
const frictionActive = isFrictionActive();
return async (otherId) => {
if (!otherId) return false;
if (cache.has(otherId)) return cache.get(otherId);
try {
const rel = await friendModel.getRelationship(otherId);
const basic = !!(rel && rel.following && rel.followsMe);
const mutual = frictionActive ? (basic && isAccepted(otherId)) : basic;
cache.set(otherId, mutual);
return mutual;
} catch (e) {
cache.set(otherId, false);
return false;
}
};
};
const authorOf = (item) => {
if (!item) return null;
if (item.value && item.value.author) return item.value.author;
if (item.author) return item.author;
if (item.feed) return item.feed;
if (item.id && typeof item.id === 'string' && item.id.startsWith('@')) return item.id;
return null;
};
const applyMutualSupportFilter = async (items, viewerId, friendModel) => {
if (!wishMutualsOnly()) return items;
if (!Array.isArray(items)) return items;
const isMutual = makeMutualCache(friendModel);
const out = [];
for (const it of items) {
const a = authorOf(it);
if (!a || a === viewerId) { out.push(it); continue; }
if (await isMutual(a)) out.push(it);
}
return out;
};
const canSendPmTo = async (viewerId, recipientId, friendModel) => {
if (!pmMutualsOnly()) return { allowed: true };
if (viewerId === recipientId) return { allowed: true };
const isMutual = makeMutualCache(friendModel);
if (await isMutual(recipientId)) return { allowed: true };
return { allowed: false, reason: 'non-mutual' };
};
module.exports = {
COOLDOWN_MS,
wishMutualsOnly,
pmMutualsOnly,
isFrictionActive,
listPending,
enqueuePending,
removePending,
loadAccepted,
isAccepted,
addAccepted,
removeAccepted,
canAutoAcceptNow,
markAutoAccept,
makeMutualCache,
applyMutualSupportFilter,
canSendPmTo,
authorOf,
};

382
src/models/votes_model.js Normal file
View file

@ -0,0 +1,382 @@
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js');
const { dedupeBy, norm } = require('../backend/dedupe');
const { buildVoteTally } = require('../backend/vote_tally');
const categories = require('../backend/opinion_categories');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const MIN_VOTE_DAYS = 7;
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const TYPE = 'votes';
async function getAllMessages(ssbClient) {
return new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, results) => (err ? reject(err) : resolve(results)))
);
});
}
function buildIndex(messages) {
const tombstoned = buildValidatedTombstoneSet(messages);
const votes = new Map();
const naivePrev = new Map();
const collabVotes = [];
const collabOpinions = [];
for (const m of messages) {
const key = m.key;
const v = m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'votesVote') { collabVotes.push({ target: c.target, author: v.author, choice: c.choice, ts: v.timestamp || 0 }); continue; }
if (c.type === 'votesOpinion') { collabOpinions.push({ target: c.target, author: v.author, category: c.category, ts: v.timestamp || 0 }); continue; }
if (c.type !== TYPE) continue;
votes.set(key, { key, ts: v.timestamp || m.timestamp || 0, content: c, author: v.author });
}
for (const [key, node] of votes) {
const t = node.content.replaces;
if (t) { naivePrev.set(key, t); }
}
return { tombstoned, votes, naivePrev, collabVotes, collabOpinions, voteTallyByKey: buildVoteTally(messages) };
}
const rootOfIn = (naivePrev, votes, key) => { let x = key, g = 0; while (naivePrev.has(x) && votes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; };
function statusFromContent(content, now) {
const raw = String(content.status || 'OPEN').toUpperCase();
if (raw === 'OPEN') {
const dl = content.deadline ? moment(content.deadline) : null;
if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED';
}
return raw;
}
function computeActiveVotes(index) {
const { tombstoned, votes, naivePrev, collabVotes, collabOpinions } = index;
const rootOf = key => rootOfIn(naivePrev, votes, key);
const groups = new Map();
for (const [key, node] of votes.entries()) {
const root = rootOf(key);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(node);
}
const cvByRoot = new Map(); const coByRoot = new Map();
for (const cv of collabVotes) { if (!votes.has(cv.target)) continue; const r = rootOf(cv.target); if (!cvByRoot.has(r)) cvByRoot.set(r, []); cvByRoot.get(r).push(cv); }
for (const co of collabOpinions) { if (!votes.has(co.target)) continue; const r = rootOf(co.target); if (!coByRoot.has(r)) coByRoot.set(r, []); coByRoot.get(r).push(co); }
const now = moment();
const result = [];
for (const [root, nodes] of groups.entries()) {
const rootNode = votes.get(root);
const rootAuthor = rootNode ? rootNode.author : null;
const ownerNodes = nodes.filter(n => n.author === rootAuthor);
const pool = ownerNodes.length ? ownerNodes : (rootNode ? [rootNode] : nodes);
let best = pool[0];
let bestStatus = statusFromContent(best.content, now);
for (let i = 1; i < pool.length; i++) {
const candidate = pool[i];
const cStatus = statusFromContent(candidate.content, now);
if (cStatus === bestStatus) {
const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0);
const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0);
if (cTime > bestTime) { best = candidate; bestStatus = cStatus; }
} else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') { best = candidate; bestStatus = cStatus; }
else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') { best = candidate; bestStatus = cStatus; }
}
if (tombstoned.has(best.key)) continue;
const lc = best.content;
const options = Array.isArray(best.content.options) ? best.content.options : [];
const tally = index.voteTallyByKey ? index.voteTallyByKey.get(best.key) : null;
let votesMap;
let voters;
let totalVotes;
if (tally) {
votesMap = Object.assign({}, tally.votes || {});
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
voters = tally.voters.slice();
totalVotes = tally.totalVotes;
} else {
votesMap = Object.assign({}, lc.votes || {});
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
voters = Array.isArray(lc.voters) ? lc.voters.slice() : [];
const voterSet = new Set(voters);
totalVotes = parseInt(lc.totalVotes || 0, 10) || voters.length;
for (const cv of (cvByRoot.get(root) || [])) {
if (voterSet.has(cv.author)) continue;
if (!options.includes(cv.choice)) continue;
voterSet.add(cv.author); voters.push(cv.author);
votesMap[cv.choice] = (votesMap[cv.choice] || 0) + 1;
totalVotes += 1;
}
}
const opinions = Object.assign({}, lc.opinions || {});
const opInh = Array.isArray(lc.opinions_inhabitants) ? lc.opinions_inhabitants.slice() : [];
const opSet = new Set(opInh);
for (const co of (coByRoot.get(root) || [])) {
if (opSet.has(co.author)) continue;
opSet.add(co.author); opInh.push(co.author);
opinions[co.category] = (opinions[co.category] || 0) + 1;
}
result.push({
id: best.key,
latestId: best.key,
...best.content,
votes: votesMap,
voters,
totalVotes,
opinions,
opinions_inhabitants: opInh,
status: bestStatus
});
}
return result;
}
async function resolveCurrentId(voteId) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const nodeByKey = new Map();
for (const m of messages) { const c = m.value && m.value.content; if (c && c.type === TYPE) nodeByKey.set(m.key, { author: m.value.author, replaces: c.replaces }); }
const strictForward = new Map();
for (const [key, n] of nodeByKey) { if (n.replaces && nodeByKey.has(n.replaces) && nodeByKey.get(n.replaces).author === n.author) strictForward.set(n.replaces, key); }
let cur = voteId, g = 0;
while (strictForward.has(cur) && g++ < 100000) cur = strictForward.get(cur);
return cur;
}
return {
async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const parsedDeadline = moment(deadline, moment.ISO_8601, true);
if (!parsedDeadline.isValid()) throw new Error('Invalid deadline');
if (parsedDeadline.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const content = {
type: TYPE,
question,
options,
deadline: parsedDeadline.toISOString(),
createdBy: userId,
status: 'OPEN',
votes: options.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {}),
totalVotes: 0,
voters: [],
tags,
opinions: {},
opinions_inhabitants: [],
createdAt: new Date().toISOString(),
updatedAt: null
};
return new Promise((res, rej) =>
ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
);
},
async deleteVoteById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const vote = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author');
const tombstone = {
type: 'tombstone',
target: tipId,
deletedAt: new Date().toISOString(),
author: userId
};
return new Promise((res, rej) =>
ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result)))
);
},
async updateVoteById(id, payload) {
const { question, deadline, options, tags } = payload || {};
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const oldMsg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
const c = oldMsg.content;
if (!c || c.type !== TYPE) throw new Error('Invalid type');
if (c.createdBy !== userId) throw new Error('Not the author');
const agg = await this.getVoteById(id);
const aggTotalVotes = agg ? (parseInt(agg.totalVotes || 0, 10) || 0) : 0;
if (agg && Object.keys(agg.opinions || {}).some(k => (agg.opinions[k] || 0) > 0)) throw new Error('Cannot edit vote after it has received opinions.');
let newDeadline = c.deadline;
if (deadline != null && deadline !== '') {
const parsed = moment(deadline, moment.ISO_8601, true);
if (!parsed.isValid()) throw new Error('Invalid deadline');
if (parsed.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) throw new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
newDeadline = parsed.toISOString();
}
let newOptions = c.options || [];
let newVotesMap = c.votes || {};
let newTotalVotes = c.totalVotes || 0;
const optionsChanged = Array.isArray(options) && (
options.length !== newOptions.length ||
options.some((o, i) => o !== newOptions[i])
);
if (optionsChanged) {
if (aggTotalVotes > 0) {
throw new Error('Cannot change options after voting has started');
}
newOptions = options;
newVotesMap = newOptions.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {});
newTotalVotes = 0;
}
let newTags = c.tags || [];
if (Array.isArray(tags)) {
newTags = tags.filter(Boolean);
} else if (typeof tags === 'string') {
newTags = tags.split(',').map(t => t.trim()).filter(Boolean);
}
const updated = {
...c,
replaces: tipId,
question: question != null ? question : c.question,
deadline: newDeadline,
options: newOptions,
votes: newVotesMap,
totalVotes: newTotalVotes,
tags: newTags,
updatedAt: new Date().toISOString()
};
return new Promise((res, rej) =>
ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
);
},
async voteOnVote(id, choice) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const vote = await this.getVoteById(id);
if (!vote) throw new Error('Vote not found');
if (vote.createdBy === userId) throw new Error('Creator cannot vote');
const options = Array.isArray(vote.options) ? vote.options : [];
if (!options.includes(choice)) throw new Error('Invalid choice');
if (Array.isArray(vote.voters) && vote.voters.includes(userId)) throw new Error('Already voted');
const content = { type: 'votesVote', target: vote.id, choice, createdAt: new Date().toISOString() };
return new Promise((res, rej) =>
ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result)))
);
},
async getVoteById(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const index = buildIndex(messages);
const activeList = computeActiveVotes(index);
const byId = new Map(activeList.map(v => [v.id, v]));
if (byId.has(id)) {
return byId.get(id);
}
const rootOf = key => rootOfIn(index.naivePrev, index.votes, key);
const root = rootOf(id);
const candidate = activeList.find(v => rootOf(v.id) === root);
if (candidate) {
return candidate;
}
const msg = await new Promise((res, rej) =>
ssbClient.get(id, (err, vote) => (err || !vote ? rej(new Error('Vote not found')) : res(vote)))
);
const content = msg.content || {};
const status = statusFromContent(content, moment());
return {
id,
latestId: id,
...content,
status
};
},
async listAll(filter = 'all') {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const index = buildIndex(messages);
let list = computeActiveVotes(index);
if (filter === 'mine') {
list = list.filter(v => v.createdBy === userId);
} else if (filter === 'open') {
list = list.filter(v => v.status === 'OPEN');
} else if (filter === 'closed') {
list = list.filter(v => v.status === 'CLOSED');
}
const deduped = dedupeBy(list, v => v.question ? [norm(v.createdBy), norm(v.question), norm(v.deadline)].join('|') : null);
return deduped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error('Invalid voting category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const vote2 = await this.getVoteById(id);
if (!vote2) throw new Error('Vote not found');
if (Array.isArray(vote2.opinions_inhabitants) && vote2.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
const content = { type: 'votesOpinion', target: vote2.id, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) =>
ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result)))
);
}
};
};

View file

@ -0,0 +1,61 @@
const {
RequestManager,
HTTPTransport,
Client
} = require("../server/node_modules/@open-rpc/client-js");
async function makeClient(url, user, pass) {
const headers = {};
if (user !== undefined || pass !== undefined) {
headers['Authorization'] = 'Basic ' + Buffer.from(`${user || ''}:${pass || ''}`).toString('base64');
}
const transport = new HTTPTransport(url, { headers });
return new Client(new RequestManager([transport]));
}
module.exports = {
client: async (url, user, pass) => {
return makeClient(url, user, pass);
},
execute: async (url, user, pass, method, params = []) => {
try {
const clientrpc = await makeClient(url, user, pass);
return await clientrpc.request({ method, params });
} catch (error) {
throw new Error("ECOin wallet disconnected. Check your wallet settings or connection status.");
}
},
getBalance: async (url, user, pass) => {
return Number(await module.exports.execute(url, user, pass, "getbalance")) || 0;
},
getAddress: async (url, user, pass) => {
try {
const addrs = await module.exports.execute(url, user, pass, "getaddressesbyaccount", [""]);
if (Array.isArray(addrs) && addrs.length > 0) return addrs[0];
} catch {}
try {
const addr = await module.exports.execute(url, user, pass, "getnewaddress", [""]);
if (typeof addr === "string" && addr) return addr;
} catch {}
return "";
},
listTransactions: async (url, user, pass) => {
return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]);
},
sendToAddress: async (url, user, pass, address, amount) => {
return await module.exports.execute(url, user, pass, "sendtoaddress", [address, Number(amount)]);
},
validateSend: async (url, user, pass, address, amount, fee) => {
let isValid = false;
const errors = [];
const addrInfo = await module.exports.execute(url, user, pass, "validateaddress", [address]);
const addressValid = !!addrInfo?.isvalid;
const amountValid = Number(amount) > 0;
const feeValid = Number(fee) >= 0;
if (!addressValid) errors.push("invalid_dest");
if (!amountValid) errors.push("invalid_amount");
if (!feeValid) errors.push("invalid_fee");
if (errors.length === 0) isValid = true;
return { isValid, errors };
}
};