| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- const pull = require('../server/node_modules/pull-stream');
- const crypto = require('crypto');
- const { getConfig } = require('../configs/config-manager.js');
- const logLimit = getConfig().ssbLogStream?.limit || 1000;
- const INVITE_CODE_BYTES = 16;
- const VALID_INVITE_MODES = ['strict', 'open'];
- module.exports = ({ cooler, tribeCrypto }) => {
- let ssb;
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
- let tribeIndex = null;
- let tribeIndexTs = 0;
- const buildTribeIndex = async () => {
- if (tribeIndex && Date.now() - tribeIndexTs < 5000) return tribeIndex;
- const client = await openSsb();
- return new Promise((resolve, reject) => {
- pull(
- client.createLogStream({ limit: logLimit }),
- pull.collect((err, msgs) => {
- if (err) return reject(err);
- const tombstoned = new Set();
- const parent = new Map();
- const child = new Map();
- const tribes = new Map();
- for (const msg of msgs) {
- const k = msg.key;
- const c = msg.value?.content;
- if (!c) continue;
- if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue; }
- if (c.type !== 'tribe') continue;
- if (c.replaces) {
- parent.set(k, c.replaces);
- child.set(c.replaces, k);
- }
- tribes.set(k, { id: k, content: c, _ts: msg.value?.timestamp });
- }
- 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 tipByRoot = new Map();
- for (const k of tribes.keys()) {
- const root = rootOf(k);
- const tip = tipOf(root);
- tipByRoot.set(root, tip);
- }
- tribeIndex = { tribes, tombstoned, parent, child, tipByRoot };
- tribeIndexTs = Date.now();
- resolve(tribeIndex);
- })
- );
- });
- };
- return {
- type: 'tribe',
- async createTribe(title, description, image, location, tagsRaw = [], isLARP = false, isAnonymous = true, inviteMode = 'strict', parentTribeId = null, status = 'OPEN', mapUrl = '') {
- if (!VALID_INVITE_MODES.includes(inviteMode)) {
- throw new Error('Invalid invite mode. Must be "strict" or "open"');
- }
- const ssb = await openSsb();
- const userId = ssb.id;
- let blobId = null;
- if (image) {
- blobId = String(image).trim() || null;
- }
- const tags = Array.isArray(tagsRaw)
- ? tagsRaw.filter(Boolean)
- : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
- const content = {
- type: 'tribe',
- title,
- description,
- image: blobId,
- location,
- tags,
- isLARP: Boolean(isLARP),
- isAnonymous: Boolean(isAnonymous),
- members: [userId],
- invites: [],
- inviteMode,
- status: status || 'OPEN',
- parentTribeId: parentTribeId || null,
- mapUrl: String(mapUrl || '').trim(),
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- author: userId,
- };
- const result = await new Promise((res, rej) => ssb.publish(content, (e, r) => e ? rej(e) : res(r)));
- if (tribeCrypto) {
- const tribeKey = tribeCrypto.generateTribeKey();
- tribeCrypto.setKey(result.key, tribeKey, 1);
- }
- tribeIndex = null;
- return result;
- },
- async generateInvite(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribe = await this.getTribeById(tribeId);
- if (tribe.inviteMode === 'strict' && tribe.author !== userId) {
- throw new Error('Only the author can generate invites in strict mode');
- }
- if (tribe.inviteMode === 'open' && !tribe.members.includes(userId)) {
- throw new Error('Only tribe members can generate invites in open mode');
- }
- const code = crypto.randomBytes(INVITE_CODE_BYTES).toString('hex');
- let invite = code;
- if (tribeCrypto) {
- const rootId = await this.getRootId(tribeId);
- const tribeKey = tribeCrypto.getKey(rootId);
- if (tribeKey) {
- const ek = tribeCrypto.encryptForInvite(tribeKey, code);
- invite = { code, ek, gen: tribeCrypto.getGen(rootId) };
- }
- }
- const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
- await this.updateTribeInvites(tribeId, invites);
- return code;
- },
- async updateTribeInvites(tribeId, invites) {
- return this.updateTribeById(tribeId, { invites });
- },
- async leaveTribe(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) throw new Error('Tribe not found');
- if (tribe.author === userId) {
- throw new Error('Tribe author cannot leave their own tribe');
- }
- const members = Array.isArray(tribe.members) ? [...tribe.members] : [];
- const idx = members.indexOf(userId);
- if (idx === -1) throw new Error('User is not a member of this tribe');
- members.splice(idx, 1);
- await this.updateTribeById(tribeId, { members });
- await this.rotateTribeKey(tribeId, members);
- },
- async joinByInvite(code) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tribes = await this.listAll();
- let matchedTribe = null;
- let matchedInvite = null;
- for (const t of tribes) {
- if (!t.invites) continue;
- for (const inv of t.invites) {
- if (typeof inv === 'string' && inv === code) {
- matchedTribe = t; matchedInvite = inv; break;
- }
- if (typeof inv === 'object' && inv.code === code) {
- matchedTribe = t; matchedInvite = inv; break;
- }
- }
- if (matchedTribe) break;
- }
- if (!matchedTribe) throw new Error('Invalid or expired invite code');
- if (matchedTribe.members.includes(userId)) {
- throw new Error('Already a member of this tribe');
- }
- if (tribeCrypto && typeof matchedInvite === 'object' && matchedInvite.ek) {
- const tribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
- const rootId = await this.getRootId(matchedTribe.id);
- tribeCrypto.setKey(rootId, tribeKey, matchedInvite.gen || 1);
- }
- const members = [...matchedTribe.members, userId];
- const invites = matchedTribe.invites.filter(inv => {
- if (typeof inv === 'string') return inv !== code;
- return inv.code !== code;
- });
- await this.updateTribeById(matchedTribe.id, { members, invites });
- return matchedTribe.id;
- },
- async deleteTribeById(tribeId) {
- await this.publishTombstone(tribeId);
- },
- async updateTribeMembers(tribeId, members) {
- const tribe = await this.getTribeById(tribeId);
- const oldMembers = tribe.members || [];
- await this.updateTribeById(tribeId, { members });
- const removed = oldMembers.filter(m => !members.includes(m));
- if (removed.length > 0) {
- await this.rotateTribeKey(tribeId, members);
- }
- },
- async publishUpdatedTribe(tribeId, updatedTribe) {
- const ssb = await openSsb();
- const updatedTribeData = {
- type: 'tribe',
- replaces: updatedTribe.replaces || tribeId,
- title: updatedTribe.title,
- description: updatedTribe.description,
- image: updatedTribe.image,
- location: updatedTribe.location,
- tags: updatedTribe.tags,
- isLARP: updatedTribe.isLARP,
- isAnonymous: updatedTribe.isAnonymous,
- members: updatedTribe.members,
- invites: updatedTribe.invites,
- inviteMode: updatedTribe.inviteMode,
- status: updatedTribe.status || 'OPEN',
- parentTribeId: updatedTribe.parentTribeId || null,
- mapUrl: updatedTribe.mapUrl || "",
- createdAt: updatedTribe.createdAt,
- updatedAt: new Date().toISOString(),
- author: updatedTribe.author,
- };
- const result = await new Promise((resolve, reject) => {
- ssb.publish(updatedTribeData, (err, result) => err ? reject(err) : resolve(result));
- });
- tribeIndex = null;
- return result;
- },
- async getTribeById(tribeId) {
- const { tribes, tombstoned, child } = await buildTribeIndex();
- let latestId = tribeId;
- while (child.has(latestId)) latestId = child.get(latestId);
- if (tombstoned.has(latestId)) throw new Error('Tribe not found');
- const tribe = tribes.get(latestId);
- if (!tribe) throw new Error('Tribe not found');
- return {
- id: tribe.id,
- title: tribe.content.title,
- description: tribe.content.description,
- image: tribe.content.image || null,
- location: tribe.content.location,
- tags: Array.isArray(tribe.content.tags) ? tribe.content.tags : [],
- isLARP: !!tribe.content.isLARP,
- isAnonymous: tribe.content.isAnonymous,
- members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
- invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
- inviteMode: tribe.content.inviteMode || 'strict',
- status: tribe.content.status || 'OPEN',
- parentTribeId: tribe.content.parentTribeId || null,
- mapUrl: tribe.content.mapUrl || "",
- createdAt: tribe.content.createdAt,
- updatedAt: tribe.content.updatedAt,
- author: tribe.content.author,
- };
- },
- async listAll() {
- const { tribes, tombstoned, tipByRoot } = await buildTribeIndex();
- const items = [];
- for (const [root, tip] of tipByRoot) {
- if (tombstoned.has(root) || tombstoned.has(tip)) continue;
- const entry = tribes.get(tip);
- if (!entry) continue;
- const c = entry.content;
- items.push({
- id: tip,
- title: c.title,
- description: c.description,
- image: c.image || null,
- location: c.location,
- tags: Array.isArray(c.tags) ? c.tags : [],
- isLARP: !!c.isLARP,
- isAnonymous: c.isAnonymous !== false,
- members: Array.isArray(c.members) ? c.members : [],
- invites: Array.isArray(c.invites) ? c.invites : [],
- inviteMode: c.inviteMode || 'strict',
- status: c.status || 'OPEN',
- parentTribeId: c.parentTribeId || null,
- mapUrl: c.mapUrl || "",
- createdAt: c.createdAt,
- updatedAt: c.updatedAt,
- author: c.author,
- _ts: entry._ts
- });
- }
- return items;
- },
- async getChainIds(tribeId) {
- const { parent, child } = await buildTribeIndex();
- let root = tribeId;
- while (parent.has(root)) root = parent.get(root);
- const ids = [root];
- let cur = root;
- while (child.has(cur)) { cur = child.get(cur); ids.push(cur); }
- return ids;
- },
- async getRootId(tribeId) {
- const { parent } = await buildTribeIndex();
- let root = tribeId;
- while (parent.has(root)) root = parent.get(root);
- return root;
- },
- async getAncestryChain(tribeId) {
- const rootId = await this.getRootId(tribeId);
- const tribe = await this.getTribeById(tribeId);
- const chain = [rootId];
- let currentTribe = tribe;
- while (currentTribe.parentTribeId) {
- const parentRootId = await this.getRootId(currentTribe.parentTribeId);
- chain.push(parentRootId);
- try {
- currentTribe = await this.getTribeById(currentTribe.parentTribeId);
- } catch (e) {
- break;
- }
- }
- return chain;
- },
- async rotateTribeKey(tribeId, remainingMembers) {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const rootId = await this.getRootId(tribeId);
- const oldKey = tribeCrypto.getKey(rootId);
- if (!oldKey) return;
- const newKey = tribeCrypto.generateTribeKey();
- const newGen = tribeCrypto.addNewKey(rootId, newKey);
- const memberKeys = {};
- for (const memberId of remainingMembers) {
- memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys);
- }
- const entries = Object.entries(memberKeys);
- const BATCH_SIZE = 20;
- for (let i = 0; i < entries.length; i += BATCH_SIZE) {
- const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
- await new Promise((resolve, reject) => {
- ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch },
- (err, res) => err ? reject(err) : resolve(res));
- });
- }
- const tribe = await this.getTribeById(tribeId);
- if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
- const updatedInvites = tribe.invites.map(inv => {
- if (typeof inv === 'object' && inv.code) {
- return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
- }
- return inv;
- });
- await this.updateTribeInvites(tribeId, updatedInvites);
- }
- },
- async processIncomingKeys() {
- if (!tribeCrypto) return;
- const ssb = await openSsb();
- const ssbKeys = require('../server/node_modules/ssb-keys');
- const config = require('../server/ssb_config');
- const msgs = await new Promise((resolve, reject) => {
- pull(
- ssb.createLogStream({ limit: logLimit }),
- pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
- );
- });
- for (const m of msgs) {
- const c = m.value?.content;
- if (!c || c.type !== 'tribe-keys') continue;
- const myEntry = c.memberKeys && c.memberKeys[ssb.id];
- if (!myEntry) continue;
- const currentGen = tribeCrypto.getGen(c.tribeId);
- if (c.generation <= currentGen) continue;
- const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
- if (newKey) {
- tribeCrypto.addNewKey(c.tribeId, newKey);
- }
- }
- },
-
- async updateTribeById(tribeId, updatedContent) {
- const ssb = await openSsb();
- const tribe = await this.getTribeById(tribeId);
- if (!tribe) throw new Error('Tribe not found');
- const updatedTribe = {
- type: 'tribe',
- ...tribe,
- ...updatedContent,
- replaces: tribeId,
- updatedAt: new Date().toISOString()
- };
- return this.publishUpdatedTribe(tribeId, updatedTribe);
- },
- async publishTombstone(tribeId) {
- const ssb = await openSsb();
- const userId = ssb.id;
- const tombstone = {
- type: 'tombstone',
- target: tribeId,
- deletedAt: new Date().toISOString(),
- author: userId
- };
- await new Promise((resolve, reject) => {
- ssb.publish(tombstone, (err) => {
- if (err) return reject(err);
- resolve();
- });
- });
- tribeIndex = null;
- },
- async listSubTribes(parentId) {
- const idx = await buildTribeIndex();
- const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
- const parentRoot = rootOf(parentId);
- const all = await this.listAll();
- return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
- }
- };
- };
|