pads_model.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. const pull = require("../server/node_modules/pull-stream")
  2. const crypto = require("crypto")
  3. const fs = require("fs")
  4. const path = require("path")
  5. const { getConfig } = require("../configs/config-manager.js")
  6. const logLimit = getConfig().ssbLogStream?.limit || 1000
  7. const safeText = (v) => String(v || "").trim()
  8. const normalizeTags = (raw) => {
  9. if (!raw) 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_SALT = "SolarNET.HuB-pads"
  14. const INVITE_BYTES = 16
  15. const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
  16. module.exports = ({ cooler, cipherModel }) => {
  17. let ssb
  18. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  19. let keyringPath = null
  20. const getKeyring = () => {
  21. if (!keyringPath) {
  22. const ssbConfig = require("../server/node_modules/ssb-config/inject")()
  23. keyringPath = path.join(ssbConfig.path, "pad-keys.json")
  24. }
  25. try { return JSON.parse(fs.readFileSync(keyringPath, "utf8")) } catch (e) { return {} }
  26. }
  27. const saveKeyring = (kr) => fs.writeFileSync(keyringPath, JSON.stringify(kr, null, 2), "utf8")
  28. const getPadKey = (rootId) => { const kr = getKeyring(); return kr[rootId] || null }
  29. const setPadKey = (rootId, keyHex) => { const kr = getKeyring(); kr[rootId] = keyHex; saveKeyring(kr) }
  30. const encryptField = (text, keyHex) => {
  31. const key = Buffer.from(keyHex, "hex")
  32. const iv = crypto.randomBytes(12)
  33. const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
  34. const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
  35. const authTag = cipher.getAuthTag()
  36. return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
  37. }
  38. const decryptField = (encrypted, keyHex) => {
  39. try {
  40. const key = Buffer.from(keyHex, "hex")
  41. const iv = Buffer.from(encrypted.slice(0, 24), "hex")
  42. const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
  43. const ciphertext = Buffer.from(encrypted.slice(56), "hex")
  44. const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
  45. decipher.setAuthTag(authTag)
  46. return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
  47. } catch (_) { return "" }
  48. }
  49. const encryptForInvite = (padKeyHex, code) => {
  50. const derived = crypto.scryptSync(code, INVITE_SALT, 32)
  51. return encryptField(padKeyHex, derived.toString("hex"))
  52. }
  53. const decryptFromInvite = (encryptedKey, code) => {
  54. const derived = crypto.scryptSync(code, INVITE_SALT, 32)
  55. return decryptField(encryptedKey, derived.toString("hex"))
  56. }
  57. const readAll = async (ssbClient) =>
  58. new Promise((resolve, reject) =>
  59. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  60. )
  61. const buildIndex = (messages) => {
  62. const tomb = new Set()
  63. const nodes = new Map()
  64. const parent = new Map()
  65. const child = new Map()
  66. for (const m of messages) {
  67. const k = m.key
  68. const v = m.value || {}
  69. const c = v.content
  70. if (!c) continue
  71. if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
  72. if (c.type === "pad") {
  73. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  74. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  75. }
  76. }
  77. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  78. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  79. const roots = new Set()
  80. for (const id of nodes.keys()) roots.add(rootOf(id))
  81. const tipByRoot = new Map()
  82. for (const r of roots) tipByRoot.set(r, tipOf(r))
  83. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
  84. }
  85. const decryptPadFields = (c, rootId) => {
  86. const keyHex = getPadKey(rootId)
  87. if (!keyHex) return { title: "", deadline: "", tags: [] }
  88. const title = c.title ? decryptField(c.title, keyHex) : ""
  89. const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
  90. const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : ""
  91. const tags = normalizeTags(tagsRaw)
  92. return { title, deadline, tags }
  93. }
  94. const buildPad = (node, rootId) => {
  95. const c = node.c || {}
  96. if (c.type !== "pad") return null
  97. const { title, deadline, tags } = decryptPadFields(c, rootId)
  98. return {
  99. key: node.key,
  100. rootId,
  101. title,
  102. status: c.status || "OPEN",
  103. deadline,
  104. tags,
  105. author: c.author || node.author,
  106. members: Array.isArray(c.members) ? c.members : [],
  107. invites: Array.isArray(c.invites) ? c.invites : [],
  108. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  109. updatedAt: c.updatedAt || null,
  110. tribeId: c.tribeId || null
  111. }
  112. }
  113. const isClosed = (pad) => {
  114. if (pad.status === "CLOSED") return true
  115. if (!pad.deadline) return false
  116. return new Date(pad.deadline).getTime() <= Date.now()
  117. }
  118. return {
  119. type: "pad",
  120. decryptContent(content, rootId) {
  121. return decryptPadFields(content, rootId)
  122. },
  123. async resolveRootId(id) {
  124. const ssbClient = await openSsb()
  125. const messages = await readAll(ssbClient)
  126. const idx = buildIndex(messages)
  127. let tip = id
  128. while (idx.child.has(tip)) tip = idx.child.get(tip)
  129. if (idx.tomb.has(tip)) throw new Error("Not found")
  130. let root = tip
  131. while (idx.parent.has(root)) root = idx.parent.get(root)
  132. return root
  133. },
  134. async resolveCurrentId(id) {
  135. const ssbClient = await openSsb()
  136. const messages = await readAll(ssbClient)
  137. const idx = buildIndex(messages)
  138. let tip = id
  139. while (idx.child.has(tip)) tip = idx.child.get(tip)
  140. if (idx.tomb.has(tip)) throw new Error("Not found")
  141. return tip
  142. },
  143. async createPad(title, status, deadline, tagsRaw, tribeId) {
  144. const ssbClient = await openSsb()
  145. const now = new Date().toISOString()
  146. const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  147. const keyHex = crypto.randomBytes(32).toString("hex")
  148. const encrypt = (text) => {
  149. const key = Buffer.from(keyHex, "hex")
  150. const iv = crypto.randomBytes(12)
  151. const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
  152. const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
  153. const authTag = cipher.getAuthTag()
  154. return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
  155. }
  156. const content = {
  157. type: "pad",
  158. title: encrypt(safeText(title)),
  159. status: validStatus,
  160. deadline: deadline ? encrypt(String(deadline)) : "",
  161. tags: encrypt(normalizeTags(tagsRaw).join(",")),
  162. author: ssbClient.id,
  163. members: [ssbClient.id],
  164. invites: [],
  165. createdAt: now,
  166. updatedAt: now,
  167. encrypted: true,
  168. ...(tribeId ? { tribeId } : {})
  169. }
  170. return new Promise((resolve, reject) => {
  171. ssbClient.publish(content, (err, msg) => {
  172. if (err) return reject(err)
  173. setPadKey(msg.key, keyHex)
  174. resolve(msg)
  175. })
  176. })
  177. },
  178. async updatePadById(id, data) {
  179. const tipId = await this.resolveCurrentId(id)
  180. const ssbClient = await openSsb()
  181. const userId = ssbClient.id
  182. const rootId = await this.resolveRootId(id)
  183. const keyHex = getPadKey(rootId)
  184. return new Promise((resolve, reject) => {
  185. ssbClient.get(tipId, (err, item) => {
  186. if (err || !item?.content) return reject(new Error("Pad not found"))
  187. if (item.content.author !== userId) return reject(new Error("Not the author"))
  188. const c = item.content
  189. const enc = (text) => keyHex ? encryptField(text, keyHex) : text
  190. const updated = {
  191. ...c,
  192. title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
  193. status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
  194. deadline: data.deadline !== undefined ? enc(String(data.deadline)) : c.deadline,
  195. tags: data.tags !== undefined ? enc(normalizeTags(data.tags).join(",")) : c.tags,
  196. updatedAt: new Date().toISOString(),
  197. replaces: tipId
  198. }
  199. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  200. ssbClient.publish(tombstone, (e1) => {
  201. if (e1) return reject(e1)
  202. ssbClient.publish(updated, (e2, res) => {
  203. if (e2) return reject(e2)
  204. if (keyHex) setPadKey(res.key, keyHex)
  205. resolve(res)
  206. })
  207. })
  208. })
  209. })
  210. },
  211. async closePadById(id) {
  212. const tipId = await this.resolveCurrentId(id)
  213. const ssbClient = await openSsb()
  214. const userId = ssbClient.id
  215. const rootId = await this.resolveRootId(id)
  216. const keyHex = getPadKey(rootId)
  217. return new Promise((resolve, reject) => {
  218. ssbClient.get(tipId, (err, item) => {
  219. if (err || !item?.content) return reject(new Error("Pad not found"))
  220. if (item.content.author !== userId) return reject(new Error("Not the author"))
  221. const c = item.content
  222. const updated = {
  223. ...c,
  224. status: "CLOSED",
  225. updatedAt: new Date().toISOString(),
  226. replaces: tipId
  227. }
  228. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  229. ssbClient.publish(tombstone, (e1) => {
  230. if (e1) return reject(e1)
  231. ssbClient.publish(updated, (e2, res) => {
  232. if (e2) return reject(e2)
  233. if (keyHex) setPadKey(res.key, keyHex)
  234. resolve(res)
  235. })
  236. })
  237. })
  238. })
  239. },
  240. async addMemberToPad(padId, feedId) {
  241. const tipId = await this.resolveCurrentId(padId)
  242. const ssbClient = await openSsb()
  243. const rootId = await this.resolveRootId(padId)
  244. return new Promise((resolve, reject) => {
  245. ssbClient.get(tipId, (err, item) => {
  246. if (err || !item?.content) return reject(new Error("Pad not found"))
  247. const c = item.content
  248. const members = Array.isArray(c.members) ? c.members : []
  249. if (members.includes(feedId)) return resolve()
  250. const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
  251. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
  252. ssbClient.publish(tombstone, (e1) => {
  253. if (e1) return reject(e1)
  254. ssbClient.publish(updated, (e2, res) => {
  255. if (e2) return reject(e2)
  256. const keyHex = getPadKey(rootId)
  257. if (keyHex) setPadKey(res.key, keyHex)
  258. resolve(res)
  259. })
  260. })
  261. })
  262. })
  263. },
  264. async deletePadById(id) {
  265. const tipId = await this.resolveCurrentId(id)
  266. const ssbClient = await openSsb()
  267. const userId = ssbClient.id
  268. return new Promise((resolve, reject) => {
  269. ssbClient.get(tipId, (err, item) => {
  270. if (err || !item?.content) return reject(new Error("Pad not found"))
  271. if (item.content.author !== userId) return reject(new Error("Not the author"))
  272. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  273. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  274. })
  275. })
  276. },
  277. async getPadById(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)) return null
  284. const node = idx.nodes.get(tip)
  285. if (!node || node.c.type !== "pad") return null
  286. let root = tip
  287. while (idx.parent.has(root)) root = idx.parent.get(root)
  288. const pad = buildPad(node, root)
  289. if (!pad) return null
  290. pad.isClosed = isClosed(pad)
  291. return pad
  292. },
  293. async listAll({ filter = "all", viewerId } = {}) {
  294. const ssbClient = await openSsb()
  295. const uid = viewerId || ssbClient.id
  296. const messages = await readAll(ssbClient)
  297. const idx = buildIndex(messages)
  298. const items = []
  299. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  300. if (idx.tomb.has(tipId)) continue
  301. const node = idx.nodes.get(tipId)
  302. if (!node || node.c.type !== "pad") continue
  303. const pad = buildPad(node, rootId)
  304. if (!pad) continue
  305. pad.isClosed = isClosed(pad)
  306. items.push(pad)
  307. }
  308. const now = Date.now()
  309. let list = items
  310. if (filter === "mine") list = list.filter(p => p.author === uid)
  311. else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
  312. else if (filter === "open") list = list.filter(p => !p.isClosed)
  313. else if (filter === "closed") list = list.filter(p => p.isClosed)
  314. return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  315. },
  316. async generateInvite(padId) {
  317. const ssbClient = await openSsb()
  318. const userId = ssbClient.id
  319. const pad = await this.getPadById(padId)
  320. if (!pad) throw new Error("Pad not found")
  321. if (pad.author !== userId) throw new Error("Only the author can generate invites")
  322. const rootId = await this.resolveRootId(padId)
  323. const keyHex = getPadKey(rootId)
  324. const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
  325. let invite = code
  326. if (keyHex) {
  327. const ek = encryptForInvite(keyHex, code)
  328. invite = { code, ek }
  329. }
  330. const invites = [...pad.invites, invite]
  331. await this.updatePadById(padId, { invites })
  332. return code
  333. },
  334. async joinByInvite(code) {
  335. const ssbClient = await openSsb()
  336. const userId = ssbClient.id
  337. const pads = await this.listAll()
  338. let matchedPad = null
  339. let matchedInvite = null
  340. for (const p of pads) {
  341. for (const inv of p.invites) {
  342. if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
  343. if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
  344. }
  345. if (matchedPad) break
  346. }
  347. if (!matchedPad) throw new Error("Invalid or expired invite code")
  348. if (matchedPad.members.includes(userId)) throw new Error("Already a member")
  349. if (typeof matchedInvite === "object" && matchedInvite.ek) {
  350. const padKey = decryptFromInvite(matchedInvite.ek, code)
  351. const rootId = await this.resolveRootId(matchedPad.rootId)
  352. setPadKey(rootId, padKey)
  353. }
  354. await this.addMemberToPad(matchedPad.rootId, userId)
  355. const invites = matchedPad.invites.filter(inv => {
  356. if (typeof inv === "string") return inv !== code
  357. return inv.code !== code
  358. })
  359. const tipId = await this.resolveCurrentId(matchedPad.rootId)
  360. const ssbC = await openSsb()
  361. return new Promise((resolve, reject) => {
  362. ssbC.get(tipId, (err, item) => {
  363. if (err || !item?.content) return reject(new Error("Pad not found after join"))
  364. const updated = { ...item.content, invites, updatedAt: new Date().toISOString(), replaces: tipId }
  365. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  366. ssbC.publish(tombstone, (e1) => {
  367. if (e1) return reject(e1)
  368. ssbC.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(matchedPad.rootId))
  369. })
  370. })
  371. })
  372. },
  373. async addEntry(padId, text) {
  374. const ssbClient = await openSsb()
  375. const rootId = await this.resolveRootId(padId)
  376. const keyHex = getPadKey(rootId)
  377. const now = new Date().toISOString()
  378. const encText = keyHex ? encryptField(safeText(text), keyHex) : safeText(text)
  379. const content = {
  380. type: "padEntry",
  381. padId: rootId,
  382. text: encText,
  383. author: ssbClient.id,
  384. createdAt: now,
  385. encrypted: !!keyHex
  386. }
  387. return new Promise((resolve, reject) => {
  388. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  389. })
  390. },
  391. async getEntries(padRootId) {
  392. const ssbClient = await openSsb()
  393. const messages = await readAll(ssbClient)
  394. const keyHex = getPadKey(padRootId)
  395. const entries = []
  396. for (const m of messages) {
  397. const v = m.value || {}
  398. const c = v.content
  399. if (!c || c.type !== "padEntry") continue
  400. if (c.padId !== padRootId) continue
  401. const text = (keyHex && c.encrypted) ? decryptField(c.text, keyHex) : (c.text || "")
  402. entries.push({
  403. key: m.key,
  404. author: c.author || v.author,
  405. text,
  406. createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
  407. })
  408. }
  409. entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  410. return entries
  411. },
  412. getMemberColor(members, feedId) {
  413. const idx = members.indexOf(feedId)
  414. return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
  415. }
  416. }
  417. }