tribes_model.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. const pull = require('../server/node_modules/pull-stream');
  2. const crypto = require('crypto');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. const INVITE_CODE_BYTES = 16;
  6. const VALID_INVITE_MODES = ['strict', 'open'];
  7. module.exports = ({ cooler, tribeCrypto }) => {
  8. let ssb;
  9. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
  10. let tribeIndex = null;
  11. let tribeIndexTs = 0;
  12. const buildTribeIndex = async () => {
  13. if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
  14. const client = await openSsb();
  15. return new Promise((resolve, reject) => {
  16. pull(
  17. client.createLogStream({ limit: logLimit }),
  18. pull.collect((err, msgs) => {
  19. if (err) return reject(err);
  20. const tombstoned = new Set();
  21. const parent = new Map();
  22. const child = new Map();
  23. const tribes = new Map();
  24. for (const msg of msgs) {
  25. const k = msg.key;
  26. const c = msg.value?.content;
  27. if (!c) continue;
  28. if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
  29. if (c.type !== 'tribe') continue;
  30. if (c.replaces) {
  31. parent.set(k, c.replaces);
  32. child.set(c.replaces, k);
  33. }
  34. tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
  35. }
  36. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur; };
  37. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur; };
  38. const tipByRoot = new Map();
  39. for (const k of tribes.keys()) {
  40. const root = rootOf(k);
  41. const tip = tipOf(root);
  42. tipByRoot.set(root, tip);
  43. }
  44. tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
  45. tribeIndexTs = Date.now();
  46. resolve(tribeIndex);
  47. })
  48. );
  49. });
  50. };
  51. return {
  52. type: 'tribe',
  53. async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
  54. if (!VALID_INVITE_MODES.includes(inviteMode)) {
  55. throw new Error('Invalid invite mode. Must be "strict" or "open"');
  56. }
  57. const ssb = await openSsb();
  58. const userId = ssb.id;
  59. let blobId = null;
  60. if (image) {
  61. blobId = String(image).trim() || null;
  62. }
  63. const tags = Array.isArray(tagsRaw)
  64. ? tagsRaw.filter(Boolean)
  65. : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
  66. const content = {
  67. type: 'tribe',
  68. title,
  69. description,
  70. image: blobId,
  71. location,
  72. tags,
  73. isLARP: Boolean(isLARP),
  74. isAnonymous: Boolean(isAnonymous),
  75. members: [userId],
  76. invites: [],
  77. inviteMode,
  78. status: status || 'OPEN',
  79. parentTribeId: parentTribeId || null,
  80. mapUrl: String(mapUrl || '').trim(),
  81. createdAt: new Date().toISOString(),
  82. updatedAt: new Date().toISOString(),
  83. author: userId,
  84. };
  85. const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
  86. if (tribeCrypto) {
  87. const tribeKey = tribeCrypto.generateTribeKey();
  88. tribeCrypto.setKey(result.key, tribeKey, 1);
  89. }
  90. tribeIndex = null;
  91. return result;
  92. },
  93. async generateInvite(tribeId) {
  94. const ssb = await openSsb();
  95. const userId = ssb.id;
  96. const tribe = await this.getTribeById(tribeId);
  97. if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
  98. throw new Error('Only the author can generate invites in strict mode');
  99. }
  100. if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
  101. throw new Error('Only tribe members can generate invites in open mode');
  102. }
  103. const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
  104. let invite = code;
  105. if (tribeCrypto) {
  106. const rootId = await this.getRootId(tribeId);
  107. const tribeKey = tribeCrypto.getKey(rootId);
  108. if (tribeKey) {
  109. const ek = tribeCrypto.encryptForInvite(tribeKey, code);
  110. invite = { code, ek, gen: tribeCrypto.getGen(rootId) };
  111. }
  112. }
  113. const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
  114. await this.updateTribeInvites(tribeId, invites);
  115. return code;
  116. },
  117. async updateTribeInvites(tribeId, invites) {
  118. return this.updateTribeById(tribeId, { invites });
  119. },
  120. async leaveTribe(tribeId) {
  121. const ssb = await openSsb();
  122. const userId = ssb.id;
  123. const tribe = await this.getTribeById(tribeId);
  124. if (!tribe) throw new Error('Tribe not found');
  125. if (tribe.author === userId) {
  126. throw new Error('Tribe author cannot leave their own tribe');
  127. }
  128. const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
  129. const idx = members.indexOf(userId);
  130. if (idx === -1) throw new Error('User is not a member of this tribe');
  131. members.splice(idx, 1);
  132. await this.updateTribeById(tribeId, { members });
  133. await this.rotateTribeKey(tribeId, members);
  134. },
  135. async joinByInvite(code) {
  136. const ssb = await openSsb();
  137. const userId = ssb.id;
  138. const tribes = await this.listAll();
  139. let matchedTribe = null;
  140. let matchedInvite = null;
  141. for (const t of tribes) {
  142. if (!t.invites) continue;
  143. for (const inv of t.invites) {
  144. if (typeof inv === 'string' && inv === code) {
  145. matchedTribe = t; matchedInvite = inv; break;
  146. }
  147. if (typeof inv === 'object' && inv.code === code) {
  148. matchedTribe = t; matchedInvite = inv; break;
  149. }
  150. }
  151. if (matchedTribe) break;
  152. }
  153. if (!matchedTribe) throw new Error('Invalid or expired invite code');
  154. if (matchedTribe.members.includes(userId)) {
  155. throw new Error('Already a member of this tribe');
  156. }
  157. if (tribeCrypto && typeof matchedInvite === 'object' && matchedInvite.ek) {
  158. const tribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
  159. const rootId = await this.getRootId(matchedTribe.id);
  160. tribeCrypto.setKey(rootId, tribeKey, matchedInvite.gen || 1);
  161. }
  162. const members = [...matchedTribe.members, userId];
  163. const invites = matchedTribe.invites.filter(inv => {
  164. if (typeof inv === 'string') return inv !== code;
  165. return inv.code !== code;
  166. });
  167. await this.updateTribeById(matchedTribe.id, { members, invites });
  168. return matchedTribe.id;
  169. },
  170. async deleteTribeById(tribeId) {
  171. await this.publishTombstone(tribeId);
  172. },
  173. async updateTribeMembers(tribeId, members) {
  174. const tribe = await this.getTribeById(tribeId);
  175. const oldMembers = tribe.members || [];
  176. await this.updateTribeById(tribeId, { members });
  177. const removed = oldMembers.filter(m => !members.includes(m));
  178. if (removed.length > 0) {
  179. await this.rotateTribeKey(tribeId, members);
  180. }
  181. },
  182. async publishUpdatedTribe(tribeId, updatedTribe) {
  183. const ssb = await openSsb();
  184. const updatedTribeData = {
  185. type: 'tribe',
  186. replaces: updatedTribe.replaces || tribeId,
  187. title: updatedTribe.title,
  188. description: updatedTribe.description,
  189. image: updatedTribe.image,
  190. location: updatedTribe.location,
  191. tags: updatedTribe.tags,
  192. isLARP: updatedTribe.isLARP,
  193. isAnonymous: updatedTribe.isAnonymous,
  194. members: updatedTribe.members,
  195. invites: updatedTribe.invites,
  196. inviteMode: updatedTribe.inviteMode,
  197. status: updatedTribe.status || 'OPEN',
  198. parentTribeId: updatedTribe.parentTribeId || null,
  199. mapUrl: updatedTribe.mapUrl || "",
  200. createdAt: updatedTribe.createdAt,
  201. updatedAt: new Date().toISOString(),
  202. author: updatedTribe.author,
  203. };
  204. const result = await new Promise((resolve, reject) => {
  205. ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
  206. });
  207. tribeIndex = null;
  208. return result;
  209. },
  210. async getTribeById(tribeId) {
  211. const { tribes, tombstoned, child } = await buildTribeIndex();
  212. let latestId = tribeId;
  213. while (child.has(latestId)) latestId = child.get(latestId);
  214. if (tombstoned.has(latestId)) throw new Error('Tribe not found');
  215. const tribe = tribes.get(latestId);
  216. if (!tribe) throw new Error('Tribe not found');
  217. return {
  218. id: tribe.id,
  219. title: tribe.content.title,
  220. description: tribe.content.description,
  221. image: tribe.content.image || null,
  222. location: tribe.content.location,
  223. tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
  224. isLARP: !!tribe.content.isLARP,
  225. isAnonymous: tribe.content.isAnonymous,
  226. members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
  227. invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
  228. inviteMode: tribe.content.inviteMode || 'strict',
  229. status: tribe.content.status || 'OPEN',
  230. parentTribeId: tribe.content.parentTribeId || null,
  231. mapUrl: tribe.content.mapUrl || "",
  232. createdAt: tribe.content.createdAt,
  233. updatedAt: tribe.content.updatedAt,
  234. author: tribe.content.author,
  235. };
  236. },
  237. async listAll() {
  238. const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
  239. const items = [];
  240. for (const [root, tip] of tipByRoot) {
  241. if (tombstoned.has(root) || tombstoned.has(tip)) continue;
  242. const entry = tribes.get(tip);
  243. if (!entry) continue;
  244. const c = entry.content;
  245. items.push({
  246. id: tip,
  247. title: c.title,
  248. description: c.description,
  249. image: c.image || null,
  250. location: c.location,
  251. tags: Array.isArray(c.tags) ? c.tags : [],
  252. isLARP: !!c.isLARP,
  253. isAnonymous: c.isAnonymous !== false,
  254. members: Array.isArray(c.members) ? c.members : [],
  255. invites: Array.isArray(c.invites) ? c.invites : [],
  256. inviteMode: c.inviteMode || 'strict',
  257. status: c.status || 'OPEN',
  258. parentTribeId: c.parentTribeId || null,
  259. mapUrl: c.mapUrl || "",
  260. createdAt: c.createdAt,
  261. updatedAt: c.updatedAt,
  262. author: c.author,
  263. _ts: entry._ts
  264. });
  265. }
  266. return items;
  267. },
  268. async getChainIds(tribeId) {
  269. const { parent, child } = await buildTribeIndex();
  270. let root = tribeId;
  271. while (parent.has(root)) root = parent.get(root);
  272. const ids = [root];
  273. let cur = root;
  274. while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
  275. return ids;
  276. },
  277. async getRootId(tribeId) {
  278. const { parent } = await buildTribeIndex();
  279. let root = tribeId;
  280. while (parent.has(root)) root = parent.get(root);
  281. return root;
  282. },
  283. async getAncestryChain(tribeId) {
  284. const rootId = await this.getRootId(tribeId);
  285. const tribe = await this.getTribeById(tribeId);
  286. const chain = [rootId];
  287. let currentTribe = tribe;
  288. while (currentTribe.parentTribeId) {
  289. const parentRootId = await this.getRootId(currentTribe.parentTribeId);
  290. chain.push(parentRootId);
  291. try {
  292. currentTribe = await this.getTribeById(currentTribe.parentTribeId);
  293. } catch (e) {
  294. break;
  295. }
  296. }
  297. return chain;
  298. },
  299. async rotateTribeKey(tribeId, remainingMembers) {
  300. if (!tribeCrypto) return;
  301. const ssb = await openSsb();
  302. const ssbKeys = require('../server/node_modules/ssb-keys');
  303. const rootId = await this.getRootId(tribeId);
  304. const oldKey = tribeCrypto.getKey(rootId);
  305. if (!oldKey) return;
  306. const newKey = tribeCrypto.generateTribeKey();
  307. const newGen = tribeCrypto.addNewKey(rootId, newKey);
  308. const memberKeys = {};
  309. for (const memberId of remainingMembers) {
  310. memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys);
  311. }
  312. const entries = Object.entries(memberKeys);
  313. const BATCH_SIZE = 20;
  314. for (let i = 0; i < entries.length; i += BATCH_SIZE) {
  315. const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
  316. await new Promise((resolve, reject) => {
  317. ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch },
  318. (err, res) => err ? reject(err) : resolve(res));
  319. });
  320. }
  321. const tribe = await this.getTribeById(tribeId);
  322. if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
  323. const updatedInvites = tribe.invites.map(inv => {
  324. if (typeof inv === 'object' && inv.code) {
  325. return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
  326. }
  327. return inv;
  328. });
  329. await this.updateTribeInvites(tribeId, updatedInvites);
  330. }
  331. },
  332. async processIncomingKeys() {
  333. if (!tribeCrypto) return;
  334. const ssb = await openSsb();
  335. const ssbKeys = require('../server/node_modules/ssb-keys');
  336. const config = require('../server/ssb_config');
  337. const msgs = await new Promise((resolve, reject) => {
  338. pull(
  339. ssb.createLogStream({ limit: logLimit }),
  340. pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
  341. );
  342. });
  343. for (const m of msgs) {
  344. const c = m.value?.content;
  345. if (!c || c.type !== 'tribe-keys') continue;
  346. const myEntry = c.memberKeys && c.memberKeys[ssb.id];
  347. if (!myEntry) continue;
  348. const currentGen = tribeCrypto.getGen(c.tribeId);
  349. if (c.generation <= currentGen) continue;
  350. const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
  351. if (newKey) {
  352. tribeCrypto.addNewKey(c.tribeId, newKey);
  353. }
  354. }
  355. },
  356. async updateTribeById(tribeId, updatedContent) {
  357. const ssb = await openSsb();
  358. const tribe = await this.getTribeById(tribeId);
  359. if (!tribe) throw new Error('Tribe not found');
  360. const updatedTribe = {
  361. type: 'tribe',
  362. ...tribe,
  363. ...updatedContent,
  364. replaces: tribeId,
  365. updatedAt: new Date().toISOString()
  366. };
  367. return this.publishUpdatedTribe(tribeId, updatedTribe);
  368. },
  369. async publishTombstone(tribeId) {
  370. const ssb = await openSsb();
  371. const userId = ssb.id;
  372. const tombstone = {
  373. type: 'tombstone',
  374. target: tribeId,
  375. deletedAt: new Date().toISOString(),
  376. author: userId
  377. };
  378. await new Promise((resolve, reject) => {
  379. ssb.publish(tombstone, (err) => {
  380. if (err) return reject(err);
  381. resolve();
  382. });
  383. });
  384. tribeIndex = null;
  385. },
  386. async listSubTribes(parentId) {
  387. const idx = await buildTribeIndex();
  388. const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
  389. const parentRoot = rootOf(parentId);
  390. const all = await this.listAll();
  391. return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
  392. }
  393. };
  394. };