chats_model.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. const pull = require("../server/node_modules/pull-stream")
  2. const crypto = require("crypto")
  3. const { getConfig } = require("../configs/config-manager.js")
  4. const { buildValidatedTombstoneSet } = require('./tombstone_validator')
  5. const logLimit = getConfig().ssbLogStream?.limit || 1000
  6. const safeArr = (v) => (Array.isArray(v) ? v : [])
  7. const safeText = (v) => String(v || "").trim()
  8. const normalizeTags = (raw) => {
  9. if (raw === undefined || raw === null) return []
  10. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  11. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  12. }
  13. const INVITE_CODE_BYTES = 16
  14. const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
  15. module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
  16. let ssb
  17. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  18. const ownCrypto = chatCrypto || tribeCrypto
  19. const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
  20. const lookupKeys = (rid) => {
  21. const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
  22. if (a.length) return a
  23. return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
  24. }
  25. const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
  26. const rotateChatKey = async (rootId, remainingMembers) => {
  27. if (!ownCrypto || !tribeCrypto || !rootId) return
  28. const existing = lookupKey(rootId)
  29. if (!existing) return
  30. const newKey = ownCrypto.generateTribeKey()
  31. const newGen = ownCrypto.addNewKey(rootId, newKey)
  32. if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
  33. const ssbClient = await openSsb()
  34. const ssbKeys = require("../server/node_modules/ssb-keys")
  35. const memberKeys = {}
  36. for (const m of remainingMembers) {
  37. try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
  38. }
  39. if (Object.keys(memberKeys).length) {
  40. await new Promise((resolve) => {
  41. ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
  42. })
  43. }
  44. }
  45. const ingestOwnTribeKeys = async () => {
  46. if (!ownCrypto) return
  47. try {
  48. const ssbClient = await openSsb()
  49. const ssbKeys = require("../server/node_modules/ssb-keys")
  50. const config = require("../server/ssb_config")
  51. const msgs = await readAll(ssbClient)
  52. for (const m of msgs) {
  53. const c = m.value && m.value.content
  54. if (!c || c.type !== "tribe-keys") continue
  55. const memberKeys = c.memberKeys
  56. if (!memberKeys || typeof memberKeys !== "object") continue
  57. const boxed = memberKeys[ssbClient.id]
  58. if (!boxed) continue
  59. try {
  60. const unboxed = ssbKeys.unbox(boxed, config.keys)
  61. const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
  62. if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
  63. } catch (_) {}
  64. }
  65. } catch (_) {}
  66. }
  67. const getTribeKeysFor = async (tribeId) => {
  68. if (!tribeCrypto || !tribesModel || !tribeId) return []
  69. try {
  70. const rootId = await tribesModel.getRootId(tribeId)
  71. return tribeCrypto.getKeys(rootId) || []
  72. } catch (_) { return [] }
  73. }
  74. const getTribeFirstKeyFor = async (tribeId) => {
  75. const ks = await getTribeKeysFor(tribeId)
  76. return ks.length ? ks[0] : null
  77. }
  78. const readAll = async (ssbClient) =>
  79. new Promise((resolve, reject) =>
  80. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  81. )
  82. const buildIndex = (messages) => {
  83. const tomb = new Set()
  84. const nodes = new Map()
  85. const parent = new Map()
  86. const child = new Map()
  87. const msgNodes = new Map()
  88. const authorByKey = new Map()
  89. const tombRequests = []
  90. for (const m of messages) {
  91. const k = m.key
  92. const v = m.value || {}
  93. const c = v.content
  94. if (!c) continue
  95. if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
  96. if (c.type === "chat") {
  97. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  98. authorByKey.set(k, v.author)
  99. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  100. } else if (c.type === "chatMessage") {
  101. msgNodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  102. authorByKey.set(k, v.author)
  103. }
  104. }
  105. for (const t of tombRequests) {
  106. const targetAuthor = authorByKey.get(t.target)
  107. if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
  108. }
  109. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  110. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  111. const roots = new Set()
  112. for (const id of nodes.keys()) roots.add(rootOf(id))
  113. const tipByRoot = new Map()
  114. for (const r of roots) tipByRoot.set(r, tipOf(r))
  115. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, msgNodes }
  116. }
  117. const resolveKeyChainSets = (chatRootId) => {
  118. if (!tribeCrypto) return []
  119. const keys = lookupKeys(chatRootId)
  120. return keys.map(k => [k])
  121. }
  122. const buildChat = (node, rootId) => {
  123. const rawC = node.c || {}
  124. if (rawC.type !== "chat") return null
  125. let c = rawC
  126. let undecryptable = false
  127. if (tribeCrypto && c.encryptedPayload) {
  128. const keyChainSets = resolveKeyChainSets(rootId)
  129. c = tribeCrypto.decryptContent(c, keyChainSets)
  130. undecryptable = !!c._undecryptable
  131. }
  132. const invites = safeArr(c.invites)
  133. const hasPublicInvite = invites.some(inv => typeof inv === "object" && inv && inv.public === true)
  134. const inferredStatus = c.status || (undecryptable ? (hasPublicInvite ? "OPEN" : "INVITE-ONLY") : "OPEN")
  135. return {
  136. key: node.key,
  137. rootId,
  138. title: c.title || "",
  139. description: c.description || "",
  140. image: c.image || null,
  141. category: c.category || "",
  142. status: inferredStatus,
  143. tags: safeArr(c.tags),
  144. members: safeArr(c.members),
  145. invites,
  146. author: c.author || node.author,
  147. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  148. updatedAt: c.updatedAt || null,
  149. encrypted: !!c.encrypted,
  150. tribeId: c.tribeId || null,
  151. undecryptable
  152. }
  153. }
  154. const buildMessage = (node, chatRootId, tribeKeys = []) => {
  155. const c = node.c || {}
  156. if (c.type !== "chatMessage") return null
  157. let text = c.text || ""
  158. if (tribeCrypto && c.encryptedText) {
  159. const candidateKeys = [...tribeKeys, ...lookupKeys(chatRootId)]
  160. for (const keyHex of candidateKeys) {
  161. try {
  162. text = tribeCrypto.decryptWithKey(c.encryptedText, keyHex)
  163. break
  164. } catch (_) {}
  165. }
  166. }
  167. return {
  168. key: node.key,
  169. chatId: c.chatId || "",
  170. text,
  171. image: c.image || null,
  172. author: c.author || node.author,
  173. createdAt: c.createdAt || new Date(node.ts).toISOString()
  174. }
  175. }
  176. const publishTombstone = async (ssbClient, tipId) =>
  177. new Promise((resolve, reject) => {
  178. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
  179. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  180. })
  181. return {
  182. type: "chat",
  183. async resolveRootId(id) {
  184. const ssbClient = await openSsb()
  185. const messages = await readAll(ssbClient)
  186. const idx = buildIndex(messages)
  187. let tip = id
  188. while (idx.child.has(tip)) tip = idx.child.get(tip)
  189. if (idx.tomb.has(tip)) throw new Error("Not found")
  190. let root = tip
  191. while (idx.parent.has(root)) root = idx.parent.get(root)
  192. return root
  193. },
  194. async resolveCurrentId(id) {
  195. const ssbClient = await openSsb()
  196. const messages = await readAll(ssbClient)
  197. const idx = buildIndex(messages)
  198. let tip = id
  199. while (idx.child.has(tip)) tip = idx.child.get(tip)
  200. if (idx.tomb.has(tip)) throw new Error("Not found")
  201. return tip
  202. },
  203. async createChat(title, description, image, category, status, tagsRaw, tribeId) {
  204. const ssbClient = await openSsb()
  205. const userId = ssbClient.id
  206. const blobId = image ? String(image).trim() || null : null
  207. const tags = normalizeTags(tagsRaw)
  208. const st = VALID_STATUS.includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  209. const now = new Date().toISOString()
  210. let content = {
  211. type: "chat",
  212. title: safeText(title),
  213. description: safeText(description),
  214. image: blobId,
  215. category: safeText(category),
  216. status: st,
  217. tags,
  218. members: [userId],
  219. invites: [],
  220. author: userId,
  221. createdAt: now,
  222. updatedAt: now,
  223. ...(tribeId ? { tribeId } : {})
  224. }
  225. if (!tribeCrypto) {
  226. return new Promise((resolve, reject) => {
  227. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  228. })
  229. }
  230. if (tribeId) {
  231. try {
  232. const ancestryIds = await tribesModel.getAncestryChain(tribeId)
  233. const chain = []
  234. for (const rid of ancestryIds || []) {
  235. const k = tribeCrypto.getKey(rid)
  236. if (!k) { chain.length = 0; break }
  237. chain.push(k)
  238. }
  239. if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
  240. } catch (_) {}
  241. return new Promise((resolve, reject) => {
  242. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  243. })
  244. }
  245. const chatKey = ownCrypto.generateTribeKey()
  246. if (st === "OPEN") {
  247. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
  248. const ek = tribeCrypto.encryptForInvite(chatKey, code)
  249. content.invites = [{ code, ek, gen: 1, public: true }]
  250. }
  251. content = tribeCrypto.encryptContent(content, [chatKey], true)
  252. const result = await new Promise((resolve, reject) => {
  253. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  254. })
  255. ownCrypto.setKey(result.key, chatKey, 1)
  256. try {
  257. const ssbKeys = require("../server/node_modules/ssb-keys")
  258. const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
  259. await new Promise((resolve) => {
  260. ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
  261. })
  262. } catch (_) {}
  263. return result
  264. },
  265. async updateChatById(id, data, { skipAuthorCheck = false } = {}) {
  266. const tipId = await this.resolveCurrentId(id)
  267. const ssbClient = await openSsb()
  268. const userId = ssbClient.id
  269. const item = await new Promise((resolve, reject) => {
  270. ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
  271. })
  272. const c = item.content
  273. const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
  274. if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
  275. const messages = await readAll(ssbClient)
  276. const idx = buildIndex(messages)
  277. let rootId = tipId
  278. while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
  279. const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
  280. const chat = buildChat(node, rootId)
  281. if (!chat) throw new Error("Invalid chat")
  282. let updated = {
  283. type: "chat",
  284. replaces: tipId,
  285. title: data.title !== undefined ? safeText(data.title) : chat.title,
  286. description: data.description !== undefined ? safeText(data.description) : chat.description,
  287. image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
  288. category: data.category !== undefined ? safeText(data.category) : chat.category,
  289. status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
  290. tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
  291. members: data.members !== undefined ? safeArr(data.members) : chat.members,
  292. invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
  293. author: chat.author,
  294. createdAt: chat.createdAt,
  295. updatedAt: new Date().toISOString(),
  296. ...(chat.tribeId ? { tribeId: chat.tribeId } : {})
  297. }
  298. if (tribeCrypto) {
  299. if (chat.tribeId) {
  300. try {
  301. const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
  302. const chain = []
  303. for (const rid of ancestryIds || []) {
  304. const k = tribeCrypto.getKey(rid)
  305. if (!k) { chain.length = 0; break }
  306. chain.push(k)
  307. }
  308. if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
  309. } catch (_) {}
  310. } else {
  311. const chatKey = lookupKey(rootId)
  312. if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
  313. }
  314. }
  315. return new Promise((resolve, reject) => {
  316. ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
  317. if (e1) return reject(e1)
  318. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  319. })
  320. })
  321. },
  322. async deleteChatById(id) {
  323. const tipId = await this.resolveCurrentId(id)
  324. const ssbClient = await openSsb()
  325. const userId = ssbClient.id
  326. return new Promise((resolve, reject) => {
  327. ssbClient.get(tipId, (err, item) => {
  328. if (err || !item?.content) return reject(new Error("Chat not found"))
  329. if (item.content.author && item.content.author !== userId) return reject(new Error("Not the author"))
  330. ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  331. })
  332. })
  333. },
  334. async closeChatById(id) {
  335. const ssbClient = await openSsb()
  336. const userId = ssbClient.id
  337. const messages = await readAll(ssbClient)
  338. const idx = buildIndex(messages)
  339. let tip = id
  340. while (idx.child.has(tip)) tip = idx.child.get(tip)
  341. if (idx.tomb.has(tip)) throw new Error("Not found")
  342. let root = tip
  343. while (idx.parent.has(root)) root = idx.parent.get(root)
  344. const node = idx.nodes.get(tip)
  345. if (!node) throw new Error("Not found")
  346. const chat = buildChat(node, root)
  347. if (!chat) throw new Error("Invalid chat")
  348. if (chat.author !== userId) throw new Error("Not the author")
  349. const updated = {
  350. type: "chat",
  351. replaces: tip,
  352. title: chat.title,
  353. description: chat.description,
  354. image: chat.image,
  355. category: chat.category,
  356. status: "CLOSED",
  357. tags: chat.tags,
  358. members: chat.members,
  359. invites: chat.invites,
  360. author: chat.author,
  361. createdAt: chat.createdAt,
  362. updatedAt: new Date().toISOString()
  363. }
  364. await publishTombstone(ssbClient, tip)
  365. return new Promise((resolve, reject) => {
  366. ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))
  367. })
  368. },
  369. async getChatById(id) {
  370. const ssbClient = await openSsb()
  371. const messages = await readAll(ssbClient)
  372. const idx = buildIndex(messages)
  373. let tip = id
  374. while (idx.child.has(tip)) tip = idx.child.get(tip)
  375. if (idx.tomb.has(tip)) return null
  376. const node = idx.nodes.get(tip)
  377. if (!node || node.c.type !== "chat") return null
  378. let root = tip
  379. while (idx.parent.has(root)) root = idx.parent.get(root)
  380. const chat = buildChat(node, root)
  381. if (!chat) return null
  382. return chat
  383. },
  384. async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
  385. const ssbClient = await openSsb()
  386. const uid = viewerId || ssbClient.id
  387. const messages = await readAll(ssbClient)
  388. const idx = buildIndex(messages)
  389. const now = Date.now()
  390. const items = []
  391. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  392. if (idx.tomb.has(tipId)) continue
  393. const node = idx.nodes.get(tipId)
  394. if (!node || node.c.type !== "chat") continue
  395. const chat = buildChat(node, rootId)
  396. if (!chat) continue
  397. const isMember = chat.author === uid || safeArr(chat.members).includes(uid)
  398. if (chat.undecryptable && !isMember && chat.status === "INVITE-ONLY") continue
  399. items.push(chat)
  400. }
  401. let list = items
  402. if (filter === "mine") list = list.filter(c => c.author === uid)
  403. else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
  404. else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
  405. else if (filter === "closed") list = list.filter(c => c.status === "CLOSED")
  406. if (q) {
  407. const qq = q.toLowerCase()
  408. list = list.filter(c => {
  409. const t = String(c.title || "").toLowerCase()
  410. const d = String(c.description || "").toLowerCase()
  411. const cat = String(c.category || "").toLowerCase()
  412. const tags = safeArr(c.tags).join(" ").toLowerCase()
  413. return t.includes(qq) || d.includes(qq) || cat.includes(qq) || tags.includes(qq)
  414. })
  415. }
  416. list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  417. return list
  418. },
  419. async generateInvite(chatId) {
  420. const ssbClient = await openSsb()
  421. const userId = ssbClient.id
  422. const chat = await this.getChatById(chatId)
  423. if (!chat) throw new Error("Chat not found")
  424. if (chat.author !== userId) throw new Error("Only the author can generate invites")
  425. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
  426. let invite = code
  427. if (tribeCrypto) {
  428. const inviteSalt = tribeCrypto.generateInviteSalt()
  429. const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code, inviteSalt)
  430. if (ekChain) {
  431. invite = { code, ekChain, salt: inviteSalt, gen: lookupGen(chat.rootId) }
  432. } else {
  433. const chatKey = lookupKey(chat.rootId)
  434. if (chatKey) {
  435. const ek = tribeCrypto.encryptForInvite(chatKey, code, inviteSalt)
  436. invite = { code, ek, salt: inviteSalt, gen: lookupGen(chat.rootId) }
  437. }
  438. }
  439. }
  440. const invites = [...chat.invites, invite]
  441. 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 })
  442. return code
  443. },
  444. async joinByInvite(code) {
  445. const ssbClient = await openSsb()
  446. const userId = ssbClient.id
  447. const messages = await readAll(ssbClient)
  448. const idx = buildIndex(messages)
  449. let matchedChat = null
  450. let matchedInvite = null
  451. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  452. if (idx.tomb.has(tipId)) continue
  453. const node = idx.nodes.get(tipId)
  454. if (!node || node.c.type !== "chat") continue
  455. const chat = buildChat(node, rootId)
  456. if (!chat || !chat.invites.length) continue
  457. for (const inv of chat.invites) {
  458. if (typeof inv === "string" && inv === code) {
  459. matchedChat = chat; matchedInvite = inv; break
  460. }
  461. if (typeof inv === "object" && inv.code === code) {
  462. matchedChat = chat; matchedInvite = inv; break
  463. }
  464. }
  465. if (matchedChat) break
  466. }
  467. if (!matchedChat) throw new Error("Invalid or expired invite code")
  468. if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
  469. let chatKey = null
  470. if (tribeCrypto && typeof matchedInvite === "object") {
  471. if (matchedInvite.ekChain) {
  472. const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
  473. if (Array.isArray(chain) && chain.length) {
  474. for (const entry of chain) {
  475. if (Array.isArray(entry.keys) && entry.keys.length) {
  476. tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
  477. } else if (entry.key) {
  478. tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
  479. }
  480. }
  481. chatKey = chain[0].key
  482. }
  483. } else if (matchedInvite.ek) {
  484. chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
  485. ownCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
  486. }
  487. }
  488. const members = [...matchedChat.members, userId]
  489. const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
  490. const invites = isPublicInvite ? matchedChat.invites : matchedChat.invites.filter(inv => {
  491. if (typeof inv === "string") return inv !== code
  492. return inv.code !== code
  493. })
  494. await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true })
  495. if (tribeCrypto && chatKey) {
  496. try {
  497. const ssbKeys = require("../server/node_modules/ssb-keys")
  498. const memberKeys = {}
  499. try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
  500. if (matchedChat.author && matchedChat.author !== userId) {
  501. try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
  502. }
  503. if (Object.keys(memberKeys).length) {
  504. await new Promise((resolve) => {
  505. ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: lookupGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
  506. })
  507. }
  508. } catch (_) {}
  509. }
  510. return matchedChat.key
  511. },
  512. async joinChat(chatId) {
  513. const ssbClient = await openSsb()
  514. const userId = ssbClient.id
  515. const chat = await this.getChatById(chatId)
  516. if (!chat) throw new Error("Chat not found")
  517. if (chat.status === "CLOSED") throw new Error("Chat is closed")
  518. if (chat.members.includes(userId)) return chat.key
  519. if (tribeCrypto && Array.isArray(chat.invites)) {
  520. const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
  521. if (pub) return await this.joinByInvite(pub.code)
  522. }
  523. const members = [...chat.members, userId]
  524. await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
  525. return chat.key
  526. },
  527. async leaveChat(chatId) {
  528. const ssbClient = await openSsb()
  529. const userId = ssbClient.id
  530. const chat = await this.getChatById(chatId)
  531. if (!chat) throw new Error("Chat not found")
  532. if (chat.author === userId) throw new Error("Author cannot leave their own chat")
  533. const members = chat.members.filter(m => m !== userId)
  534. await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
  535. try { await rotateChatKey(chat.rootId, members) } catch (_) {}
  536. },
  537. async ingestKeys() { await ingestOwnTribeKeys() },
  538. async pruneOrphanKeys() {
  539. if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
  540. try {
  541. const ssbClient = await openSsb()
  542. const messages = await readAll(ssbClient)
  543. const live = new Set()
  544. for (const m of messages) {
  545. const c = m.value && m.value.content
  546. if (!c) continue
  547. if (c.type === "chat") live.add(m.key)
  548. }
  549. const tomb = buildValidatedTombstoneSet(messages)
  550. const all = ownCrypto.getAllRootIds()
  551. let removed = 0
  552. for (const rid of all) {
  553. if (!live.has(rid) || tomb.has(rid)) {
  554. try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
  555. }
  556. }
  557. return removed
  558. } catch (_) { return 0 }
  559. },
  560. async sendMessage(chatId, text, image = null) {
  561. const ssbClient = await openSsb()
  562. const userId = ssbClient.id
  563. const chat = await this.getChatById(chatId)
  564. if (!chat) throw new Error("Chat not found")
  565. if (chat.status === "CLOSED") throw new Error("Chat is closed")
  566. if (!chat.members.includes(userId)) {
  567. if (chat.status === "OPEN") await this.joinChat(chatId)
  568. else throw new Error("Not a participant")
  569. }
  570. const messages = await readAll(ssbClient)
  571. const oneHourAgo = Date.now() - 60 * 60 * 1000
  572. const recentCount = messages.filter(m => {
  573. const c = m.value?.content
  574. return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
  575. }).length
  576. if (recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
  577. const now = new Date().toISOString()
  578. let content = {
  579. type: "chatMessage",
  580. chatId: chat.rootId,
  581. author: userId,
  582. createdAt: now
  583. }
  584. if (image) content.image = image
  585. if (tribeCrypto) {
  586. let encKey = null
  587. if (chat.tribeId) encKey = await getTribeFirstKeyFor(chat.tribeId)
  588. if (!encKey) encKey = lookupKey(chat.rootId)
  589. if (!encKey) throw new Error(`Missing chat key for ${chat.rootId} — cannot send message`)
  590. content.encryptedText = tribeCrypto.encryptWithKey(safeText(text), encKey)
  591. if (chat.tribeId) content.tribeId = chat.tribeId
  592. } else {
  593. throw new Error('Chat crypto unavailable — cannot send message')
  594. }
  595. return new Promise((resolve, reject) => {
  596. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  597. })
  598. },
  599. async listMessages(chatRootId) {
  600. const ssbClient = await openSsb()
  601. const messages = await readAll(ssbClient)
  602. const idx = buildIndex(messages)
  603. let tribeId = null
  604. const tipId = idx.tipByRoot.get(chatRootId) || chatRootId
  605. const chatNode = idx.nodes.get(tipId) || idx.nodes.get(chatRootId)
  606. if (chatNode?.c?.tribeId) tribeId = chatNode.c.tribeId
  607. const tribeKeys = tribeId ? await getTribeKeysFor(tribeId) : []
  608. const result = []
  609. for (const [k, node] of idx.msgNodes.entries()) {
  610. if (node.c.chatId !== chatRootId) continue
  611. const msg = buildMessage(node, chatRootId, tribeKeys)
  612. if (msg) result.push(msg)
  613. }
  614. result.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  615. return result
  616. },
  617. async getParticipants(chatRootId) {
  618. const chat = await this.getChatById(chatRootId)
  619. if (!chat) return []
  620. return chat.members
  621. }
  622. }
  623. }