| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772 |
- const pull = require("../server/node_modules/pull-stream")
- const crypto = require("crypto")
- const fs = require("fs")
- const { buildValidatedTombstoneSet } = require('./tombstone_validator')
- const path = require("path")
- const { getConfig } = require("../configs/config-manager.js")
- const logLimit = getConfig().ssbLogStream?.limit || 1000
- const safeText = (v) => String(v || "").trim()
- const normalizeTags = (raw) => {
- if (!raw) return []
- if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
- return String(raw).split(",").map(t => t.trim()).filter(Boolean)
- }
- const INVITE_SALT = "SolarNET.HuB-pads"
- const INVITE_BYTES = 16
- const MEMBER_COLORS = ["#e74c3c","#3498db","#2ecc71","#f39c12","#9b59b6","#1abc9c","#e67e22","#e91e63","#00bcd4","#8bc34a"]
- module.exports = ({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }) => {
- let ssb
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
- const ownCrypto = padCrypto || tribeCrypto
- const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
- const lookupKeys = (rid) => {
- const a = (ownCrypto && ownCrypto.getKeys(rid)) || []
- if (a.length) return a
- return (tribeCrypto && tribeCrypto.getKeys(rid)) || []
- }
- const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0)
- let keyringPath = null
- let migratedToTribeCrypto = false
- const getLegacyKeyringPath = () => {
- if (!keyringPath) {
- const ssbConfig = require("../server/node_modules/ssb-config/inject")()
- keyringPath = path.join(ssbConfig.path, "pad-keys.json")
- }
- return keyringPath
- }
- const migrateLegacyKeyring = () => {
- if (migratedToTribeCrypto || !ownCrypto) { migratedToTribeCrypto = true; return }
- migratedToTribeCrypto = true
- try {
- const p = getLegacyKeyringPath()
- if (!fs.existsSync(p)) return
- const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {}
- for (const [rootId, keyHex] of Object.entries(legacy)) {
- if (rootId && keyHex && !ownCrypto.getKey(rootId)) {
- ownCrypto.setKey(rootId, keyHex, 1)
- }
- }
- } catch (_) {}
- }
- const getPadKey = (rootId) => {
- migrateLegacyKeyring()
- if (ownCrypto) return lookupKey(rootId)
- try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
- }
- const setPadKey = (rootId, keyHex) => {
- migrateLegacyKeyring()
- if (ownCrypto) { ownCrypto.setKey(rootId, keyHex, 1); return }
- let kr = {}
- try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
- kr[rootId] = keyHex
- fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8")
- }
- const encryptField = (text, keyHex) => {
- const key = Buffer.from(keyHex, "hex")
- const iv = crypto.randomBytes(12)
- const cipher = crypto.createCipheriv("aes-256-gcm", key, iv)
- const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
- const authTag = cipher.getAuthTag()
- return iv.toString("hex") + authTag.toString("hex") + enc.toString("hex")
- }
- const decryptField = (encrypted, keyHex) => {
- try {
- const key = Buffer.from(keyHex, "hex")
- const iv = Buffer.from(encrypted.slice(0, 24), "hex")
- const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
- const ciphertext = Buffer.from(encrypted.slice(56), "hex")
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
- decipher.setAuthTag(authTag)
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
- } catch (_) { return "" }
- }
- const tryDecryptField = (encrypted, keyHex) => {
- const key = Buffer.from(keyHex, "hex")
- const iv = Buffer.from(encrypted.slice(0, 24), "hex")
- const authTag = Buffer.from(encrypted.slice(24, 56), "hex")
- const ciphertext = Buffer.from(encrypted.slice(56), "hex")
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv)
- decipher.setAuthTag(authTag)
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
- }
- const getTribeKeysFor = async (tribeId) => {
- if (!tribeCrypto || !tribesModel || !tribeId) return []
- try {
- const rootId = await tribesModel.getRootId(tribeId)
- const keys = tribeCrypto.getKeys(rootId) || []
- return keys
- } catch (_) { return [] }
- }
- const decryptWithKeys = (c, keys) => {
- if (!c.title || !keys.length) return null
- for (const k of keys) {
- try {
- const title = tryDecryptField(c.title, k)
- let deadline = ""
- let tagsRaw = ""
- try { deadline = c.deadline ? tryDecryptField(c.deadline, k) : "" } catch (_) {}
- try { tagsRaw = c.tags ? tryDecryptField(c.tags, k) : "" } catch (_) {}
- return { title: safeText(title), deadline, tags: normalizeTags(tagsRaw) }
- } catch (_) {}
- }
- return null
- }
- const encryptForInvite = (padKeyHex, code, saltHex) => {
- const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
- const derived = crypto.scryptSync(code, salt, 32)
- return encryptField(padKeyHex, derived.toString("hex"))
- }
- const decryptFromInvite = (encryptedKey, code, saltHex) => {
- const salt = saltHex ? Buffer.from(saltHex, "hex") : Buffer.from(INVITE_SALT)
- const derived = crypto.scryptSync(code, salt, 32)
- return decryptField(encryptedKey, derived.toString("hex"))
- }
- const tryDecryptPublicInviteKey = (invites) => {
- if (!Array.isArray(invites)) return null
- for (const inv of invites) {
- if (!inv || typeof inv !== "object") continue
- if (inv.public !== true) continue
- if (typeof inv.code !== "string" || typeof inv.ek !== "string") continue
- try {
- const key = decryptFromInvite(inv.ek, inv.code, inv.salt)
- if (key) return key
- } catch (_) {}
- }
- return null
- }
- const generateInviteSalt = () => crypto.randomBytes(16).toString("hex")
- const rotatePadKey = async (rootId, remainingMembers) => {
- if (!ownCrypto || !tribeCrypto || !rootId) return
- const existing = getPadKey(rootId)
- if (!existing) return
- const newKey = crypto.randomBytes(32).toString("hex")
- const newGen = ownCrypto.addNewKey(rootId, newKey)
- if (!Array.isArray(remainingMembers) || !remainingMembers.length) return
- const ssbClient = await openSsb()
- const ssbKeys = require("../server/node_modules/ssb-keys")
- const memberKeys = {}
- for (const m of remainingMembers) {
- try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {}
- }
- if (Object.keys(memberKeys).length) {
- await new Promise((resolve) => {
- ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve())
- })
- }
- }
- const ingestOwnTribeKeys = async () => {
- if (!ownCrypto) return
- try {
- const ssbClient = await openSsb()
- const ssbKeys = require("../server/node_modules/ssb-keys")
- const config = require("../server/ssb_config")
- const msgs = await readAll(ssbClient)
- for (const m of msgs) {
- const c = m.value && m.value.content
- if (!c || c.type !== "tribe-keys") continue
- const memberKeys = c.memberKeys
- if (!memberKeys || typeof memberKeys !== "object") continue
- const boxed = memberKeys[ssbClient.id]
- if (!boxed) continue
- try {
- const unboxed = ssbKeys.unbox(boxed, config.keys)
- const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null)
- if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key)
- } catch (_) {}
- }
- } catch (_) {}
- }
- const readAll = async (ssbClient) =>
- new Promise((resolve, reject) =>
- pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
- )
- const buildIndex = (messages) => {
- const tomb = new Set()
- const nodes = new Map()
- const parent = new Map()
- const child = new Map()
- const authorByKey = new Map()
- const tombRequests = []
- for (const m of messages) {
- const k = m.key
- const v = m.value || {}
- const c = v.content
- if (!c) continue
- if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
- if (c.type === "pad") {
- nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
- authorByKey.set(k, v.author)
- if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
- }
- }
- for (const t of tombRequests) {
- const targetAuthor = authorByKey.get(t.target)
- if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
- }
- const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
- const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
- const roots = new Set()
- for (const id of nodes.keys()) roots.add(rootOf(id))
- const tipByRoot = new Map()
- for (const r of roots) tipByRoot.set(r, tipOf(r))
- return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
- }
- const decryptPadFields = (c, rootId, tribeKeys) => {
- if (c.encrypted !== true) {
- return { title: safeText(c.title), deadline: c.deadline ? String(c.deadline) : "", tags: normalizeTags(c.tags) }
- }
- if (c.tribeId && Array.isArray(tribeKeys) && tribeKeys.length) {
- const viaTribe = decryptWithKeys(c, tribeKeys)
- if (viaTribe) return viaTribe
- }
- let keyHex = getPadKey(rootId)
- if (!keyHex) keyHex = tryDecryptPublicInviteKey(c.invites)
- if (!keyHex) return { title: "", deadline: "", tags: [] }
- const title = c.title ? decryptField(c.title, keyHex) : ""
- const deadline = c.deadline ? decryptField(c.deadline, keyHex) : ""
- const tagsRaw = c.tags ? decryptField(c.tags, keyHex) : ""
- const tags = normalizeTags(tagsRaw)
- return { title, deadline, tags }
- }
- const buildPad = (node, rootId, tribeKeys) => {
- const c = node.c || {}
- if (c.type !== "pad") return null
- const { title, deadline, tags } = decryptPadFields(c, rootId, tribeKeys)
- return {
- key: node.key,
- rootId,
- title,
- status: c.status || "OPEN",
- deadline,
- tags,
- author: c.author || node.author,
- members: Array.isArray(c.members) ? c.members : [],
- invites: Array.isArray(c.invites) ? c.invites : [],
- createdAt: c.createdAt || new Date(node.ts).toISOString(),
- updatedAt: c.updatedAt || null,
- tribeId: c.tribeId || null,
- encrypted: c.encrypted === true
- }
- }
- const isClosed = (pad) => {
- if (pad.status === "CLOSED") return true
- if (!pad.deadline) return false
- return new Date(pad.deadline).getTime() <= Date.now()
- }
- return {
- type: "pad",
- async decryptContent(content, rootId) {
- const tKeys = content && content.tribeId ? await getTribeKeysFor(content.tribeId) : []
- return decryptPadFields(content, rootId, tKeys)
- },
- async resolveRootId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- return root
- },
- async resolveCurrentId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- return tip
- },
- async createPad(title, status, deadline, tagsRaw, tribeId) {
- const ssbClient = await openSsb()
- const now = new Date().toISOString()
- const validStatus = ["OPEN", "INVITE-ONLY"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
- const userId = ssbClient.id
- const tagsArr = normalizeTags(tagsRaw)
- const isPublicOpen = validStatus === "OPEN" && !tribeId
- if (isPublicOpen) {
- const content = {
- type: "pad",
- title: safeText(title),
- status: validStatus,
- deadline: deadline ? String(deadline) : "",
- tags: tagsArr,
- author: userId,
- members: [userId],
- invites: [],
- createdAt: now,
- updatedAt: now,
- encrypted: false
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
- })
- }
- let keyHex = null
- let usesTribeKey = false
- if (tribeId) {
- const tKeys = await getTribeKeysFor(tribeId)
- if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
- }
- if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
- const enc = (text) => encryptField(text, keyHex)
- const content = {
- type: "pad",
- title: enc(safeText(title)),
- status: validStatus,
- deadline: deadline ? enc(String(deadline)) : "",
- tags: enc(tagsArr.join(",")),
- author: userId,
- members: [userId],
- invites: [],
- createdAt: now,
- updatedAt: now,
- encrypted: true,
- ...(tribeId ? { tribeId } : {})
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => {
- if (err) return reject(err)
- if (!usesTribeKey) {
- setPadKey(msg.key, keyHex)
- if (tribeCrypto) {
- try {
- const ssbKeys = require("../server/node_modules/ssb-keys")
- const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
- ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
- return
- } catch (_) {}
- }
- }
- resolve(msg)
- })
- })
- },
- async updatePadById(id, data) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const rootId = await this.resolveRootId(id)
- return new Promise(async (resolve, reject) => {
- ssbClient.get(tipId, async (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const c = item.content
- const isEncrypted = c.encrypted === true
- let keyHex = null
- let usesTribeKey = false
- if (isEncrypted) {
- if (c.tribeId) {
- const tKeys = await getTribeKeysFor(c.tribeId)
- if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
- }
- if (!keyHex) keyHex = getPadKey(rootId)
- if (!keyHex) return reject(new Error(`Missing pad key for ${rootId} — cannot update pad`))
- }
- const enc = (text) => isEncrypted ? encryptField(text, keyHex) : text
- const tagsField = (raw) => isEncrypted ? encryptField(normalizeTags(raw).join(","), keyHex) : normalizeTags(raw)
- const updated = {
- ...c,
- title: data.title !== undefined ? enc(safeText(data.title)) : c.title,
- status: data.status !== undefined ? (["OPEN","INVITE-ONLY"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
- deadline: data.deadline !== undefined ? (isEncrypted ? enc(String(data.deadline)) : String(data.deadline)) : c.deadline,
- tags: data.tags !== undefined ? tagsField(data.tags) : c.tags,
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => {
- if (e2) return reject(e2)
- if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
- resolve(res)
- })
- })
- })
- })
- },
- async closePadById(id) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const rootId = await this.resolveRootId(id)
- return new Promise(async (resolve, reject) => {
- ssbClient.get(tipId, async (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const c = item.content
- let keyHex = null
- let usesTribeKey = false
- if (c.tribeId) {
- const tKeys = await getTribeKeysFor(c.tribeId)
- if (tKeys.length) { keyHex = tKeys[0]; usesTribeKey = true }
- }
- if (!keyHex) keyHex = getPadKey(rootId)
- const updated = {
- ...c,
- status: "CLOSED",
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => {
- if (e2) return reject(e2)
- if (keyHex && !usesTribeKey) setPadKey(res.key, keyHex)
- resolve(res)
- })
- })
- })
- })
- },
- async leavePad(padId) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const pad = await this.getPadById(padId)
- if (!pad) throw new Error("Pad not found")
- if (pad.author === userId) throw new Error("Author cannot leave their own pad")
- const members = (Array.isArray(pad.members) ? pad.members : []).filter(m => m !== userId)
- if (!Array.isArray(pad.members) || !pad.members.includes(userId)) return
- const tipId = await this.resolveCurrentId(padId)
- const rootId = await this.resolveRootId(padId)
- await new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found"))
- const updated = { ...item.content, members, updatedAt: new Date().toISOString(), replaces: tipId }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2) => e2 ? reject(e2) : resolve())
- })
- })
- })
- try { await rotatePadKey(rootId, members) } catch (_) {}
- },
- async ingestKeys() { await ingestOwnTribeKeys() },
- async pruneOrphanKeys() {
- if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0
- try {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const live = new Set()
- const tomb = buildValidatedTombstoneSet(messages)
- for (const m of messages) {
- const c = m.value && m.value.content
- if (!c) continue
- if (c.type === "pad") live.add(m.key)
- }
- const all = ownCrypto.getAllRootIds()
- let removed = 0
- for (const rid of all) {
- if (!live.has(rid) || tomb.has(rid)) {
- try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {}
- }
- }
- return removed
- } catch (_) { return 0 }
- },
- async addMemberToPad(padId, feedId) {
- const tipId = await this.resolveCurrentId(padId)
- const ssbClient = await openSsb()
- const rootId = await this.resolveRootId(padId)
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found"))
- const c = item.content
- const members = Array.isArray(c.members) ? c.members : []
- if (members.includes(feedId)) return resolve()
- if (c.encrypted === true && !c.tribeId && !getPadKey(rootId)) {
- const key = tryDecryptPublicInviteKey(c.invites)
- if (key) setPadKey(rootId, key)
- }
- const updated = { ...c, members: [...members, feedId], updatedAt: new Date().toISOString(), replaces: tipId }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => {
- if (e2) return reject(e2)
- if (c.encrypted === true && !c.tribeId) {
- const keyHex = getPadKey(rootId)
- if (keyHex) setPadKey(res.key, keyHex)
- }
- resolve(res)
- })
- })
- })
- })
- },
- async deletePadById(id) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
- })
- })
- },
- async getPadById(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) return null
- const node = idx.nodes.get(tip)
- if (!node || node.c.type !== "pad") return null
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- const tKeys = node.c.tribeId ? await getTribeKeysFor(node.c.tribeId) : []
- const pad = buildPad(node, root, tKeys)
- if (!pad) return null
- pad.isClosed = isClosed(pad)
- return pad
- },
- async listAll({ filter = "all", viewerId } = {}) {
- const ssbClient = await openSsb()
- const uid = viewerId || ssbClient.id
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const tribeKeyCache = new Map()
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "pad") continue
- let tKeys = []
- if (node.c.tribeId) {
- if (!tribeKeyCache.has(node.c.tribeId)) {
- tribeKeyCache.set(node.c.tribeId, await getTribeKeysFor(node.c.tribeId))
- }
- tKeys = tribeKeyCache.get(node.c.tribeId)
- }
- const pad = buildPad(node, rootId, tKeys)
- if (!pad) continue
- pad.isClosed = isClosed(pad)
- items.push(pad)
- }
- const now = Date.now()
- let list = items
- if (filter === "mine") list = list.filter(p => p.author === uid)
- else if (filter === "recent") list = list.filter(p => new Date(p.createdAt).getTime() >= now - 86400000)
- else if (filter === "open") list = list.filter(p => !p.isClosed)
- else if (filter === "closed") list = list.filter(p => p.isClosed)
- return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- },
- async generateInvite(padId) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const pad = await this.getPadById(padId)
- if (!pad) throw new Error("Pad not found")
- if (pad.author !== userId) throw new Error("Only the author can generate invites")
- const rootId = await this.resolveRootId(padId)
- const keyHex = getPadKey(rootId)
- const code = crypto.randomBytes(INVITE_BYTES).toString("hex")
- let invite = code
- if (keyHex) {
- const inviteSalt = generateInviteSalt()
- const ek = encryptForInvite(keyHex, code, inviteSalt)
- invite = { code, ek, salt: inviteSalt }
- }
- const invites = [...pad.invites, invite]
- await this.updatePadById(padId, { invites })
- return code
- },
- async joinByInvite(code) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const pads = await this.listAll()
- let matchedPad = null
- let matchedInvite = null
- for (const p of pads) {
- for (const inv of p.invites) {
- if (typeof inv === "string" && inv === code) { matchedPad = p; matchedInvite = inv; break }
- if (typeof inv === "object" && inv.code === code) { matchedPad = p; matchedInvite = inv; break }
- }
- if (matchedPad) break
- }
- if (!matchedPad) throw new Error("Invalid or expired invite code")
- if (matchedPad.members.includes(userId)) throw new Error("Already a member")
- let padKey = null
- let resolvedRootId = null
- if (typeof matchedInvite === "object" && matchedInvite.ek) {
- padKey = decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt)
- resolvedRootId = await this.resolveRootId(matchedPad.rootId)
- setPadKey(resolvedRootId, padKey)
- }
- await this.addMemberToPad(matchedPad.rootId, userId)
- if (tribeCrypto && padKey && resolvedRootId) {
- try {
- const ssbKeys = require("../server/node_modules/ssb-keys")
- const memberKeys = {}
- try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
- if (matchedPad.author && matchedPad.author !== userId) {
- try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
- }
- if (Object.keys(memberKeys).length) {
- await new Promise((resolve) => {
- ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
- })
- }
- } catch (_) {}
- }
- const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
- const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => {
- if (typeof inv === "string") return inv !== code
- return inv.code !== code
- })
- const tipId = await this.resolveCurrentId(matchedPad.rootId)
- const ssbC = await openSsb()
- return new Promise((resolve, reject) => {
- ssbC.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Pad not found after join"))
- const updated = { ...item.content, invites, updatedAt: new Date().toISOString(), replaces: tipId }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbC.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbC.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(matchedPad.rootId))
- })
- })
- })
- },
- async addEntry(padId, text) {
- const ssbClient = await openSsb()
- const rootId = await this.resolveRootId(padId)
- const pad = await this.getPadById(rootId)
- const padIsEncrypted = !!(pad && pad.encrypted)
- const now = new Date().toISOString()
- const safeBody = safeText(text)
- if (!padIsEncrypted) {
- const content = {
- type: "padEntry",
- padId: rootId,
- text: safeBody,
- author: ssbClient.id,
- createdAt: now,
- encrypted: false,
- ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
- })
- }
- let keyHex = null
- if (pad && pad.tribeId) {
- const tKeys = await getTribeKeysFor(pad.tribeId)
- if (tKeys.length) keyHex = tKeys[0]
- }
- if (!keyHex) keyHex = getPadKey(rootId)
- if (!keyHex) throw new Error(`Missing pad key for ${rootId} — cannot publish pad entry`)
- const content = {
- type: "padEntry",
- padId: rootId,
- text: encryptField(safeBody, keyHex),
- author: ssbClient.id,
- createdAt: now,
- encrypted: true,
- ...(pad && pad.tribeId ? { tribeId: pad.tribeId } : {})
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
- })
- },
- async getEntries(padRootId) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const pad = await this.getPadById(padRootId)
- const padKey = getPadKey(padRootId)
- let tribeKeys = []
- if (pad && pad.tribeId) {
- tribeKeys = await getTribeKeysFor(pad.tribeId)
- }
- const entries = []
- for (const m of messages) {
- const v = m.value || {}
- const c = v.content
- if (!c || c.type !== "padEntry") continue
- if (c.padId !== padRootId) continue
- let text = c.text || ""
- if (c.encrypted && c.text) {
- let decoded = ""
- for (const k of tribeKeys) {
- try { decoded = tryDecryptField(c.text, k); break } catch (_) {}
- }
- if (!decoded && padKey) decoded = decryptField(c.text, padKey)
- text = decoded
- }
- entries.push({
- key: m.key,
- author: c.author || v.author,
- text,
- createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
- })
- }
- entries.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
- return entries
- },
- getMemberColor(members, feedId) {
- const idx = members.indexOf(feedId)
- return idx >= 0 ? MEMBER_COLORS[idx % MEMBER_COLORS.length] : "#888"
- }
- }
- }
|