pads_model.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. const pull = require("../server/node_modules/pull-stream")
  2. const crypto = require("crypto")
  3. const fs = require("fs")
  4. const { buildValidatedTombstoneSet } = require('./tombstone_validator')
  5. const path = require("path")
  6. const { getConfig } = require("../configs/config-manager.js")
  7. const logLimit = getConfig().ssbLogStream?.limit || 1000
  8. const safeText = (v) => String(v || "").trim()
  9. const normalizeTags = (raw) => {
  10. if (!raw) return []
  11. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  12. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  13. }
  14. const INVITE_SALT = "SolarNET.HuB-pads"
  15. const INVITE_BYTES = 16
  16. const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
  17. module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => {
  18. let ssb
  19. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  20. const ownCrypto = padCrypto || tribeCrypto
  21. const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
  22. const lookupKeys = (rid) => {
  23. const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
  24. if (a.length) return a
  25. return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
  26. }
  27. const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
  28. let keyringPath = null
  29. let migratedToTribeCrypto = false
  30. const getLegacyKeyringPath = () => {
  31. if (!keyringPath) {
  32. const ssbConfig = require("../server/node_modules/ssb-config/inject")()
  33. keyringPath = path.join(ssbConfig.path, "pad-keys.json")
  34. }
  35. return keyringPath
  36. }
  37. const migrateLegacyKeyring = () => {
  38. if (migratedToTribeCrypto || !ownCrypto) { migratedToTribeCrypto = true; return }
  39. migratedToTribeCrypto = true
  40. try {
  41. const p = getLegacyKeyringPath()
  42. if (!fs.existsSync(p)) return
  43. const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {}
  44. for (const [rootId, keyHex] of Object.entries(legacy)) {
  45. if (rootId && keyHex && !ownCrypto.getKey(rootId)) {
  46. ownCrypto.setKey(rootId, keyHex, 1)
  47. }
  48. }
  49. } catch (_) {}
  50. }
  51. const getPadKey = (rootId) => {
  52. migrateLegacyKeyring()
  53. if (ownCrypto) return lookupKey(rootId)
  54. try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
  55. }
  56. const setPadKey = (rootId, keyHex) => {
  57. migrateLegacyKeyring()
  58. if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return }
  59. let kr = {}
  60. try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
  61. kr[rootId] = keyHex
  62. fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8")
  63. }
  64. const encryptField = (text, keyHex) => {
  65. const key = Buffer.from(keyHex, "hex")
  66. const iv = crypto.randomBytes(12)
  67. const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
  68. const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
  69. const authTag = cipher.getAuthTag()
  70. return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
  71. }
  72. const decryptField = (encrypted, keyHex) => {
  73. try {
  74. const key = Buffer.from(keyHex, "hex")
  75. const iv = Buffer.from(encrypted.slice(0, 24), "hex")
  76. const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
  77. const ciphertext = Buffer.from(encrypted.slice(56), "hex")
  78. const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
  79. decipher.setAuthTag(authTag)
  80. return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
  81. } catch (_) { return "" }
  82. }
  83. const tryDecryptField = (encrypted, keyHex) => {
  84. const key = Buffer.from(keyHex, "hex")
  85. const iv = Buffer.from(encrypted.slice(0, 24), "hex")
  86. const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
  87. const ciphertext = Buffer.from(encrypted.slice(56), "hex")
  88. const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
  89. decipher.setAuthTag(authTag)
  90. return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
  91. }
  92. const getTribeKeysFor = async (tribeId) => {
  93. if (!tribeCrypto || !tribesModel || !tribeId) return []
  94. try {
  95. const rootId = await tribesModel.getRootId(tribeId)
  96. const keys = tribeCrypto.getKeys(rootId) || []
  97. return keys
  98. } catch (_) { return [] }
  99. }
  100. const decryptWithKeys = (c, keys) => {
  101. if (!c.title || !keys.length) return null
  102. for (const k of keys) {
  103. try {
  104. const title = tryDecryptField(c.title, k)
  105. let deadline = ""
  106. let tagsRaw = ""
  107. try { deadline = c.deadline ? tryDecryptField(c.deadline, k) : "" } catch (_) {}
  108. try { tagsRaw = c.tags ? tryDecryptField(c.tags, k) : "" } catch (_) {}
  109. return { title: safeText(title), deadline, tags: normalizeTags(tagsRaw) }
  110. } catch (_) {}
  111. }
  112. return null
  113. }
  114. const encryptForInvite = (padKeyHex, code, saltHex) => {
  115. const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
  116. const derived = crypto.scryptSync(code, salt, 32)
  117. return encryptField(padKeyHex, derived.toString("hex"))
  118. }
  119. const decryptFromInvite = (encryptedKey, code, saltHex) => {
  120. const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
  121. const derived = crypto.scryptSync(code, salt, 32)
  122. return decryptField(encryptedKey, derived.toString("hex"))
  123. }
  124. const tryDecryptPublicInviteKey = (invites) => {
  125. if (!Array.isArray(invites)) return null
  126. for (const inv of invites) {
  127. if (!inv || typeof inv !== "object") continue
  128. if (inv.public !== true) continue
  129. if (typeof inv.code !== "string" || typeof inv.ek !== "string") continue
  130. try {
  131. const key = decryptFromInvite(inv.ek, inv.code, inv.salt)
  132. if (key) return key
  133. } catch (_) {}
  134. }
  135. return null
  136. }
  137. const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
  138. const rotatePadKey = async (rootId, remainingMembers) => {
  139. if (!ownCrypto || !tribeCrypto || !rootId) return
  140. const existing = getPadKey(rootId)
  141. if (!existing) return
  142. const newKey = crypto.randomBytes(32).toString("hex")
  143. const newGen = ownCrypto.addNewKey(rootId, newKey)
  144. if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
  145. const ssbClient = await openSsb()
  146. const ssbKeys = require("../server/node_modules/ssb-keys")
  147. const memberKeys = {}
  148. for (const m of remainingMembers) {
  149. try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
  150. }
  151. if (Object.keys(memberKeys).length) {
  152. await new Promise((resolve) => {
  153. ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
  154. })
  155. }
  156. }
  157. const ingestOwnTribeKeys = async () => {
  158. if (!ownCrypto) return
  159. try {
  160. const ssbClient = await openSsb()
  161. const ssbKeys = require("../server/node_modules/ssb-keys")
  162. const config = require("../server/ssb_config")
  163. const msgs = await readAll(ssbClient)
  164. for (const m of msgs) {
  165. const c = m.value && m.value.content
  166. if (!c || c.type !== "tribe-keys") continue
  167. const memberKeys = c.memberKeys
  168. if (!memberKeys || typeof memberKeys !== "object") continue
  169. const boxed = memberKeys[ssbClient.id]
  170. if (!boxed) continue
  171. try {
  172. const unboxed = ssbKeys.unbox(boxed, config.keys)
  173. const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
  174. if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
  175. } catch (_) {}
  176. }
  177. } catch (_) {}
  178. }
  179. const readAll = async (ssbClient) =>
  180. new Promise((resolve, reject) =>
  181. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  182. )
  183. const buildIndex = (messages) => {
  184. const tomb = new Set()
  185. const nodes = new Map()
  186. const parent = new Map()
  187. const child = new Map()
  188. const authorByKey = new Map()
  189. const tombRequests = []
  190. for (const m of messages) {
  191. const k = m.key
  192. const v = m.value || {}
  193. const c = v.content
  194. if (!c) continue
  195. if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
  196. if (c.type === "pad") {
  197. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  198. authorByKey.set(k, v.author)
  199. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  200. }
  201. }
  202. for (const t of tombRequests) {
  203. const targetAuthor = authorByKey.get(t.target)
  204. if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
  205. }
  206. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  207. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  208. const roots = new Set()
  209. for (const id of nodes.keys()) roots.add(rootOf(id))
  210. const tipByRoot = new Map()
  211. for (const r of roots) tipByRoot.set(r, tipOf(r))
  212. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
  213. }
  214. const decryptPadFields = (c, rootId, tribeKeys) => {
  215. if (c.encrypted !== true) {
  216. return { title: safeText(c.title), deadline: c.deadline ? String(c.deadline) : "", tags: normalizeTags(c.tags) }
  217. }
  218. if (c.tribeId && Array.isArray(tribeKeys) && tribeKeys.length) {
  219. const viaTribe = decryptWithKeys(c, tribeKeys)
  220. if (viaTribe) return viaTribe
  221. }
  222. let keyHex = getPadKey(rootId)
  223. if (!keyHex) keyHex = tryDecryptPublicInviteKey(c.invites)
  224. if (!keyHex) return { title: "", deadline: "", tags: [] }
  225. const title = c.title ? decryptField(c.title, keyHex) : ""
  226. const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
  227. const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : ""
  228. const tags = normalizeTags(tagsRaw)
  229. return { title, deadline, tags }
  230. }
  231. const buildPad = (node, rootId, tribeKeys) => {
  232. const c = node.c || {}
  233. if (c.type !== "pad") return null
  234. const { title, deadline, tags } = decryptPadFields(c, rootId, tribeKeys)
  235. return {
  236. key: node.key,
  237. rootId,
  238. title,
  239. status: c.status || "OPEN",
  240. deadline,
  241. tags,
  242. author: c.author || node.author,
  243. members: Array.isArray(c.members) ? c.members : [],
  244. invites: Array.isArray(c.invites) ? c.invites : [],
  245. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  246. updatedAt: c.updatedAt || null,
  247. tribeId: c.tribeId || null,
  248. encrypted: c.encrypted === true
  249. }
  250. }
  251. const isClosed = (pad) => {
  252. if (pad.status === "CLOSED") return true
  253. if (!pad.deadline) return false
  254. return new Date(pad.deadline).getTime() <= Date.now()
  255. }
  256. return {
  257. type: "pad",
  258. async decryptContent(content, rootId) {
  259. const tKeys = content && content.tribeId ? await getTribeKeysFor(content.tribeId) : []
  260. return decryptPadFields(content, rootId, tKeys)
  261. },
  262. async resolveRootId(id) {
  263. const ssbClient = await openSsb()
  264. const messages = await readAll(ssbClient)
  265. const idx = buildIndex(messages)
  266. let tip = id
  267. while (idx.child.has(tip)) tip = idx.child.get(tip)
  268. if (idx.tomb.has(tip)) throw new Error("Not found")
  269. let root = tip
  270. while (idx.parent.has(root)) root = idx.parent.get(root)
  271. return root
  272. },
  273. async resolveCurrentId(id) {
  274. const ssbClient = await openSsb()
  275. const messages = await readAll(ssbClient)
  276. const idx = buildIndex(messages)
  277. let tip = id
  278. while (idx.child.has(tip)) tip = idx.child.get(tip)
  279. if (idx.tomb.has(tip)) throw new Error("Not found")
  280. return tip
  281. },
  282. async createPad(title, status, deadline, tagsRaw, tribeId) {
  283. const ssbClient = await openSsb()
  284. const now = new Date().toISOString()
  285. const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  286. const userId = ssbClient.id
  287. const tagsArr = normalizeTags(tagsRaw)
  288. const isPublicOpen = validStatus === "OPEN" && !tribeId
  289. if (isPublicOpen) {
  290. const content = {
  291. type: "pad",
  292. title: safeText(title),
  293. status: validStatus,
  294. deadline: deadline ? String(deadline) : "",
  295. tags: tagsArr,
  296. author: userId,
  297. members: [userId],
  298. invites: [],
  299. createdAt: now,
  300. updatedAt: now,
  301. encrypted: false
  302. }
  303. return new Promise((resolve, reject) => {
  304. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  305. })
  306. }
  307. let keyHex = null
  308. let usesTribeKey = false
  309. if (tribeId) {
  310. const tKeys = await getTribeKeysFor(tribeId)
  311. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  312. }
  313. if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
  314. const enc = (text) => encryptField(text, keyHex)
  315. const content = {
  316. type: "pad",
  317. title: enc(safeText(title)),
  318. status: validStatus,
  319. deadline: deadline ? enc(String(deadline)) : "",
  320. tags: enc(tagsArr.join(",")),
  321. author: userId,
  322. members: [userId],
  323. invites: [],
  324. createdAt: now,
  325. updatedAt: now,
  326. encrypted: true,
  327. ...(tribeId ? { tribeId } : {})
  328. }
  329. return new Promise((resolve, reject) => {
  330. ssbClient.publish(content, (err, msg) => {
  331. if (err) return reject(err)
  332. if (!usesTribeKey) {
  333. setPadKey(msg.key, keyHex)
  334. if (tribeCrypto) {
  335. try {
  336. const ssbKeys = require("../server/node_modules/ssb-keys")
  337. const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
  338. ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
  339. return
  340. } catch (_) {}
  341. }
  342. }
  343. resolve(msg)
  344. })
  345. })
  346. },
  347. async updatePadById(id, data) {
  348. const tipId = await this.resolveCurrentId(id)
  349. const ssbClient = await openSsb()
  350. const userId = ssbClient.id
  351. const rootId = await this.resolveRootId(id)
  352. return new Promise(async (resolve, reject) => {
  353. ssbClient.get(tipId, async (err, item) => {
  354. if (err || !item?.content) return reject(new Error("Pad not found"))
  355. if (item.content.author !== userId) return reject(new Error("Not the author"))
  356. const c = item.content
  357. const isEncrypted = c.encrypted === true
  358. let keyHex = null
  359. let usesTribeKey = false
  360. if (isEncrypted) {
  361. if (c.tribeId) {
  362. const tKeys = await getTribeKeysFor(c.tribeId)
  363. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  364. }
  365. if (!keyHex) keyHex = getPadKey(rootId)
  366. if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`))
  367. }
  368. const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text
  369. const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw)
  370. const updated = {
  371. ...c,
  372. title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
  373. status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
  374. deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline,
  375. tags: data.tags !== undefined ? tagsField(data.tags) : c.tags,
  376. updatedAt: new Date().toISOString(),
  377. replaces: tipId
  378. }
  379. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  380. ssbClient.publish(tombstone, (e1) => {
  381. if (e1) return reject(e1)
  382. ssbClient.publish(updated, (e2, res) => {
  383. if (e2) return reject(e2)
  384. if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
  385. resolve(res)
  386. })
  387. })
  388. })
  389. })
  390. },
  391. async closePadById(id) {
  392. const tipId = await this.resolveCurrentId(id)
  393. const ssbClient = await openSsb()
  394. const userId = ssbClient.id
  395. const rootId = await this.resolveRootId(id)
  396. return new Promise(async (resolve, reject) => {
  397. ssbClient.get(tipId, async (err, item) => {
  398. if (err || !item?.content) return reject(new Error("Pad not found"))
  399. if (item.content.author !== userId) return reject(new Error("Not the author"))
  400. const c = item.content
  401. let keyHex = null
  402. let usesTribeKey = false
  403. if (c.tribeId) {
  404. const tKeys = await getTribeKeysFor(c.tribeId)
  405. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  406. }
  407. if (!keyHex) keyHex = getPadKey(rootId)
  408. const updated = {
  409. ...c,
  410. status: "CLOSED",
  411. updatedAt: new Date().toISOString(),
  412. replaces: tipId
  413. }
  414. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  415. ssbClient.publish(tombstone, (e1) => {
  416. if (e1) return reject(e1)
  417. ssbClient.publish(updated, (e2, res) => {
  418. if (e2) return reject(e2)
  419. if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
  420. resolve(res)
  421. })
  422. })
  423. })
  424. })
  425. },
  426. async leavePad(padId) {
  427. const ssbClient = await openSsb()
  428. const userId = ssbClient.id
  429. const pad = await this.getPadById(padId)
  430. if (!pad) throw new Error("Pad not found")
  431. if (pad.author === userId) throw new Error("Author cannot leave their own pad")
  432. const members = (Array.isArray(pad.members) ? pad.members : []).filter(m => m !== userId)
  433. if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return
  434. const tipId = await this.resolveCurrentId(padId)
  435. const rootId = await this.resolveRootId(padId)
  436. await new Promise((resolve, reject) => {
  437. ssbClient.get(tipId, (err, item) => {
  438. if (err || !item?.content) return reject(new Error("Pad not found"))
  439. const updated = { ...item.content, members, updatedAt: new Date().toISOString(), replaces: tipId }
  440. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  441. ssbClient.publish(tombstone, (e1) => {
  442. if (e1) return reject(e1)
  443. ssbClient.publish(updated, (e2) => e2 ? reject(e2) : resolve())
  444. })
  445. })
  446. })
  447. try { await rotatePadKey(rootId, members) } catch (_) {}
  448. },
  449. async ingestKeys() { await ingestOwnTribeKeys() },
  450. async pruneOrphanKeys() {
  451. if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
  452. try {
  453. const ssbClient = await openSsb()
  454. const messages = await readAll(ssbClient)
  455. const live = new Set()
  456. const tomb = buildValidatedTombstoneSet(messages)
  457. for (const m of messages) {
  458. const c = m.value && m.value.content
  459. if (!c) continue
  460. if (c.type === "pad") live.add(m.key)
  461. }
  462. const all = ownCrypto.getAllRootIds()
  463. let removed = 0
  464. for (const rid of all) {
  465. if (!live.has(rid) || tomb.has(rid)) {
  466. try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
  467. }
  468. }
  469. return removed
  470. } catch (_) { return 0 }
  471. },
  472. async addMemberToPad(padId, feedId) {
  473. const tipId = await this.resolveCurrentId(padId)
  474. const ssbClient = await openSsb()
  475. const rootId = await this.resolveRootId(padId)
  476. return new Promise((resolve, reject) => {
  477. ssbClient.get(tipId, (err, item) => {
  478. if (err || !item?.content) return reject(new Error("Pad not found"))
  479. const c = item.content
  480. const members = Array.isArray(c.members) ? c.members : []
  481. if (members.includes(feedId)) return resolve()
  482. if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) {
  483. const key = tryDecryptPublicInviteKey(c.invites)
  484. if (key) setPadKey(rootId, key)
  485. }
  486. const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
  487. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
  488. ssbClient.publish(tombstone, (e1) => {
  489. if (e1) return reject(e1)
  490. ssbClient.publish(updated, (e2, res) => {
  491. if (e2) return reject(e2)
  492. if (c.encrypted === true && !c.tribeId) {
  493. const keyHex = getPadKey(rootId)
  494. if (keyHex) setPadKey(res.key, keyHex)
  495. }
  496. resolve(res)
  497. })
  498. })
  499. })
  500. })
  501. },
  502. async deletePadById(id) {
  503. const tipId = await this.resolveCurrentId(id)
  504. const ssbClient = await openSsb()
  505. const userId = ssbClient.id
  506. return new Promise((resolve, reject) => {
  507. ssbClient.get(tipId, (err, item) => {
  508. if (err || !item?.content) return reject(new Error("Pad not found"))
  509. if (item.content.author !== userId) return reject(new Error("Not the author"))
  510. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  511. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  512. })
  513. })
  514. },
  515. async getPadById(id) {
  516. const ssbClient = await openSsb()
  517. const messages = await readAll(ssbClient)
  518. const idx = buildIndex(messages)
  519. let tip = id
  520. while (idx.child.has(tip)) tip = idx.child.get(tip)
  521. if (idx.tomb.has(tip)) return null
  522. const node = idx.nodes.get(tip)
  523. if (!node || node.c.type !== "pad") return null
  524. let root = tip
  525. while (idx.parent.has(root)) root = idx.parent.get(root)
  526. const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : []
  527. const pad = buildPad(node, root, tKeys)
  528. if (!pad) return null
  529. pad.isClosed = isClosed(pad)
  530. return pad
  531. },
  532. async listAll({ filter = "all", viewerId } = {}) {
  533. const ssbClient = await openSsb()
  534. const uid = viewerId || ssbClient.id
  535. const messages = await readAll(ssbClient)
  536. const idx = buildIndex(messages)
  537. const tribeKeyCache = new Map()
  538. const items = []
  539. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  540. if (idx.tomb.has(tipId)) continue
  541. const node = idx.nodes.get(tipId)
  542. if (!node || node.c.type !== "pad") continue
  543. let tKeys = []
  544. if (node.c.tribeId) {
  545. if (!tribeKeyCache.has(node.c.tribeId)) {
  546. tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId))
  547. }
  548. tKeys = tribeKeyCache.get(node.c.tribeId)
  549. }
  550. const pad = buildPad(node, rootId, tKeys)
  551. if (!pad) continue
  552. pad.isClosed = isClosed(pad)
  553. items.push(pad)
  554. }
  555. const now = Date.now()
  556. let list = items
  557. if (filter === "mine") list = list.filter(p => p.author === uid)
  558. else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
  559. else if (filter === "open") list = list.filter(p => !p.isClosed)
  560. else if (filter === "closed") list = list.filter(p => p.isClosed)
  561. return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  562. },
  563. async generateInvite(padId) {
  564. const ssbClient = await openSsb()
  565. const userId = ssbClient.id
  566. const pad = await this.getPadById(padId)
  567. if (!pad) throw new Error("Pad not found")
  568. if (pad.author !== userId) throw new Error("Only the author can generate invites")
  569. const rootId = await this.resolveRootId(padId)
  570. const keyHex = getPadKey(rootId)
  571. const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
  572. let invite = code
  573. if (keyHex) {
  574. const inviteSalt = generateInviteSalt()
  575. const ek = encryptForInvite(keyHex, code, inviteSalt)
  576. invite = { code, ek, salt: inviteSalt }
  577. }
  578. const invites = [...pad.invites, invite]
  579. await this.updatePadById(padId, { invites })
  580. return code
  581. },
  582. async joinByInvite(code) {
  583. const ssbClient = await openSsb()
  584. const userId = ssbClient.id
  585. const pads = await this.listAll()
  586. let matchedPad = null
  587. let matchedInvite = null
  588. for (const p of pads) {
  589. for (const inv of p.invites) {
  590. if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
  591. if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
  592. }
  593. if (matchedPad) break
  594. }
  595. if (!matchedPad) throw new Error("Invalid or expired invite code")
  596. if (matchedPad.members.includes(userId)) throw new Error("Already a member")
  597. let padKey = null
  598. let resolvedRootId = null
  599. if (typeof matchedInvite === "object" && matchedInvite.ek) {
  600. padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
  601. resolvedRootId = await this.resolveRootId(matchedPad.rootId)
  602. setPadKey(resolvedRootId, padKey)
  603. }
  604. await this.addMemberToPad(matchedPad.rootId, userId)
  605. if (tribeCrypto && padKey && resolvedRootId) {
  606. try {
  607. const ssbKeys = require("../server/node_modules/ssb-keys")
  608. const memberKeys = {}
  609. try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
  610. if (matchedPad.author && matchedPad.author !== userId) {
  611. try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
  612. }
  613. if (Object.keys(memberKeys).length) {
  614. await new Promise((resolve) => {
  615. ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
  616. })
  617. }
  618. } catch (_) {}
  619. }
  620. const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
  621. const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => {
  622. if (typeof inv === "string") return inv !== code
  623. return inv.code !== code
  624. })
  625. const tipId = await this.resolveCurrentId(matchedPad.rootId)
  626. const ssbC = await openSsb()
  627. return new Promise((resolve, reject) => {
  628. ssbC.get(tipId, (err, item) => {
  629. if (err || !item?.content) return reject(new Error("Pad not found after join"))
  630. const updated = { ...item.content, invites, updatedAt: new Date().toISOString(), replaces: tipId }
  631. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  632. ssbC.publish(tombstone, (e1) => {
  633. if (e1) return reject(e1)
  634. ssbC.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(matchedPad.rootId))
  635. })
  636. })
  637. })
  638. },
  639. async addEntry(padId, text) {
  640. const ssbClient = await openSsb()
  641. const rootId = await this.resolveRootId(padId)
  642. const pad = await this.getPadById(rootId)
  643. const padIsEncrypted = !!(pad && pad.encrypted)
  644. const now = new Date().toISOString()
  645. const safeBody = safeText(text)
  646. if (!padIsEncrypted) {
  647. const content = {
  648. type: "padEntry",
  649. padId: rootId,
  650. text: safeBody,
  651. author: ssbClient.id,
  652. createdAt: now,
  653. encrypted: false,
  654. ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
  655. }
  656. return new Promise((resolve, reject) => {
  657. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  658. })
  659. }
  660. let keyHex = null
  661. if (pad && pad.tribeId) {
  662. const tKeys = await getTribeKeysFor(pad.tribeId)
  663. if (tKeys.length) keyHex = tKeys[0]
  664. }
  665. if (!keyHex) keyHex = getPadKey(rootId)
  666. if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
  667. const content = {
  668. type: "padEntry",
  669. padId: rootId,
  670. text: encryptField(safeBody, keyHex),
  671. author: ssbClient.id,
  672. createdAt: now,
  673. encrypted: true,
  674. ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
  675. }
  676. return new Promise((resolve, reject) => {
  677. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  678. })
  679. },
  680. async getEntries(padRootId) {
  681. const ssbClient = await openSsb()
  682. const messages = await readAll(ssbClient)
  683. const pad = await this.getPadById(padRootId)
  684. const padKey = getPadKey(padRootId)
  685. let tribeKeys = []
  686. if (pad && pad.tribeId) {
  687. tribeKeys = await getTribeKeysFor(pad.tribeId)
  688. }
  689. const entries = []
  690. for (const m of messages) {
  691. const v = m.value || {}
  692. const c = v.content
  693. if (!c || c.type !== "padEntry") continue
  694. if (c.padId !== padRootId) continue
  695. let text = c.text || ""
  696. if (c.encrypted && c.text) {
  697. let decoded = ""
  698. for (const k of tribeKeys) {
  699. try { decoded = tryDecryptField(c.text, k); break } catch (_) {}
  700. }
  701. if (!decoded && padKey) decoded = decryptField(c.text, padKey)
  702. text = decoded
  703. }
  704. entries.push({
  705. key: m.key,
  706. author: c.author || v.author,
  707. text,
  708. createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
  709. })
  710. }
  711. entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  712. return entries
  713. },
  714. getMemberColor(members, feedId) {
  715. const idx = members.indexOf(feedId)
  716. return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
  717. }
  718. }
  719. }