upstream: integrar Oasis 1.3.1
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.
This commit is contained in:
parent
8d838dd8b1
commit
c332afd6ea
265 changed files with 80970 additions and 29167 deletions
|
|
@ -2,6 +2,7 @@ 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, memoIndex } = require("./typed_log")
|
||||
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
|
||||
|
|
@ -16,6 +17,7 @@ const normalizeTags = (raw) => {
|
|||
|
||||
const INVITE_CODE_BYTES = 16
|
||||
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
|
||||
const REACTION_EMOJIS = ["up", "heart", "laugh", "down"]
|
||||
|
||||
const DEFAULT_MESSAGES_PER_HOUR = 60
|
||||
|
||||
|
|
@ -35,6 +37,17 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
|
||||
const ownCrypto = chatCrypto || tribeCrypto
|
||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||
const hashCode = (code, salt) => tribeCrypto ? tribeCrypto.hashInviteCode(code, salt) : null
|
||||
const inviteMatches = (inv, token) => {
|
||||
if (!token) return false
|
||||
if (typeof inv === "string") return inv === token
|
||||
if (!inv || typeof inv !== "object") return false
|
||||
if (typeof inv.code === "string" && inv.code === token) return true
|
||||
if (typeof inv.ch === "string" && (inv.ch === token || hashCode(token, inv.salt) === inv.ch)) return true
|
||||
return false
|
||||
}
|
||||
const inviteToken = (inv) => (inv && typeof inv === "object" && typeof inv.ch === "string") ? inv.ch : (typeof inv === "string" ? inv : (inv && inv.code) || "")
|
||||
const keyProofFor = (keyHex, id) => { try { return crypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(id), "utf8").digest("hex") } catch (_) { return null } }
|
||||
const lookupKeys = (rid) => {
|
||||
const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
|
||||
if (a.length) return a
|
||||
|
|
@ -48,11 +61,10 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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) {
|
||||
for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (Object.keys(memberKeys).length) {
|
||||
|
|
@ -69,9 +81,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
const config = require("../server/ssb_config")
|
||||
const msgs = await readAll(ssbClient)
|
||||
const idx = buildIndex(msgs)
|
||||
const trusted = (root, author) => {
|
||||
const n = idx.nodes.get(root)
|
||||
return !!author && !!n && (author === n.author || author === ssbClient.id)
|
||||
}
|
||||
for (const m of msgs) {
|
||||
const c = m.value && m.value.content
|
||||
if (!c || c.type !== "tribe-keys") continue
|
||||
if (!trusted(c.tribeId, m.value.author)) continue
|
||||
const memberKeys = c.memberKeys
|
||||
if (!memberKeys || typeof memberKeys !== "object") continue
|
||||
const boxed = memberKeys[ssbClient.id]
|
||||
|
|
@ -98,10 +116,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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 CHAT_TYPES = ["chat", "chatMessage", "chatMember", "chatReaction", "chatPin", "tribe-keys", "tombstone"]
|
||||
|
||||
const readAll = async (ssbClient) => readTyped(ssbClient, CHAT_TYPES, { limit: logLimit })
|
||||
|
||||
const buildIndex = (messages) => {
|
||||
const tomb = new Set()
|
||||
|
|
@ -112,6 +129,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const authorByKey = new Map()
|
||||
const tombRequests = []
|
||||
const memberMsgs = []
|
||||
const reactMsgs = []
|
||||
const pinMsgs = []
|
||||
|
||||
for (const m of messages) {
|
||||
const k = m.key
|
||||
|
|
@ -120,11 +139,19 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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 })
|
||||
memberMsgs.push({ target: c.target, member: c.member, on: c.on !== false, code: typeof c.code === "string" ? c.code : "", keyProof: typeof c.keyProof === "string" ? c.keyProof : "", author: v.author, ts: v.timestamp || m.timestamp || 0 })
|
||||
continue
|
||||
}
|
||||
if (c.type === "chatReaction" && c.target && REACTION_EMOJIS.includes(c.emoji)) {
|
||||
reactMsgs.push({ target: c.target, emoji: c.emoji, on: c.on !== false, author: v.author, ts: v.timestamp || m.timestamp || 0 })
|
||||
continue
|
||||
}
|
||||
if (c.type === "chatPin" && c.target) {
|
||||
pinMsgs.push({ target: c.target, on: c.on !== false, 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 })
|
||||
nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, seq: v.sequence || 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") {
|
||||
|
|
@ -133,24 +160,28 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
}
|
||||
}
|
||||
|
||||
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 rootMemo = new Map()
|
||||
const rootOf = (id) => {
|
||||
if (rootMemo.has(id)) return rootMemo.get(id)
|
||||
let cur = id, g = 0
|
||||
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur)
|
||||
rootMemo.set(id, cur)
|
||||
return cur
|
||||
}
|
||||
const ownerTip = new Map()
|
||||
for (const [k, n] of nodes) {
|
||||
const r = rootOf(k)
|
||||
const rn = nodes.get(r)
|
||||
if (!rn || n.author !== rn.author) continue
|
||||
const prev = ownerTip.has(r) ? nodes.get(ownerTip.get(r)) : null
|
||||
if (!prev || n.seq > prev.seq || (n.seq === prev.seq && n.ts >= prev.ts)) ownerTip.set(r, k)
|
||||
}
|
||||
const tipOf = (id) => { const r = rootOf(id); return ownerTip.get(r) || r }
|
||||
|
||||
const roots = new Set()
|
||||
for (const id of nodes.keys()) roots.add(rootOf(id))
|
||||
|
|
@ -158,25 +189,72 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
for (const r of roots) tipByRoot.set(r, tipOf(r))
|
||||
|
||||
const memberByRoot = new Map()
|
||||
const invitesOf = (root) => {
|
||||
const out = []
|
||||
for (const id of [root, tipOf(root)]) { const n = nodes.get(id); if (n && Array.isArray(n.c.invites)) out.push(...n.c.invites) }
|
||||
return out
|
||||
}
|
||||
const sealedRoot = (root) => { const oc = (nodes.get(root) || {}).c || {}; return !!(oc.encryptedPayload || oc.encrypted === true) }
|
||||
const selfJoinAllowed = (root, mm) => {
|
||||
if (!sealedRoot(root)) return true
|
||||
const invites = invitesOf(root)
|
||||
if (invites.some(inv => inv && typeof inv === "object" && inv.public === true)) return true
|
||||
if (mm.keyProof && lookupKeys(root).some(k => keyProofFor(k, mm.member) === mm.keyProof)) return true
|
||||
const tribeChat = !!((nodes.get(root) || {}).c || {}).tribeId
|
||||
return !!mm.code && invites.some(inv => tribeChat ? inviteMatches(inv, mm.code) : (typeof inv === "string" ? inv === mm.code : !!(inv && typeof inv === "object" && typeof inv.code === "string" && inv.code === mm.code)))
|
||||
}
|
||||
const consumedByRoot = new Map()
|
||||
const codeClaimsByRoot = 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 })
|
||||
const ownerAuthor = nodes.get(r) && nodes.get(r).author
|
||||
const self = mm.member === mm.author
|
||||
if (!self && mm.author !== ownerAuthor) continue
|
||||
if (mm.on && mm.code) {
|
||||
if (!codeClaimsByRoot.has(r)) codeClaimsByRoot.set(r, [])
|
||||
codeClaimsByRoot.get(r).push({ code: mm.code, member: mm.member, keyProof: mm.keyProof, byOwner: mm.author === ownerAuthor })
|
||||
}
|
||||
if (self && mm.author !== ownerAuthor && mm.on && !selfJoinAllowed(r, mm)) 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 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 })
|
||||
}
|
||||
|
||||
const isCodeConsumed = (rootId, code) => !!(code && consumedByRoot.has(rootId) && consumedByRoot.get(rootId).has(code))
|
||||
const codeClaims = (rootId, codes) => (codeClaimsByRoot.get(rootId) || []).filter(c => codes.includes(c.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 }
|
||||
for (const [f, fNode] of nodes) {
|
||||
const r = rootOf(f)
|
||||
if (!nodes.has(r) || fNode.author === nodes.get(r).author) continue
|
||||
const opened = decryptChatContent(fNode.c, r)
|
||||
if (opened.undecryptable || !Array.isArray(opened.c && opened.c.members)) continue
|
||||
if (!memberByRoot.has(r)) memberByRoot.set(r, new Map())
|
||||
const perMember = memberByRoot.get(r)
|
||||
const prev = perMember.get(fNode.author)
|
||||
if (!prev || fNode.ts >= prev.ts) perMember.set(fNode.author, { on: opened.c.members.includes(fNode.author), ts: fNode.ts })
|
||||
}
|
||||
|
||||
const reactionByKey = new Map()
|
||||
for (const r of reactMsgs) {
|
||||
const rk = `${r.target}|${r.author}|${r.emoji}`
|
||||
const prev = reactionByKey.get(rk)
|
||||
if (!prev || r.ts >= prev.ts) reactionByKey.set(rk, r)
|
||||
}
|
||||
const pinByTarget = new Map()
|
||||
for (const pm of pinMsgs) {
|
||||
const prev = pinByTarget.get(pm.target)
|
||||
if (!prev || pm.ts >= prev.ts) pinByTarget.set(pm.target, pm)
|
||||
}
|
||||
|
||||
return { tomb, nodes, rawRootOf, rootOf, tipOf, tipByRoot, msgNodes, memberByRoot, consumedByRoot, isCodeConsumed, codeClaims, reactionByKey, pinByTarget }
|
||||
}
|
||||
|
||||
const resolveKeyChainSets = (chatRootId) => {
|
||||
|
|
@ -248,13 +326,13 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const { c, undecryptable } = decryptChatContent(rawC, rootId)
|
||||
|
||||
let ownerContent = c
|
||||
let ownerAuthor = c.author || node.author
|
||||
let ownerAuthor = 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
|
||||
ownerAuthor = ownerNode.author
|
||||
}
|
||||
}
|
||||
const memberToggles = idx && idx.memberByRoot ? idx.memberByRoot.get(rootId) : null
|
||||
|
|
@ -275,7 +353,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
tags: safeArr(c.tags),
|
||||
members,
|
||||
invites,
|
||||
author: c.author || node.author,
|
||||
author: node.author,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || null,
|
||||
encrypted: !!c.encrypted,
|
||||
|
|
@ -304,8 +382,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
chatId: c.chatId || "",
|
||||
text,
|
||||
image: c.image || null,
|
||||
replyTo: c.replyTo || null,
|
||||
author: c.author || node.author,
|
||||
mimeType: typeof c.mimeType === "string" ? c.mimeType : null,
|
||||
replyTo: typeof c.replyTo === "string" ? c.replyTo : null,
|
||||
author: node.author,
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString()
|
||||
}
|
||||
}
|
||||
|
|
@ -317,28 +396,27 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
})
|
||||
|
||||
const ensureMemberKeys = async (ssbClient, messages, chats) => {
|
||||
if (!tribeCrypto) return
|
||||
const distributed = new Map()
|
||||
if (!tribeCrypto || !ownCrypto) return
|
||||
const leavesByRoot = 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)
|
||||
if (!c || c.type !== "chatMember" || c.on !== false || !c.target || !c.member) continue
|
||||
if (!leavesByRoot.has(c.target)) leavesByRoot.set(c.target, [])
|
||||
leavesByRoot.get(c.target).push({ member: c.member, author: m.value.author, ts: Number(m.timestamp || m.value.timestamp || 0) })
|
||||
}
|
||||
const ssbKeys = require("../server/node_modules/ssb-keys")
|
||||
for (const chat of safeArr(chats)) {
|
||||
if (!chat || chat.undecryptable) continue
|
||||
if (!chat || chat.undecryptable || chat.author !== ssbClient.id) continue
|
||||
const rootId = chat.rootId
|
||||
if (!rootId) continue
|
||||
if (!rootId || !lookupKey(rootId)) continue
|
||||
const members = safeArr(chat.members)
|
||||
const leaves = (leavesByRoot.get(rootId) || []).filter(l => l.author === l.member || l.author === chat.author)
|
||||
const plan = ownCrypto.keyPlan({ rootId, ownerId: chat.author, gen: lookupGen(rootId), members, messages, leaves })
|
||||
if (plan.rotate) { await rotateChatKey(rootId, members); continue }
|
||||
if (!plan.missing.length) 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) {
|
||||
for (const m of plan.missing) {
|
||||
try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {}
|
||||
}
|
||||
if (!Object.keys(memberKeys).length) continue
|
||||
|
|
@ -352,9 +430,22 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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
|
||||
const proofKey = on && member === ssbClient.id ? lookupKey(rootId) : null
|
||||
if (proofKey) content.keyProof = keyProofFor(proofKey, member)
|
||||
ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))
|
||||
})
|
||||
|
||||
const decryptTribeChats = async (idx) => {
|
||||
if (!tribeCrypto || !tribesModel || typeof tribeCrypto.decryptFromTribe !== "function") return idx
|
||||
for (const [k, n] of idx.nodes) {
|
||||
const c = n.c || {}
|
||||
if (!c.encryptedPayload || !c.tribeId) continue
|
||||
const dec = await tribeCrypto.decryptFromTribe(c, tribesModel).catch(() => null)
|
||||
if (dec && !dec._undecryptable) idx.nodes.set(k, { ...n, c: dec })
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
const collectChats = (idx) => {
|
||||
const out = []
|
||||
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
|
||||
|
|
@ -383,11 +474,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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)
|
||||
const root = idx.rootOf(id)
|
||||
if (idx.tomb.has(idx.tipOf(root))) throw new Error("Not found")
|
||||
return root
|
||||
},
|
||||
|
||||
|
|
@ -395,13 +483,12 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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)
|
||||
const tip = idx.tipOf(idx.rootOf(id))
|
||||
if (idx.tomb.has(tip)) throw new Error("Not found")
|
||||
return tip
|
||||
},
|
||||
|
||||
async createChat(title, description, image, category, status, tagsRaw, tribeId) {
|
||||
async createChat(title, description, image, category, status, tagsRaw, tribeId, extra = {}) {
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
const blobId = image ? String(image).trim() || null : null
|
||||
|
|
@ -422,7 +509,8 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
author: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(tribeId ? { tribeId } : {})
|
||||
...(tribeId ? { tribeId } : {}),
|
||||
...(extra && typeof extra === "object" && typeof extra.courseId === "string" && extra.courseId ? { courseId: extra.courseId } : {})
|
||||
}
|
||||
|
||||
if (!tribeCrypto) {
|
||||
|
|
@ -478,13 +566,11 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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")
|
||||
if (item.author !== 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 rootId = idx.rootOf(tipId)
|
||||
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")
|
||||
|
|
@ -544,7 +630,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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"))
|
||||
if (item.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())
|
||||
})
|
||||
})
|
||||
|
|
@ -555,11 +641,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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)
|
||||
const root = idx.rootOf(id)
|
||||
const tip = idx.tipOf(root)
|
||||
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")
|
||||
|
|
@ -592,18 +676,15 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
async getChatById(id) {
|
||||
const ssbClient = await openSsb()
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
const idx = await decryptTribeChats(memoIndex('chats', messages, buildIndex))
|
||||
|
||||
let tip = id
|
||||
while (idx.child.has(tip)) tip = idx.child.get(tip)
|
||||
const root = idx.rootOf(id)
|
||||
const tip = idx.tipOf(root)
|
||||
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))
|
||||
|
|
@ -613,7 +694,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
const ssbClient = await openSsb()
|
||||
const uid = viewerId || ssbClient.id
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
const idx = await decryptTribeChats(buildIndex(messages))
|
||||
const now = Date.now()
|
||||
|
||||
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
|
||||
|
|
@ -631,7 +712,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
list = list.map(c => ({ ...c, lastMsgAt: lastMsgAt.get(c.rootId) || 0, lastMineAt: lastMineAt.get(c.rootId) || 0 }))
|
||||
|
||||
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 === "recent") list = list.filter(c => Math.max(new Date(c.createdAt).getTime() || 0, Number(c.lastMsgAt || 0)) >= 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")
|
||||
|
||||
|
|
@ -665,13 +746,16 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (tribeCrypto) {
|
||||
const inviteSalt = tribeCrypto.generateInviteSalt()
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
|
||||
const ident = opts.public ? { code, ...pubFlag } : { ch: hashCode(code, inviteSalt) }
|
||||
if (ekChain) {
|
||||
invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId), ...pubFlag }
|
||||
invite = { ...ident, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId) }
|
||||
} 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 }
|
||||
invite = { ...ident, ek, salt: inviteSalt, gen: lookupGen(chat.rootId) }
|
||||
} else {
|
||||
invite = { ...ident, salt: inviteSalt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -702,6 +786,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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 })
|
||||
if (!chat.tribeId) await rotateChatKey(chat.rootId, chat.members)
|
||||
},
|
||||
|
||||
async joinByInvite(code) {
|
||||
|
|
@ -721,12 +806,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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 (inviteMatches(inv, code)) { matchedChat = chat; matchedInvite = inv; break }
|
||||
}
|
||||
if (matchedChat) break
|
||||
}
|
||||
|
|
@ -735,29 +815,40 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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")
|
||||
const token = inviteToken(matchedInvite) || code
|
||||
|
||||
let chatKey = null
|
||||
let chain = null
|
||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
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
|
||||
}
|
||||
chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
||||
if (Array.isArray(chain) && chain.length) chatKey = chain[0].key
|
||||
else chain = null
|
||||
} 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 (!isPublic) {
|
||||
if (idx.isCodeConsumed(matchedChat.rootId, token) || idx.isCodeConsumed(matchedChat.rootId, code)) throw new Error("Invite already used")
|
||||
const claims = idx.codeClaims(matchedChat.rootId, [token, code]).filter(c => c.member !== userId)
|
||||
const spent = chatKey ? claims.some(c => c.byOwner || keyProofFor(chatKey, c.member) === c.keyProof) : claims.length > 0
|
||||
if (spent) throw new Error("Invite already used")
|
||||
}
|
||||
|
||||
if (chain) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else if (chatKey) {
|
||||
ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
|
||||
}
|
||||
|
||||
await publishMemberToggle(ssbClient, matchedChat.rootId, userId, true, isPublic ? undefined : token)
|
||||
|
||||
if (tribeCrypto && chatKey) {
|
||||
try {
|
||||
|
|
@ -801,9 +892,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
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() },
|
||||
|
|
@ -825,7 +914,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
} catch (_) { return 0 }
|
||||
},
|
||||
|
||||
async sendMessage(chatId, text, image = null, replyTo = null) {
|
||||
async sendMessage(chatId, text, image = null, replyTo = null, mimeType = null) {
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
const chat = await this.getChatById(chatId)
|
||||
|
|
@ -857,7 +946,14 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
createdAt: now
|
||||
}
|
||||
if (image) content.image = image
|
||||
if (replyTo && typeof replyTo === "string" && replyTo.startsWith("%")) content.replyTo = replyTo
|
||||
if (image && mimeType) content.mimeType = String(mimeType)
|
||||
if (replyTo) {
|
||||
const idxAll = buildIndex(messages)
|
||||
const target = idxAll.msgNodes.get(replyTo)
|
||||
if (target && (target.c.chatId === chat.rootId || idxAll.rawRootOf(target.c.chatId) === idxAll.rawRootOf(chat.rootId))) {
|
||||
content.replyTo = replyTo
|
||||
}
|
||||
}
|
||||
|
||||
const chatIsEncrypted = !!(chat.tribeId) || !!lookupKey(chat.rootId)
|
||||
if (chatIsEncrypted && tribeCrypto) {
|
||||
|
|
@ -896,10 +992,74 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
|||
if (msg) result.push(msg)
|
||||
}
|
||||
|
||||
const viewerId = ssbClient.id
|
||||
let chatAuthor = null
|
||||
if (chatNode) chatAuthor = chatNode.author
|
||||
const reactsByTarget = new Map()
|
||||
for (const r of idx.reactionByKey.values()) {
|
||||
if (!r.on) continue
|
||||
if (!reactsByTarget.has(r.target)) reactsByTarget.set(r.target, [])
|
||||
reactsByTarget.get(r.target).push(r)
|
||||
}
|
||||
const byKey = new Map(result.map(m => [m.key, m]))
|
||||
for (const m of result) {
|
||||
const counts = {}
|
||||
const mine = {}
|
||||
for (const e of REACTION_EMOJIS) { counts[e] = 0; mine[e] = false }
|
||||
for (const r of reactsByTarget.get(m.key) || []) {
|
||||
counts[r.emoji] += 1
|
||||
if (r.author === viewerId) mine[r.emoji] = true
|
||||
}
|
||||
m.reactions = { counts, mine }
|
||||
const pin = idx.pinByTarget.get(m.key)
|
||||
m.pinned = !!(pin && pin.on && chatAuthor && pin.author === chatAuthor)
|
||||
const quoted = m.replyTo ? byKey.get(m.replyTo) : null
|
||||
m.reply = quoted ? { author: quoted.author, text: String(quoted.text || "").slice(0, 120) } : null
|
||||
}
|
||||
|
||||
result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
return result
|
||||
},
|
||||
|
||||
async toggleReaction(chatId, targetKey, emoji) {
|
||||
if (!REACTION_EMOJIS.includes(emoji)) throw new Error("Invalid reaction")
|
||||
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 idx = buildIndex(messages)
|
||||
const target = idx.msgNodes.get(targetKey)
|
||||
if (!target || (target.c.chatId !== chat.rootId && idx.rawRootOf(target.c.chatId) !== idx.rawRootOf(chat.rootId))) throw new Error("Message not found")
|
||||
const prev = idx.reactionByKey.get(`${targetKey}|${userId}|${emoji}`)
|
||||
const on = !(prev && prev.on)
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: "chatReaction", chatId: chat.rootId, target: targetKey, emoji, on, author: userId, createdAt: new Date().toISOString() }, (e, res) => e ? reject(e) : resolve(res))
|
||||
})
|
||||
},
|
||||
|
||||
async togglePin(chatId, targetKey) {
|
||||
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 chat author can pin")
|
||||
const messages = await readAll(ssbClient)
|
||||
const idx = buildIndex(messages)
|
||||
const target = idx.msgNodes.get(targetKey)
|
||||
if (!target || (target.c.chatId !== chat.rootId && idx.rawRootOf(target.c.chatId) !== idx.rawRootOf(chat.rootId))) throw new Error("Message not found")
|
||||
const prev = idx.pinByTarget.get(targetKey)
|
||||
const on = !(prev && prev.on && prev.author === userId)
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: "chatPin", chatId: chat.rootId, target: targetKey, on, author: userId, createdAt: new Date().toISOString() }, (e, res) => e ? reject(e) : resolve(res))
|
||||
})
|
||||
},
|
||||
|
||||
async getParticipants(chatRootId) {
|
||||
const chat = await this.getChatById(chatRootId)
|
||||
if (!chat) return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue