define([ '/common/common-util.js', '/common/common-hash.js', '/common/common-constants.js', '/common/common-realtime.js', '/common/proxy-manager.js', '/common/userObject.js', '/common/outer/sharedfolder.js', '/common/outer/roster.js', '/common/common-messaging.js', '/common/common-feedback.js', '/bower_components/chainpad-listmap/chainpad-listmap.js', '/bower_components/chainpad-crypto/crypto.js', '/bower_components/chainpad-netflux/chainpad-netflux.js', '/bower_components/chainpad/chainpad.dist.js', '/bower_components/nthen/index.js', '/bower_components/saferphore/index.js', '/bower_components/tweetnacl/nacl-fast.min.js', ], function (Util, Hash, Constants, Realtime, ProxyManager, UserObject, SF, Roster, Messaging, Feedback, Listmap, Crypto, CpNetflux, ChainPad, nThen, Saferphore) { var Team = {}; var Nacl = window.nacl; var initializeTeams = function (ctx, cb) { cb(); }; var registerChangeEvents = function (ctx, team, proxy, fId) { if (!team) { return; } if (!fId) { // Listen for shared folder password change proxy.on('change', ['drive', UserObject.SHARED_FOLDERS], function (o, n, p) { if (p.length > 3 && p[3] === 'password') { var id = p[2]; var data = proxy.drive[UserObject.SHARED_FOLDERS][id]; var href = team.manager.user.userObject.getHref ? team.manager.user.userObject.getHref(data) : data.href; var parsed = Hash.parsePadUrl(href); var secret = Hash.getSecrets(parsed.type, parsed.hash, o); // We've received a new password, we should update it locally // NOTE: this is an async call because new password means new roHref! // We need to wait for the new roHref in the proxy before calling the handlers // because a read-only team will use it when connecting to the new channel setTimeout(function () { SF.updatePassword(ctx.Store, { oldChannel: secret.channel, password: n, href: href }, ctx.store.network, function () { console.log('Shared folder password changed'); }); }); return false; } }); } proxy.on('change', [], function (o, n, p) { if (fId) { // Pin the new pads if (p[0] === UserObject.FILES_DATA && typeof(n) === "object" && n.channel && !n.owners) { var toPin = [n.channel]; // Also pin the onlyoffice channels if they exist if (n.rtChannel) { toPin.push(n.rtChannel); } if (n.lastVersion) { toPin.push(n.lastVersion); } team.pin(toPin, function (obj) { if (obj && obj.error) { console.error(obj.error); } }); } // Unpin the deleted pads (deleted <=> changed to undefined) if (p[0] === UserObject.FILES_DATA && typeof(o) === "object" && o.channel && !n) { var toUnpin = [o.channel]; var c = team.manager.findChannel(o.channel); var exists = c.some(function (data) { return data.fId !== fId; }); if (!exists) { // Unpin // Also unpin the onlyoffice channels if they exist if (o.rtChannel) { toUnpin.push(o.rtChannel); } if (o.lastVersion) { toUnpin.push(o.lastVersion); } team.unpin(toUnpin, function (obj) { if (obj && obj.error) { console.error(obj); } }); } } } team.sendEvent('DRIVE_CHANGE', { id: fId, old: o, new: n, path: p }); }); proxy.on('remove', [], function (o, p) { team.sendEvent('DRIVE_REMOVE', { id: fId, old: o, path: p }); }); proxy.on('disconnect', function () { team.offline = true; }); proxy.on('reconnect', function (/* info */) { team.offline = false; }); }; var closeTeam = function (ctx, teamId) { var team = ctx.teams[teamId]; if (!team) { return; } try { team.listmap.stop(); } catch (e) {} try { team.roster.stop(); } catch (e) {} team.proxy = {}; team.stopped = true; delete ctx.teams[teamId]; delete ctx.store.proxy.teams[teamId]; ctx.emit('LEAVE_TEAM', teamId, team.clients); ctx.updateMetadata(); }; var getTeamChannelList = function (ctx, id) { // Get the list of pads' channel ID in your drive // This list is filtered so that it doesn't include pad owned by other users // It now includes channels from shared folders var store = ctx.teams[id]; if (!store) { return null; } var list = store.manager.getChannelsList('pin'); var team = ctx.store.proxy.teams[id]; list.push(team.channel); var chatChannel = Util.find(team, ['keys', 'chat', 'channel']); var membersChannel = Util.find(team, ['keys', 'roster', 'channel']); var mailboxChannel = Util.find(team, ['keys', 'mailbox', 'channel']); if (chatChannel) { list.push(chatChannel); } if (membersChannel) { list.push(membersChannel); } if (mailboxChannel) { list.push(mailboxChannel); } list.sort(); return list; }; var handleSharedFolder = function (ctx, id, sfId, rt) { var t = ctx.teams[id]; if (!t) { return; } t.sharedFolders[sfId] = rt; registerChangeEvents(ctx, t, rt.proxy, sfId); }; var initRpc = function (ctx, team, data, cb) { if (team.rpc) { return void cb(); } if (!data.edPrivate || !data.edPublic) { return void cb('EFORBIDDEN'); } require(['/common/pinpad.js'], function (Pinpad) { Pinpad.create(ctx.store.network, data, function (e, call) { if (e) { return void cb(e); } team.rpc = call; cb(); }); }); }; var onReady = function (ctx, id, lm, roster, keys, cId, cb) { var proxy = lm.proxy; var team = { id: id, proxy: proxy, listmap: lm, clients: [], realtime: lm.realtime, handleSharedFolder: function (sfId, rt) { handleSharedFolder(ctx, id, sfId, rt); }, sharedFolders: {}, // equivalent of store.sharedFolders in async-store roster: roster }; // Subscribe to events if (cId) { team.clients.push(cId); } // Listen for roster changes roster.on('change', function () { var state = roster.getState(); var me = Util.find(ctx, ['store', 'proxy', 'curvePublic']); if (!state.members[me]) { return void closeTeam(ctx, id); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', id]); if (teamData) { teamData.metadata = state.metadata; } ctx.updateMetadata(); ctx.emit('ROSTER_CHANGE', id, team.clients); }); roster.on('checkpoint', function (hash) { var rosterData = Util.find(ctx, ['store', 'proxy', 'teams', id, 'keys', 'roster']); rosterData.lastKnownHash = hash; }); // Update metadata var state = roster.getState(); var teamData = Util.find(ctx, ['store', 'proxy', 'teams', id]); if (teamData) { teamData.metadata = state.metadata; } // Broadcast an event to all the tabs displaying this team team.sendEvent = function (q, data, sender) { ctx.emit(q, data, team.clients.filter(function (cId) { return cId !== sender; })); }; // Provide team chat keys to the messenger app team.getChatData = function () { var chatKeys = keys.chat || {}; var hash = chatKeys.edit || chatKeys.view; if (!hash) { return {}; } var secret = Hash.getSecrets('chat', hash); return { teamId: id, channel: secret.channel, secret: secret, validateKey: chatKeys.validateKey }; }; var secret; team.pin = function (data, cb) { return void cb({error: 'EFORBIDDEN'}); }; team.unpin = function (data, cb) { return void cb({error: 'EFORBIDDEN'}); }; nThen(function (waitFor) { // Init Team RPC if (!keys.drive.edPrivate) { return; } initRpc(ctx, team, keys.drive, waitFor(function (err) { if (err) { return; } team.pin = function (data, cb) { if (!team.rpc) { return void cb({error: 'TEAM_RPC_NOT_READY'}); } if (typeof(cb) !== 'function') { console.error('expected a callback'); } team.rpc.pin(data, function (e, hash) { if (e) { return void cb({error: e}); } cb({hash: hash}); }); }; team.unpin = function (data, cb) { if (!team.rpc) { return void cb({error: 'TEAM_RPC_NOT_READY'}); } if (typeof(cb) !== 'function') { console.error('expected a callback'); } team.rpc.unpin(data, function (e, hash) { if (e) { return void cb({error: e}); } cb({hash: hash}); }); }; })); }).nThen(function () { // Create the proxy manager var loadSharedFolder = function (id, data, cb, isNew) { SF.load({ isNew: isNew, network: ctx.store.network, store: team, isNewChannel: ctx.Store.isNewChannel }, id, data, cb); }; var teamData = ctx.store.proxy.teams[team.id]; var hash = teamData.hash || teamData.roHash; secret = Hash.getSecrets('team', hash, teamData.password); var manager = team.manager = ProxyManager.create(proxy.drive, { onSync: function (cb) { ctx.Store.onSync(id, cb); }, edPublic: keys.drive.edPublic, pin: team.pin, unpin: team.unpin, loadSharedFolder: loadSharedFolder, settings: { drive: Util.find(ctx.store, ['proxy', 'settings', 'drive']) }, Store: ctx.Store }, { outer: true, removeOwnedChannel: function (channel, cb) { var data; if (typeof(channel) === "object") { channel.teamId = id; data = channel; } else { data = { channel: channel, teamId: id }; } ctx.Store.removeOwnedChannel('', data, cb); }, edPublic: keys.drive.edPublic, loggedIn: true, log: function (msg) { // broadcast to all drive apps team.sendEvent("DRIVE_LOG", msg); }, rt: team.realtime, editKey: secret.keys.secondaryKey, readOnly: Boolean(!secret.keys.secondaryKey) }); team.secondaryKey = secret && secret.keys.secondaryKey; team.userObject = manager.user.userObject; team.userObject.fixFiles(); }).nThen(function (waitFor) { // Load the shared folders ctx.teams[id] = team; registerChangeEvents(ctx, team, proxy); SF.checkMigration(team.secondaryKey, proxy, team.userObject, waitFor()); SF.loadSharedFolders(ctx.Store, ctx.store.network, team, team.userObject, waitFor); }).nThen(function () { if (!team.rpc) { return; } var list = getTeamChannelList(ctx, id); var local = Hash.hashChannelList(list); // Check pin list team.rpc.getServerHash(function (e, hash) { if (e) { return void console.warn(e); } if (hash !== local) { // Reset pin list team.rpc.reset(list, function (e/*, hash*/) { if (e) { console.warn(e); } }); } }); }).nThen(function () { if (ctx.onReadyHandlers[id]) { ctx.onReadyHandlers[id].forEach(function (obj) { // Callback and subscribe the client to new notifications if (typeof (obj.cb) === "function") { obj.cb(); } if (!obj.cId) { return; } var idx = team.clients.indexOf(obj.cId); if (idx === -1) { team.clients.push(obj.cId); } }); } delete ctx.onReadyHandlers[id]; cb(); }); }; var openChannel = function (ctx, teamData, id, _cb) { var cb = Util.once(_cb); var hash = teamData.hash || teamData.roHash; var secret = Hash.getSecrets('team', hash, teamData.password); var crypto = Crypto.createEncryptor(secret.keys); if (!teamData.roHash) { teamData.roHash = Hash.getViewHashFromKeys(secret); } var keys = teamData.keys; if (!keys.chat.validateKey && keys.chat.edit) { var chatSecret = Hash.getSecrets('chat', keys.chat.edit); keys.chat.validateKey = chatSecret.keys.validateKey; } var roster; var lm; // Roster keys var myKeys = { curvePublic: ctx.store.proxy.curvePublic, curvePrivate: ctx.store.proxy.curvePrivate }; var rosterData = keys.roster || {}; var rosterKeys = rosterData.edit ? Crypto.Team.deriveMemberKeys(rosterData.edit, myKeys) : Crypto.Team.deriveGuestKeys(rosterData.view || ''); nThen(function (waitFor) { ctx.store.anon_rpc.send("IS_NEW_CHANNEL", secret.channel, waitFor(function (e, response) { if (response && response.length && typeof(response[0]) === 'boolean' && response[0]) { // Channel is empty: remove this team delete ctx.store.proxy.teams[id]; waitFor.abort(); cb({error: 'ENOENT'}); } })); ctx.store.anon_rpc.send("IS_NEW_CHANNEL", rosterKeys.channel, waitFor(function (e, response) { if (response && response.length && typeof(response[0]) === 'boolean' && response[0]) { // Channel is empty: remove this team delete ctx.store.proxy.teams[id]; waitFor.abort(); cb({error: 'ENOENT'}); } })); }).nThen(function (waitFor) { // Load the proxy var cfg = { data: {}, readOnly: !Boolean(secret.keys.signKey), network: ctx.store.network, channel: secret.channel, crypto: crypto, ChainPad: ChainPad, metadata: { validateKey: secret.keys.validateKey || undefined, }, userName: 'team', classic: true }; cfg.onMetadataUpdate = function () { var team = ctx.teams[id]; if (!team) { return; } ctx.emit('ROSTER_CHANGE', id, team.clients); }; lm = Listmap.create(cfg); lm.proxy.on('ready', waitFor()); lm.proxy.on('error', function (info) { if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { cb({error:'ECONNECT'}); } if (info && info.error) { if (info.error === "EDELETED" ) { closeTeam(ctx, id); } } }); // Load the roster Roster.create({ network: ctx.store.network, channel: rosterKeys.channel, keys: rosterKeys, anon_rpc: ctx.store.anon_rpc, lastKnownHash: rosterData.lastKnownHash, }, waitFor(function (err, _roster) { if (err) { waitFor.abort(); console.error(err); return void cb({error: 'ROSTER_ERROR'}); } roster = _roster; rosterData.lastKnownHash = roster.getLastCheckpointHash(); // If we've been kicked, don't try to update our data, we'll close everything // in the next nThen part var state = roster.getState(); var me = Util.find(ctx, ['store', 'proxy', 'curvePublic']); if (!state.members[me]) { return; } // If you're allowed to edit the roster, try to update your data if (!rosterData.edit) { return; } var data = {}; var myData = Messaging.createData(ctx.store.proxy, false); myData.pending = false; data[ctx.store.proxy.curvePublic] = myData; roster.describe(data, function (err) { if (!err) { return; } if (err === 'NO_CHANGE') { return; } console.error(err); }); })); }).nThen(function (waitFor) { // Make sure we have not been kicked from the roster var state = roster.getState(); var me = Util.find(ctx, ['store', 'proxy', 'curvePublic']); if (!state.members[me]) { lm.stop(); roster.stop(); lm.proxy = {}; delete ctx.store.proxy.teams[id]; ctx.updateMetadata(); cb({error: 'EFORBIDDEN'}); waitFor.abort(); } }).nThen(function () { onReady(ctx, id, lm, roster, keys, null, cb); }); }; var createTeam = function (ctx, data, cId, _cb) { var cb = Util.once(_cb); var password = Hash.createChannelId(); var hash = Hash.createRandomHash('team', password); var secret = Hash.getSecrets('team', hash, password); var roHash = Hash.getViewHashFromKeys(secret); var keyPair = Nacl.sign.keyPair(); // keyPair.secretKey , keyPair.publicKey var rosterSeed = Crypto.Team.createSeed(); var rosterKeys = Crypto.Team.deriveMemberKeys(rosterSeed, { curvePublic: ctx.store.proxy.curvePublic, curvePrivate: ctx.store.proxy.curvePrivate }); var roster; var chatSecret = Hash.getSecrets('chat'); var chatHashes = Hash.getHashes(chatSecret); var config = { network: ctx.store.network, channel: secret.channel, data: {}, validateKey: secret.keys.validateKey, // derived validation key crypto: Crypto.createEncryptor(secret.keys), logLevel: 1, classic: true, ChainPad: ChainPad, owners: [ctx.store.proxy.edPublic] }; nThen(function (waitFor) { // Initialize the roster Roster.create({ network: ctx.store.network, channel: rosterKeys.channel, //sharedConfig.rosterChannel, owners: [ctx.store.proxy.edPublic], keys: rosterKeys, anon_rpc: ctx.store.anon_rpc, lastKnownHash: void 0, }, waitFor(function (err, _roster) { if (err) { waitFor.abort(); return void cb({error: 'ROSTER_ERROR'}); } roster = _roster; var myData = Messaging.createData(ctx.store.proxy); delete myData.channel; roster.init(myData, waitFor(function (err) { if (err) { waitFor.abort(); return void cb({error: 'ROSTER_INIT_ERROR'}); } })); })); // Add yourself as owner of the chat channel var crypto = Crypto.createEncryptor(chatSecret.keys); var chatCfg = { network: ctx.store.network, channel: chatSecret.channel, noChainPad: true, crypto: crypto, metadata: { validateKey: chatSecret.keys.validateKey, owners: [ctx.store.proxy.edPublic], } }; var chatReady = waitFor(); var cpNf2; chatCfg.onReady = function () { if (cpNf2) { cpNf2.stop(); } chatReady(); }; chatCfg.onError = function () { waitFor.abort(); return void cb({error: 'CHAT_INIT_ERROR'}); }; cpNf2 = CpNetflux.start(chatCfg); }).nThen(function (waitFor) { roster.metadata({ name: data.name }, waitFor(function (err) { if (err) { waitFor.abort(); return void cb({error: 'ROSTER_INIT_ERROR'}); } })); }).nThen(function () { var id = Util.createRandomInteger(); config.onMetadataUpdate = function () { var team = ctx.teams[id]; if (!team) { return; } ctx.emit('ROSTER_CHANGE', id, team.clients); }; var lm = Listmap.create(config); var proxy = lm.proxy; proxy.version = 2; // No migration needed proxy.on('ready', function () { // Store keys in our drive var keys = { drive: { edPrivate: Nacl.util.encodeBase64(keyPair.secretKey), edPublic: Nacl.util.encodeBase64(keyPair.publicKey) }, chat: { edit: chatHashes.editHash, view: chatHashes.viewHash, validateKey: chatSecret.keys.validateKey, channel: chatSecret.channel }, roster: { channel: rosterKeys.channel, edit: rosterSeed, view: rosterKeys.viewKeyStr, } }; ctx.store.proxy.teams[id] = { owner: true, channel: secret.channel, hash: hash, roHash: roHash, password: password, keys: keys, //members: membersHashes.editHash, metadata: { name: data.name } }; // Initialize the team drive proxy.drive = {}; onReady(ctx, id, lm, roster, keys, cId, function () { Feedback.send('TEAM_CREATION'); ctx.updateMetadata(); cb(); }); }).on('error', function (info) { if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { cb({error:'ECONNECT'}); } if (info && info.error) { if (info.error === "EDELETED") { closeTeam(ctx, id); } } }); }); }; var deleteTeam = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!team || !teamData) { return void cb ({error: 'ENOENT'}); } var state = team.roster.getState(); var curvePublic = Util.find(ctx, ['store', 'proxy', 'curvePublic']); var me = state.members[curvePublic]; if (!me || me.role !== "OWNER") { return cb({ error: "EFORBIDDEN"}); } var edPublic = Util.find(ctx, ['store', 'proxy', 'edPublic']); var teamEdPublic = Util.find(teamData, ['keys', 'drive', 'edPublic']); nThen(function (waitFor) { ctx.Store.anonRpcMsg(null, { msg: 'GET_METADATA', data: teamData.channel }, waitFor(function (obj) { // If we can't get owners, abort if (obj && obj.error) { waitFor.abort(); return cb({ error: obj.error}); } // Check if we're an owner of the team drive var metadata = obj[0]; if (metadata && Array.isArray(metadata.owners) && metadata.owners.indexOf(edPublic) !== -1) { return; } // If w'e're not an owner, abort waitFor.abort(); cb({error: 'EFORBIDDEN'}); })); }).nThen(function (waitFor) { team.proxy.delete = true; // For each pad, check on the server if there are other owners. // If yes, then remove yourself as an owner // If no, delete the pad var ownedPads = team.manager.getChannelsList('owned'); var sem = Saferphore.create(10); ownedPads.forEach(function (c) { var w = waitFor(); sem.take(function (give) { var otherOwners = false; nThen(function (_w) { ctx.Store.anonRpcMsg(null, { msg: 'GET_METADATA', data: c }, _w(function (obj) { if (obj && obj.error) { give(); return void _w.abort(); } var md = obj[0]; var isOwner = md && Array.isArray(md.owners) && md.owners.indexOf(teamEdPublic) !== -1; if (!isOwner) { give(); return void _w.abort(); } otherOwners = md.owners.some(function (ed) { return ed !== teamEdPublic; }); })); }).nThen(function (_w) { if (otherOwners) { ctx.Store.setPadMetadata(null, { channel: c, command: 'RM_OWNERS', value: [teamEdPublic], }, _w()); return; } // We're the only owner: delete the pad team.rpc.removeOwnedChannel(c, _w(function (err) { if (err) { console.error(err); } })); }).nThen(function () { give(); w(); }); }); }); }).nThen(function (waitFor) { // Delete the pins log team.rpc.removePins(waitFor(function (err) { if (err) { console.error(err); } })); // Delete the roster var rosterChan = Util.find(teamData, ['keys', 'roster', 'channel']); ctx.store.rpc.removeOwnedChannel(rosterChan, waitFor(function (err) { if (err) { console.error(err); } })); // Delete the chat var chatChan = Util.find(teamData, ['keys', 'chat', 'channel']); ctx.store.rpc.removeOwnedChannel(chatChan, waitFor(function (err) { if (err) { console.error(err); } })); // Delete the team drive ctx.store.rpc.removeOwnedChannel(teamData.channel, waitFor(function (err) { if (err) { console.error(err); } })); }).nThen(function () { Feedback.send('TEAM_DELETION'); closeTeam(ctx, teamId); cb(); }); }; var joinTeam = function (ctx, data, cId, cb) { var team = data.team; if (!(team.hash || team.roHash) || !team.channel || !team.password || !team.keys || !team.metadata) { return void cb({error: 'EINVAL'}); } var id = Util.createRandomInteger(); ctx.store.proxy.teams[id] = team; ctx.onReadyHandlers[id] = []; openChannel(ctx, team, id, function (obj) { if (!(obj && obj.error)) { console.debug('Team joined:' + id); } ctx.updateMetadata(); cb(obj); }); }; var leaveTeam = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var curvePublic = ctx.store.proxy.curvePublic; team.roster.remove([curvePublic], function (err) { if (err) { return void cb({error: err}); } closeTeam(ctx, teamId); cb(); }); }; var getTeamRoster = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var state = team.roster.getState() || {}; var members = state.members || {}; // Get pending owners var md = team.listmap.metadata || {}; if (Array.isArray(md.pending_owners)) { // Get the members associated to the pending_owners' edPublic and mark them as such md.pending_owners.forEach(function (ed) { var member; Object.keys(members).some(function (curve) { if (members[curve].edPublic === ed) { member = members[curve]; return true; } }); if (!member && teamData.owner) { var removeOwnership = function (chan) { ctx.Store.setPadMetadata(null, { channel: chan, command: 'RM_PENDING_OWNERS', value: [ed], }, function () {}); }; removeOwnership(teamData.channel); removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel'])); removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel'])); return; } member.pendingOwner = true; }); } // Add online status (using messenger data) var chatData = team.getChatData(); var online = ctx.store.messenger.getOnlineList(chatData.channel) || []; online.forEach(function (curve) { if (members[curve]) { members[curve].online = true; } }); cb(members); }; // Return folders with edit rights available to everybody (decrypted pad href) var getEditableFolders = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } var folders = team.manager.folders || {}; var ids = Object.keys(folders).filter(function (id) { return !folders[id].proxy.version; }); cb(ids.map(function (id) { var uo = Util.find(team, ['user', 'userObject']); return { name: Util.find(folders, [id, 'proxy', 'metadata', 'title']), path: uo ? uo.findFile(id)[0] : [] }; })); }; var getTeamMetadata = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var state = team.roster.getState() || {}; cb(state.metadata || {}); }; var setTeamMetadata = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } team.roster.metadata(data.metadata, function (err) { if (err) { return void cb({error: err}); } var localTeam = ctx.store.proxy.teams[teamId]; if (localTeam) { localTeam.metadata = data.metadata; } cb(); }); }; var offerOwnership = function (ctx, data, cId, _cb) { var cb = Util.once(_cb); var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } if (!data.curvePublic) { return void cb({error: 'MISSING_DATA'}); } var state = team.roster.getState(); var user = state.members[data.curvePublic]; nThen(function (waitFor) { // Offer ownership to a friend var onError = function (res) { var err = res && res.error; if (err) { console.error(err); waitFor.abort(); return void cb({error:err}); } }; var addPendingOwner = function (chan) { ctx.Store.setPadMetadata(null, { channel: chan, command: 'ADD_PENDING_OWNERS', value: [user.edPublic], }, waitFor(onError)); }; // Team proxy addPendingOwner(teamData.channel); // Team roster addPendingOwner(Util.find(teamData, ['keys', 'roster', 'channel'])); // Team chat addPendingOwner(Util.find(teamData, ['keys', 'chat', 'channel'])); }).nThen(function (waitFor) { var obj = {}; obj[user.curvePublic] = { role: 'OWNER' }; team.roster.describe(obj, waitFor(function (err) { if (err) { console.error(err); } })); }).nThen(function (waitFor) { // Send mailbox to offer ownership var myData = Messaging.createData(ctx.store.proxy, false); ctx.store.mailbox.sendTo("ADD_OWNER", { teamChannel: teamData.channel, chatChannel: Util.find(teamData, ['keys', 'chat', 'channel']), rosterChannel: Util.find(teamData, ['keys', 'roster', 'channel']), title: teamData.metadata.name, user: myData }, { channel: user.notifications, curvePublic: user.curvePublic }, waitFor()); }).nThen(function () { cb(); }); }; var revokeOwnership = function (ctx, teamId, user, _cb) { var cb = Util.once(_cb); if (!teamId) { return void cb({error: 'EINVAL'}); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } var md = team.listmap.metadata || {}; var isPendingOwner = (md.pending_owners || []).indexOf(user.edPublic) !== -1; nThen(function (waitFor) { var cmd = isPendingOwner ? 'RM_PENDING_OWNERS' : 'RM_OWNERS'; var onError = function (res) { var err = res && res.error; if (err) { console.error(err); waitFor.abort(); return void cb(err); } }; var removeOwnership = function (chan) { ctx.Store.setPadMetadata(null, { channel: chan, command: cmd, value: [user.edPublic], }, waitFor(onError)); }; // Team proxy removeOwnership(teamData.channel); // Team roster removeOwnership(Util.find(teamData, ['keys', 'roster', 'channel'])); // Team chat removeOwnership(Util.find(teamData, ['keys', 'chat', 'channel'])); }).nThen(function (waitFor) { var obj = {}; obj[user.curvePublic] = { role: 'ADMIN', pendingOwner: false }; team.roster.describe(obj, waitFor(function (err) { if (err) { console.error(err); } })); }).nThen(function (waitFor) { // Send mailbox to offer ownership var myData = Messaging.createData(ctx.store.proxy, false); ctx.store.mailbox.sendTo("RM_OWNER", { teamChannel: teamData.channel, title: teamData.metadata.name, pending: isPendingOwner, user: myData }, { channel: user.notifications, curvePublic: user.curvePublic }, waitFor()); }).nThen(function () { cb(); }); }; // We've received an offer to be an owner of the team. // If we accept, we need to set the "owner" flag in our team data // If we decline, we need to change our role back to "ADMIN" var answerOwnership = function (ctx, data, cId, cb) { var myTeams = ctx.store.proxy.teams; var teamId; Object.keys(myTeams).forEach(function (id) { if (myTeams[id].channel === data.teamChannel) { teamId = id; return true; } }); if (!teamId) { return void cb({error: 'EINVAL'}); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var obj = {}; // Accept if (data.answer) { teamData.owner = true; return; } // Decline obj[ctx.store.proxy.curvePublic] = { role: 'ADMIN', }; team.roster.describe(obj, function (err) { if (err) { return void cb({error: err}); } cb(); }); }; var getInviteData = function (ctx, teamId, edit) { var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return {}; } var data = Util.clone(teamData); if (!edit) { // Delete edit keys delete data.hash; delete data.keys.drive.edPrivate; delete data.keys.chat.edit; } // Delete owner key delete data.owner; return data; }; // Update my edit rights in listmap (only upgrade) and userObject (upgrade and downgrade) // We also need to propagate the changes to the shared folders var updateMyRights = function (ctx, teamId, hash) { if (!teamId) { return true; } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return true; } var team = ctx.teams[teamId]; var secret = Hash.getSecrets('team', hash || teamData.roHash, teamData.password); // Upgrade the listmap if we can SF.upgrade(teamData.channel, secret); // Set the new readOnly value in userObject if (team.userObject) { team.userObject.setReadOnly(!secret.keys.secondaryKey, secret.keys.secondaryKey); } // Upgrade the shared folders var folders = Util.find(team, ['proxy', 'drive', 'sharedFolders']); Object.keys(folders || {}).forEach(function (sfId) { var data = team.manager.getSharedFolderData(sfId); var parsed = Hash.parsePadUrl(data.href || data.roHref); var secret = Hash.getSecrets(parsed.type, parsed.hash, data.password); SF.upgrade(secret.channel, secret); var uo = Util.find(team, ['manager', 'folders', sfId, 'userObject']); if (uo) { uo.setReadOnly(!secret.keys.secondaryKey, secret.keys.secondaryKey); } }); ctx.updateMetadata(); ctx.emit('ROSTER_CHANGE_RIGHTS', teamId, team.clients); }; var changeMyRights = function (ctx, teamId, state, data) { if (!teamId) { return true; } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return true; } var team = ctx.teams[teamId]; if (!team) { return true; } if (teamData.channel !== data.channel || teamData.password !== data.password) { return true; } if (state) { teamData.hash = data.hash; teamData.keys.drive.edPrivate = data.keys.drive.edPrivate; teamData.keys.chat.edit = data.keys.chat.edit; var secret = Hash.getSecrets('team', data.hash, teamData.password); team.secondaryKey = secret && secret.keys.secondaryKey; } else { delete teamData.hash; delete teamData.keys.drive.edPrivate; delete teamData.keys.chat.edit; delete team.secondaryKey; } updateMyRights(ctx, teamId, data.hash); }; var changeEditRights = function (ctx, teamId, user, state, cb) { if (!teamId) { return void cb({error: 'EINVAL'}); } var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]); if (!teamData) { return void cb ({error: 'ENOENT'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } // Send mailbox to offer ownership var myData = Messaging.createData(ctx.store.proxy, false); ctx.store.mailbox.sendTo("TEAM_EDIT_RIGHTS", { state: state, teamData: getInviteData(ctx, teamId, state), user: myData }, { channel: user.notifications, curvePublic: user.curvePublic }, cb); }; var describeUser = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } if (!data.curvePublic || !data.data) { return void cb({error: 'MISSING_DATA'}); } var state = team.roster.getState(); var user = state.members[data.curvePublic]; // It it is an ownership revocation, we have to set it in pad metadata first if (user.role === "OWNER" && data.data.role !== "OWNER") { revokeOwnership(ctx, teamId, user, function (err) { if (!err) { return void cb(); } console.error(err); return void cb({error: err}); }); return; } // Viewer to editor if (user.role === "VIEWER" && data.data.role !== "VIEWER") { changeEditRights(ctx, teamId, user, true, function (err) { return void cb({error: err}); }); } // Editor to viewer if (user.role !== "VIEWER" && data.data.role === "VIEWER") { changeEditRights(ctx, teamId, user, false, function (err) { return void cb({error: err}); }); } var obj = {}; obj[data.curvePublic] = data.data; team.roster.describe(obj, function (err) { if (err) { return void cb({error: err}); } cb(); }); }; var inviteToTeam = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } var user = data.user; if (!user || !user.curvePublic || !user.notifications) { return void cb({error: 'MISSING_DATA'}); } delete user.channel; delete user.lastKnownHash; user.pending = true; var obj = {}; obj[user.curvePublic] = user; obj[user.curvePublic].role = 'VIEWER'; team.roster.add(obj, function (err) { if (err && err !== 'NO_CHANGE') { return void cb({error: err}); } ctx.store.mailbox.sendTo('INVITE_TO_TEAM', { user: Messaging.createData(ctx.store.proxy, false), team: getInviteData(ctx, teamId) }, { channel: user.notifications, curvePublic: user.curvePublic }, function (obj) { cb(obj); }); }); }; var removeUser = function (ctx, data, cId, cb) { var teamId = data.teamId; if (!teamId) { return void cb({error: 'EINVAL'}); } var team = ctx.teams[teamId]; if (!team) { return void cb ({error: 'ENOENT'}); } if (!team.roster) { return void cb({error: 'NO_ROSTER'}); } if (!data.curvePublic) { return void cb({error: 'MISSING_DATA'}); } var state = team.roster.getState(); var userData = state.members[data.curvePublic]; team.roster.remove([data.curvePublic], function (err) { if (err) { return void cb({error: err}); } // The user has been removed, send them a notification if (!userData || !userData.notifications) { return cb(); } ctx.store.mailbox.sendTo('KICKED_FROM_TEAM', { pending: data.pending, user: Messaging.createData(ctx.store.proxy, false), teamChannel: getInviteData(ctx, teamId).channel, teamName: getInviteData(ctx, teamId).metadata.name }, { channel: userData.notifications, curvePublic: userData.curvePublic }, function (obj) { cb(obj); }); }); }; // Remove a client from all the team they're subscribed to var removeClient = function (ctx, cId) { Object.keys(ctx.onReadyHandlers).forEach(function (teamId) { var idx = -1; ctx.onReadyHandlers[teamId].some(function (obj, _idx) { if (obj.cId === cId) { idx = _idx; return true; } }); if (idx !== -1) { ctx.onReadyHandlers[teamId].splice(idx, 1); } }); Object.keys(ctx.teams).forEach(function (id) { var clients = ctx.teams[id].clients; var idx = clients.indexOf(cId); if (idx !== -1) { clients.splice(idx, 1); } }); }; var subscribe = function (ctx, id, cId, cb) { // Unsubscribe from other teams: one tab can only receive events about one team removeClient(ctx, cId); // And leave the chat channel try { ctx.store.messenger.removeClient(cId); } catch (e) {} if (!id) { return void cb(); } // If the team is loading, as ourselves in the list if (ctx.onReadyHandlers[id]) { var _idx = ctx.onReadyHandlers[id].indexOf(cId); if (_idx === -1) { ctx.onReadyHandlers[id].push({ cId: cId, cb: cb }); } return; } // Otherwise, subscribe to new notifications if (!ctx.teams[id]) { return void cb({error: 'EINVAL'}); } var clients = ctx.teams[id].clients; var idx = clients.indexOf(cId); if (idx === -1) { clients.push(cId); } cb(); }; var openTeamChat = function (ctx, data, cId, cb) { var team = ctx.teams[data.teamId]; if (!team) { return void cb({error: 'ENOENT'}); } var onUpdate = function () { ctx.emit('ROSTER_CHANGE', data.teamId, team.clients); }; ctx.store.messenger.openTeamChat(team.getChatData(), onUpdate, cId, cb); }; Team.init = function (cfg, waitFor, emit) { var team = {}; var store = cfg.store; if (!store.loggedIn || !store.proxy.edPublic) { return; } var ctx = { store: store, Store: cfg.Store, pinPads: cfg.pinPads, emit: emit, onReadyHandlers: {}, teams: {}, updateMetadata: cfg.updateMetadata }; var teams = store.proxy.teams = store.proxy.teams || {}; initializeTeams(ctx, waitFor(function (err) { if (err) { return; } })); // Listen for changes in our access rights (if another worker receives edit access) ctx.store.proxy.on('change', ['teams'], function (o, n, p) { if (p[2] !== 'hash') { return; } updateMyRights(ctx, p[1], n); }); ctx.store.proxy.on('remove', ['teams'], function (o, p) { if (p[2] !== 'hash') { return; } updateMyRights(ctx, p[1]); }); Object.keys(teams).forEach(function (id) { ctx.onReadyHandlers[id] = []; openChannel(ctx, teams[id], id, waitFor(function (err) { if (err) { return void console.error(err); } console.debug('Team '+id+' ready'); })); }); team.getTeam = function (id) { return ctx.teams[id]; }; team.getTeamsData = function (app) { var t = {}; var safe = false; if (['drive', 'teams', 'settings'].indexOf(app) !== -1) { safe = true; } Object.keys(teams).forEach(function (id) { t[id] = { owner: teams[id].owner, name: teams[id].metadata.name, edPublic: Util.find(teams[id], ['keys', 'drive', 'edPublic']), avatar: Util.find(teams[id], ['metadata', 'avatar']), viewer: !Util.find(teams[id], ['keys', 'drive', 'edPrivate']), }; if (safe && ctx.teams[id]) { t[id].secondaryKey = ctx.teams[id].secondaryKey; } if (ctx.teams[id]) { t[id].hasSecondaryKey = Boolean(ctx.teams[id].secondaryKey); } }); return t; }; team.getTeams = function () { return Object.keys(ctx.teams); }; team.removeFromTeam = function (teamId, curve) { if (!teams[teamId]) { return; } if (ctx.onReadyHandlers[teamId]) { ctx.onReadyHandlers[teamId].push({cb : function () { ctx.teams[teamId].roster.remove([curve], function (err) { if (err && err !== 'NO_CHANGE') { console.error(err); } }); }}); return; } var team = ctx.teams[teamId]; if (!team) { return void console.error("TEAM MODULE ERROR"); } team.roster.remove([curve], function (err) { if (err && err !== 'NO_CHANGE') { console.error(err); } }); }; team.changeMyRights = function (id, edit, teamData) { changeMyRights(ctx, id, edit, teamData); }; team.updateMyData = function (data) { Object.keys(ctx.teams).forEach(function (id) { var team = ctx.teams[id]; if (!team.roster) { return; } var obj = {}; obj[data.curvePublic] = data; team.roster.describe(obj, function (err) { if (err) { console.error(err); } }); }); }; team.removeClient = function (clientId) { removeClient(ctx, clientId); }; team.execCommand = function (clientId, obj, cb) { var cmd = obj.cmd; var data = obj.data; if (cmd === 'SUBSCRIBE') { // Only the team app will subscribe to events? return void subscribe(ctx, data, clientId, cb); } if (cmd === 'LIST_TEAMS') { return void cb(store.proxy.teams); } if (cmd === 'OPEN_TEAM_CHAT') { return void openTeamChat(ctx, data, clientId, cb); } if (cmd === 'GET_TEAM_ROSTER') { return void getTeamRoster(ctx, data, clientId, cb); } if (cmd === 'GET_TEAM_METADATA') { return void getTeamMetadata(ctx, data, clientId, cb); } if (cmd === 'SET_TEAM_METADATA') { return void setTeamMetadata(ctx, data, clientId, cb); } if (cmd === 'OFFER_OWNERSHIP') { return void offerOwnership(ctx, data, clientId, cb); } if (cmd === 'ANSWER_OWNERSHIP') { return void answerOwnership(ctx, data, clientId, cb); } if (cmd === 'DESCRIBE_USER') { return void describeUser(ctx, data, clientId, cb); } if (cmd === 'INVITE_TO_TEAM') { return void inviteToTeam(ctx, data, clientId, cb); } if (cmd === 'LEAVE_TEAM') { return void leaveTeam(ctx, data, clientId, cb); } if (cmd === 'JOIN_TEAM') { return void joinTeam(ctx, data, clientId, cb); } if (cmd === 'REMOVE_USER') { return void removeUser(ctx, data, clientId, cb); } if (cmd === 'DELETE_TEAM') { return void deleteTeam(ctx, data, clientId, cb); } if (cmd === 'CREATE_TEAM') { return void createTeam(ctx, data, clientId, cb); } if (cmd === 'GET_EDITABLE_FOLDERS') { return void getEditableFolders(ctx, data, clientId, cb); } }; return team; }; return Team; });