Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
576 lines
25 KiB
JavaScript
576 lines
25 KiB
JavaScript
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 { readTyped, requestScope, memoIndex } = require('./typed_log');
|
|
const longText = require('../backend/long_text');
|
|
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 readForumLog();
|
|
const authorOf = new Map();
|
|
for (const m of msgs) if (m && m.key && m.value) authorOf.set(m.key, m.value.author);
|
|
for (const m of msgs) {
|
|
const c = m.value && m.value.content;
|
|
if (!c || c.type !== 'tribe-keys') continue;
|
|
if (!c.tribeId || authorOf.get(c.tribeId) !== m.value.author) 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 FORUM_TYPES = ['forum', 'forum-invite', 'forum-invite-tombstone', 'forum-open-invite', 'forum-open-invite-tombstone', 'tribe-keys', 'vote', 'tombstone', longText.CHUNK_TYPE];
|
|
|
|
const chunkIndexOf = (msgs) => longText.indexChunks(
|
|
msgs.filter(m => m.value && m.value.content && m.value.content.type === longText.CHUNK_TYPE),
|
|
(c) => {
|
|
if (!c || !c.encryptedPayload) return c;
|
|
const dec = decryptForumContent(c, null);
|
|
return dec && !dec._undecryptable ? dec : null;
|
|
}
|
|
);
|
|
const fullText = (idx, c, author) => longText.joinText(c && c.text, c && c.chunks, longText.lookupIn(idx), author);
|
|
|
|
const readForumLog = async () => {
|
|
const ssbClient = await openSsb();
|
|
return readTyped(ssbClient, FORUM_TYPES, { limit: logLimit, withWindow: true });
|
|
};
|
|
|
|
const authorsByKey = (messages) => {
|
|
const out = new Map();
|
|
for (const m of messages) if (m && m.key && m.value) out.set(m.key, m.value.author);
|
|
return out;
|
|
};
|
|
|
|
const inviteTombstones = (messages, authorOf) => {
|
|
const markerTomb = new Set();
|
|
const invTomb = new Set();
|
|
for (const m of messages) {
|
|
const c = m.value && m.value.content;
|
|
if (!c || typeof c.target !== 'string' || authorOf.get(c.target) !== m.value.author) continue;
|
|
if (c.type === 'forum-open-invite-tombstone') markerTomb.add(c.target);
|
|
if (c.type === 'forum-invite-tombstone') invTomb.add(c.target);
|
|
}
|
|
return { markerTomb, invTomb };
|
|
};
|
|
|
|
const scanForumOpenInvite = async (forumId) => {
|
|
const messages = await readForumLog();
|
|
const authorOf = authorsByKey(messages);
|
|
const forumAuthor = authorOf.get(forumId);
|
|
if (!forumAuthor) return null;
|
|
const { markerTomb, invTomb } = inviteTombstones(messages, authorOf);
|
|
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 (m.value.author !== forumAuthor) 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: m.value.author, markerKey: m.key, inviteKey: c.inviteKey || null, ts };
|
|
}
|
|
return best;
|
|
};
|
|
|
|
async function collectTombstones(ssbClient) {
|
|
return deletedOf(await readForumLog());
|
|
}
|
|
|
|
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))
|
|
);
|
|
});
|
|
}
|
|
|
|
const deletedOf = (msgs) => memoIndex('forum:deleted', msgs, buildValidatedTombstoneSet);
|
|
|
|
function aggregateVotes(ssbClient, targetId, known = null) {
|
|
const store = requestScope.getStore();
|
|
if (!store || store.method !== 'GET') return countVotes(ssbClient, targetId, known);
|
|
if (!store.forumVotes) store.forumVotes = new Map();
|
|
let pending = store.forumVotes.get(targetId);
|
|
if (!pending) { pending = countVotes(ssbClient, targetId, known); store.forumVotes.set(targetId, pending); }
|
|
return pending;
|
|
}
|
|
|
|
async function countVotes(ssbClient, targetId, known = null) {
|
|
const tombstoned = known || await collectTombstones(ssbClient);
|
|
return new Promise((resolve, reject) => {
|
|
const latest = new Map();
|
|
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 => {
|
|
const author = link.value.author;
|
|
const ts = Number(link.value.timestamp) || 0;
|
|
const seq = Number(link.value.sequence) || 0;
|
|
const prev = latest.get(author);
|
|
if (!prev || ts > prev.ts || (ts === prev.ts && seq > prev.seq)) latest.set(author, { value: Number(link.value.content.vote.value) || 0, ts, seq });
|
|
},
|
|
err => {
|
|
if (err) return reject(err);
|
|
let positives = 0, negatives = 0;
|
|
for (const v of latest.values()) { if (v.value > 0) positives++; else if (v.value < 0) negatives++; }
|
|
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 msgs = await readForumLog();
|
|
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, author: msg.value.author, timestamp: msg.value.timestamp };
|
|
}
|
|
|
|
const buildForums = async (ssbClient) => {
|
|
const msgs = await readForumLog();
|
|
const deleted = deletedOf(msgs);
|
|
const chunkIdx = chunkIndexOf(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, author: m.value.author, text: fullText(chunkIdx, c, m.value.author) }));
|
|
const forumsWithVotes = await Promise.all(
|
|
forums.map(async f => {
|
|
const { positives, negatives } = await aggregateVotes(ssbClient, f.key, deleted);
|
|
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: fullText(chunkIdx, decReply, m.value.author), author: m.value.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: fullText(chunkIdx, cRaw, m.value.author), author: m.value.author, timestamp: m.value.timestamp });
|
|
}
|
|
});
|
|
const final = await Promise.all(
|
|
forumsWithVotes.map(async f => {
|
|
const replies = repliesByRoot[f.key] || [];
|
|
await Promise.all(replies.map(async (r) => {
|
|
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key, deleted);
|
|
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
|
|
};
|
|
})
|
|
);
|
|
return final;
|
|
};
|
|
|
|
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 forumKey = null;
|
|
if (isPrivateFlag && ownCrypto && tribeCrypto) forumKey = ownCrypto.generateTribeKey();
|
|
const seal = (c) => forumKey ? tribeCrypto.encryptContent(c, [forumKey], true) : c;
|
|
const publish = (c) => new Promise((resolve, reject) =>
|
|
ssbClient.publish(seal(c), (err, res) => err ? reject(err) : resolve(res))
|
|
);
|
|
const content = await longText.chunkContent(plainContent, 'text', { maxBytes: forumKey ? longText.CONTENT_BYTES.sealed : longText.CONTENT_BYTES.plain, publish });
|
|
const result = await publish(content);
|
|
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;
|
|
let key = null;
|
|
if (isPrivate && ownCrypto && tribeCrypto) {
|
|
key = lookupKey(forumId);
|
|
if (!key) throw new Error('Missing forum key — cannot reply to encrypted forum');
|
|
}
|
|
const seal = (c) => key ? tribeCrypto.encryptContent(c, [key], true) : c;
|
|
const publish = (c) => new Promise((resolve, reject) =>
|
|
ssbClient.publish(seal(c), (err, res) => err ? reject(err) : resolve(res))
|
|
);
|
|
content = await longText.chunkContent(content, 'text', { maxBytes: key ? longText.CONTENT_BYTES.sealed : longText.CONTENT_BYTES.plain, publish });
|
|
return publish(content);
|
|
},
|
|
|
|
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 (rawRoot.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 (rawRoot.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)));
|
|
author = rawRoot && rawRoot.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 readForumLog();
|
|
const authorOf = authorsByKey(messages);
|
|
const { invTomb } = inviteTombstones(messages, authorOf);
|
|
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;
|
|
if (!authorOf.get(c.target) || authorOf.get(c.target) !== m.value.author) 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 userId = ssbClient.id;
|
|
const store = requestScope.getStore();
|
|
if (store && !store.forumAll) store.forumAll = buildForums(ssbClient);
|
|
const final = (await (store ? store.forumAll : buildForums(ssbClient))).slice();
|
|
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 readForumLog();
|
|
const deleted = deletedOf(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, deleted);
|
|
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(m.value.author);
|
|
}
|
|
const participants = Array.from(new Set(replyAuthors.concat(original.value.author).filter(Boolean)));
|
|
return {
|
|
...base,
|
|
author: original.value.author,
|
|
text: fullText(chunkIndexOf(msgs), base, original.value.author),
|
|
key: id,
|
|
positiveVotes: positives,
|
|
negativeVotes: negatives,
|
|
score: positives - negatives,
|
|
participants
|
|
};
|
|
},
|
|
|
|
getMessagesByForumId: async forumId => {
|
|
const ssbClient = await openSsb();
|
|
const msgs = await readForumLog();
|
|
const deleted = deletedOf(msgs);
|
|
const chunkIdx = chunkIndexOf(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: fullText(chunkIdx, c, m.value.author),
|
|
author: m.value.author,
|
|
timestamp: m.value.timestamp,
|
|
parent: c.branch || null
|
|
}));
|
|
const [, { positives: p, negatives: n }] = await Promise.all([
|
|
Promise.all(replies.map(async (r) => {
|
|
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key, deleted);
|
|
r.positiveVotes = rp;
|
|
r.negativeVotes = rn;
|
|
r.score = rp - rn;
|
|
})),
|
|
aggregateVotes(ssbClient, forumId, deleted)
|
|
]);
|
|
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
|
|
};
|
|
};
|
|
|