pads_model.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  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. decryptContentPublicSync(content) {
  263. if (!content) return null
  264. if (content.encrypted !== true) {
  265. return { title: safeText(content.title), deadline: content.deadline ? String(content.deadline) : "", tags: normalizeTags(content.tags) }
  266. }
  267. if (content.tribeId) return null
  268. const keyHex = tryDecryptPublicInviteKey(content.invites)
  269. if (!keyHex) return null
  270. try {
  271. const title = content.title ? decryptField(content.title, keyHex) : ""
  272. const deadline = content.deadline ? decryptField(content.deadline, keyHex) : ""
  273. const tagsRaw = content.tags ? decryptField(content.tags, keyHex) : ""
  274. return { title, deadline, tags: normalizeTags(tagsRaw) }
  275. } catch (_) { return null }
  276. },
  277. async resolveRootId(id) {
  278. const ssbClient = await openSsb()
  279. const messages = await readAll(ssbClient)
  280. const idx = buildIndex(messages)
  281. let tip = id
  282. while (idx.child.has(tip)) tip = idx.child.get(tip)
  283. if (idx.tomb.has(tip)) throw new Error("Not found")
  284. let root = tip
  285. while (idx.parent.has(root)) root = idx.parent.get(root)
  286. return root
  287. },
  288. async resolveCurrentId(id) {
  289. const ssbClient = await openSsb()
  290. const messages = await readAll(ssbClient)
  291. const idx = buildIndex(messages)
  292. let tip = id
  293. while (idx.child.has(tip)) tip = idx.child.get(tip)
  294. if (idx.tomb.has(tip)) throw new Error("Not found")
  295. return tip
  296. },
  297. async createPad(title, status, deadline, tagsRaw, tribeId) {
  298. const ssbClient = await openSsb()
  299. const now = new Date().toISOString()
  300. const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  301. const userId = ssbClient.id
  302. const tagsArr = normalizeTags(tagsRaw)
  303. const isPublicOpen = validStatus === "OPEN" && !tribeId
  304. if (isPublicOpen) {
  305. const content = {
  306. type: "pad",
  307. title: safeText(title),
  308. status: validStatus,
  309. deadline: deadline ? String(deadline) : "",
  310. tags: tagsArr,
  311. author: userId,
  312. members: [userId],
  313. invites: [],
  314. createdAt: now,
  315. updatedAt: now,
  316. encrypted: false
  317. }
  318. return new Promise((resolve, reject) => {
  319. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  320. })
  321. }
  322. let keyHex = null
  323. let usesTribeKey = false
  324. if (tribeId) {
  325. const tKeys = await getTribeKeysFor(tribeId)
  326. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  327. }
  328. if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
  329. const enc = (text) => encryptField(text, keyHex)
  330. const content = {
  331. type: "pad",
  332. title: enc(safeText(title)),
  333. status: validStatus,
  334. deadline: deadline ? enc(String(deadline)) : "",
  335. tags: enc(tagsArr.join(",")),
  336. author: userId,
  337. members: [userId],
  338. invites: [],
  339. createdAt: now,
  340. updatedAt: now,
  341. encrypted: true,
  342. ...(tribeId ? { tribeId } : {})
  343. }
  344. return new Promise((resolve, reject) => {
  345. ssbClient.publish(content, (err, msg) => {
  346. if (err) return reject(err)
  347. if (!usesTribeKey) {
  348. setPadKey(msg.key, keyHex)
  349. if (tribeCrypto) {
  350. try {
  351. const ssbKeys = require("../server/node_modules/ssb-keys")
  352. const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
  353. ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
  354. return
  355. } catch (_) {}
  356. }
  357. }
  358. resolve(msg)
  359. })
  360. })
  361. },
  362. async updatePadById(id, data) {
  363. const tipId = await this.resolveCurrentId(id)
  364. const ssbClient = await openSsb()
  365. const userId = ssbClient.id
  366. const rootId = await this.resolveRootId(id)
  367. return new Promise(async (resolve, reject) => {
  368. ssbClient.get(tipId, async (err, item) => {
  369. if (err || !item?.content) return reject(new Error("Pad not found"))
  370. if (item.content.author !== userId) return reject(new Error("Not the author"))
  371. const c = item.content
  372. const isEncrypted = c.encrypted === true
  373. let keyHex = null
  374. let usesTribeKey = false
  375. if (isEncrypted) {
  376. if (c.tribeId) {
  377. const tKeys = await getTribeKeysFor(c.tribeId)
  378. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  379. }
  380. if (!keyHex) keyHex = getPadKey(rootId)
  381. if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`))
  382. }
  383. const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text
  384. const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw)
  385. const updated = {
  386. ...c,
  387. title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
  388. status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
  389. deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline,
  390. tags: data.tags !== undefined ? tagsField(data.tags) : c.tags,
  391. updatedAt: new Date().toISOString(),
  392. replaces: tipId
  393. }
  394. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  395. ssbClient.publish(tombstone, (e1) => {
  396. if (e1) return reject(e1)
  397. ssbClient.publish(updated, (e2, res) => {
  398. if (e2) return reject(e2)
  399. if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
  400. resolve(res)
  401. })
  402. })
  403. })
  404. })
  405. },
  406. async closePadById(id) {
  407. const tipId = await this.resolveCurrentId(id)
  408. const ssbClient = await openSsb()
  409. const userId = ssbClient.id
  410. const rootId = await this.resolveRootId(id)
  411. return new Promise(async (resolve, reject) => {
  412. ssbClient.get(tipId, async (err, item) => {
  413. if (err || !item?.content) return reject(new Error("Pad not found"))
  414. if (item.content.author !== userId) return reject(new Error("Not the author"))
  415. const c = item.content
  416. let keyHex = null
  417. let usesTribeKey = false
  418. if (c.tribeId) {
  419. const tKeys = await getTribeKeysFor(c.tribeId)
  420. if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
  421. }
  422. if (!keyHex) keyHex = getPadKey(rootId)
  423. const updated = {
  424. ...c,
  425. status: "CLOSED",
  426. updatedAt: new Date().toISOString(),
  427. replaces: tipId
  428. }
  429. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  430. ssbClient.publish(tombstone, (e1) => {
  431. if (e1) return reject(e1)
  432. ssbClient.publish(updated, (e2, res) => {
  433. if (e2) return reject(e2)
  434. if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
  435. resolve(res)
  436. })
  437. })
  438. })
  439. })
  440. },
  441. async leavePad(padId) {
  442. const ssbClient = await openSsb()
  443. const userId = ssbClient.id
  444. const pad = await this.getPadById(padId)
  445. if (!pad) throw new Error("Pad not found")
  446. if (pad.author === userId) throw new Error("Author cannot leave their own pad")
  447. const members = (Array.isArray(pad.members) ? pad.members : []).filter(m => m !== userId)
  448. if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return
  449. const tipId = await this.resolveCurrentId(padId)
  450. const rootId = await this.resolveRootId(padId)
  451. await new Promise((resolve, reject) => {
  452. ssbClient.get(tipId, (err, item) => {
  453. if (err || !item?.content) return reject(new Error("Pad not found"))
  454. const updated = { ...item.content, members, updatedAt: new Date().toISOString(), replaces: tipId }
  455. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  456. ssbClient.publish(tombstone, (e1) => {
  457. if (e1) return reject(e1)
  458. ssbClient.publish(updated, (e2) => e2 ? reject(e2) : resolve())
  459. })
  460. })
  461. })
  462. try { await rotatePadKey(rootId, members) } catch (_) {}
  463. },
  464. async ingestKeys() { await ingestOwnTribeKeys() },
  465. async pruneOrphanKeys() {
  466. if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
  467. try {
  468. const ssbClient = await openSsb()
  469. const messages = await readAll(ssbClient)
  470. const live = new Set()
  471. const tomb = buildValidatedTombstoneSet(messages)
  472. for (const m of messages) {
  473. const c = m.value && m.value.content
  474. if (!c) continue
  475. if (c.type === "pad") live.add(m.key)
  476. }
  477. const all = ownCrypto.getAllRootIds()
  478. let removed = 0
  479. for (const rid of all) {
  480. if (!live.has(rid) || tomb.has(rid)) {
  481. try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
  482. }
  483. }
  484. return removed
  485. } catch (_) { return 0 }
  486. },
  487. async addMemberToPad(padId, feedId) {
  488. const tipId = await this.resolveCurrentId(padId)
  489. const ssbClient = await openSsb()
  490. const rootId = await this.resolveRootId(padId)
  491. return new Promise((resolve, reject) => {
  492. ssbClient.get(tipId, (err, item) => {
  493. if (err || !item?.content) return reject(new Error("Pad not found"))
  494. const c = item.content
  495. const members = Array.isArray(c.members) ? c.members : []
  496. if (members.includes(feedId)) return resolve()
  497. if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) {
  498. const key = tryDecryptPublicInviteKey(c.invites)
  499. if (key) setPadKey(rootId, key)
  500. }
  501. const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
  502. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
  503. ssbClient.publish(tombstone, (e1) => {
  504. if (e1) return reject(e1)
  505. ssbClient.publish(updated, (e2, res) => {
  506. if (e2) return reject(e2)
  507. if (c.encrypted === true && !c.tribeId) {
  508. const keyHex = getPadKey(rootId)
  509. if (keyHex) setPadKey(res.key, keyHex)
  510. }
  511. resolve(res)
  512. })
  513. })
  514. })
  515. })
  516. },
  517. async deletePadById(id) {
  518. const tipId = await this.resolveCurrentId(id)
  519. const ssbClient = await openSsb()
  520. const userId = ssbClient.id
  521. return new Promise((resolve, reject) => {
  522. ssbClient.get(tipId, (err, item) => {
  523. if (err || !item?.content) return reject(new Error("Pad not found"))
  524. if (item.content.author !== userId) return reject(new Error("Not the author"))
  525. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  526. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  527. })
  528. })
  529. },
  530. async getPadById(id) {
  531. const ssbClient = await openSsb()
  532. const messages = await readAll(ssbClient)
  533. const idx = buildIndex(messages)
  534. let tip = id
  535. while (idx.child.has(tip)) tip = idx.child.get(tip)
  536. if (idx.tomb.has(tip)) return null
  537. const node = idx.nodes.get(tip)
  538. if (!node || node.c.type !== "pad") return null
  539. let root = tip
  540. while (idx.parent.has(root)) root = idx.parent.get(root)
  541. const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : []
  542. const pad = buildPad(node, root, tKeys)
  543. if (!pad) return null
  544. pad.isClosed = isClosed(pad)
  545. return pad
  546. },
  547. async listAll({ filter = "all", viewerId } = {}) {
  548. const ssbClient = await openSsb()
  549. const uid = viewerId || ssbClient.id
  550. const messages = await readAll(ssbClient)
  551. const idx = buildIndex(messages)
  552. const tribeKeyCache = new Map()
  553. const items = []
  554. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  555. if (idx.tomb.has(tipId)) continue
  556. const node = idx.nodes.get(tipId)
  557. if (!node || node.c.type !== "pad") continue
  558. let tKeys = []
  559. if (node.c.tribeId) {
  560. if (!tribeKeyCache.has(node.c.tribeId)) {
  561. tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId))
  562. }
  563. tKeys = tribeKeyCache.get(node.c.tribeId)
  564. }
  565. const pad = buildPad(node, rootId, tKeys)
  566. if (!pad) continue
  567. pad.isClosed = isClosed(pad)
  568. items.push(pad)
  569. }
  570. const now = Date.now()
  571. let list = items
  572. if (filter === "mine") list = list.filter(p => p.author === uid)
  573. else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
  574. else if (filter === "open") list = list.filter(p => !p.isClosed)
  575. else if (filter === "closed") list = list.filter(p => p.isClosed)
  576. return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  577. },
  578. async generateInvite(padId) {
  579. const ssbClient = await openSsb()
  580. const userId = ssbClient.id
  581. const pad = await this.getPadById(padId)
  582. if (!pad) throw new Error("Pad not found")
  583. if (pad.author !== userId) throw new Error("Only the author can generate invites")
  584. const rootId = await this.resolveRootId(padId)
  585. const keyHex = getPadKey(rootId)
  586. const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
  587. let invite = code
  588. if (keyHex) {
  589. const inviteSalt = generateInviteSalt()
  590. const ek = encryptForInvite(keyHex, code, inviteSalt)
  591. invite = { code, ek, salt: inviteSalt }
  592. }
  593. const invites = [...pad.invites, invite]
  594. await this.updatePadById(padId, { invites })
  595. return code
  596. },
  597. async joinByInvite(code) {
  598. const ssbClient = await openSsb()
  599. const userId = ssbClient.id
  600. const pads = await this.listAll()
  601. let matchedPad = null
  602. let matchedInvite = null
  603. for (const p of pads) {
  604. for (const inv of p.invites) {
  605. if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
  606. if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
  607. }
  608. if (matchedPad) break
  609. }
  610. if (!matchedPad) throw new Error("Invalid or expired invite code")
  611. if (matchedPad.members.includes(userId)) throw new Error("Already a member")
  612. let padKey = null
  613. let resolvedRootId = null
  614. if (typeof matchedInvite === "object" && matchedInvite.ek) {
  615. padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
  616. resolvedRootId = await this.resolveRootId(matchedPad.rootId)
  617. setPadKey(resolvedRootId, padKey)
  618. }
  619. await this.addMemberToPad(matchedPad.rootId, userId)
  620. if (tribeCrypto && padKey && resolvedRootId) {
  621. try {
  622. const ssbKeys = require("../server/node_modules/ssb-keys")
  623. const memberKeys = {}
  624. try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
  625. if (matchedPad.author && matchedPad.author !== userId) {
  626. try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
  627. }
  628. if (Object.keys(memberKeys).length) {
  629. await new Promise((resolve) => {
  630. ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
  631. })
  632. }
  633. } catch (_) {}
  634. }
  635. const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
  636. const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => {
  637. if (typeof inv === "string") return inv !== code
  638. return inv.code !== code
  639. })
  640. const tipId = await this.resolveCurrentId(matchedPad.rootId)
  641. const ssbC = await openSsb()
  642. return new Promise((resolve, reject) => {
  643. ssbC.get(tipId, (err, item) => {
  644. if (err || !item?.content) return reject(new Error("Pad not found after join"))
  645. const updated = { ...item.content, invites, updatedAt: new Date().toISOString(), replaces: tipId }
  646. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  647. ssbC.publish(tombstone, (e1) => {
  648. if (e1) return reject(e1)
  649. ssbC.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(matchedPad.rootId))
  650. })
  651. })
  652. })
  653. },
  654. async addEntry(padId, text) {
  655. const ssbClient = await openSsb()
  656. const rootId = await this.resolveRootId(padId)
  657. const pad = await this.getPadById(rootId)
  658. const padIsEncrypted = !!(pad && pad.encrypted)
  659. const now = new Date().toISOString()
  660. const safeBody = safeText(text)
  661. if (!padIsEncrypted) {
  662. const content = {
  663. type: "padEntry",
  664. padId: rootId,
  665. text: safeBody,
  666. author: ssbClient.id,
  667. createdAt: now,
  668. encrypted: false,
  669. ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
  670. }
  671. return new Promise((resolve, reject) => {
  672. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  673. })
  674. }
  675. let keyHex = null
  676. if (pad && pad.tribeId) {
  677. const tKeys = await getTribeKeysFor(pad.tribeId)
  678. if (tKeys.length) keyHex = tKeys[0]
  679. }
  680. if (!keyHex) keyHex = getPadKey(rootId)
  681. if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
  682. const content = {
  683. type: "padEntry",
  684. padId: rootId,
  685. text: encryptField(safeBody, keyHex),
  686. author: ssbClient.id,
  687. createdAt: now,
  688. encrypted: true,
  689. ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
  690. }
  691. return new Promise((resolve, reject) => {
  692. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  693. })
  694. },
  695. async getEntries(padRootId) {
  696. const ssbClient = await openSsb()
  697. const messages = await readAll(ssbClient)
  698. const pad = await this.getPadById(padRootId)
  699. const padKey = getPadKey(padRootId)
  700. let tribeKeys = []
  701. if (pad && pad.tribeId) {
  702. tribeKeys = await getTribeKeysFor(pad.tribeId)
  703. }
  704. const entries = []
  705. for (const m of messages) {
  706. const v = m.value || {}
  707. const c = v.content
  708. if (!c || c.type !== "padEntry") continue
  709. if (c.padId !== padRootId) continue
  710. let text = c.text || ""
  711. if (c.encrypted && c.text) {
  712. let decoded = ""
  713. for (const k of tribeKeys) {
  714. try { decoded = tryDecryptField(c.text, k); break } catch (_) {}
  715. }
  716. if (!decoded && padKey) decoded = decryptField(c.text, padKey)
  717. text = decoded
  718. }
  719. entries.push({
  720. key: m.key,
  721. author: c.author || v.author,
  722. text,
  723. createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
  724. })
  725. }
  726. entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  727. return entries
  728. },
  729. getMemberColor(members, feedId) {
  730. const idx = members.indexOf(feedId)
  731. return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
  732. }
  733. }
  734. }