You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cryptpad/www/common/outer/team.js

2157 lines
84 KiB
JavaScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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',
'/common/outer/invitation.js',
'/common/cryptget.js',
'/common/outer/cache-store.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, Invite, Crypt, Cache,
Listmap, Crypto, CpNetflux, ChainPad, nThen, Saferphore) {
var Team = {};
var Nacl = window.nacl;
var onStoreReady = Util.mkEvent(true);
var openCachedTeamChat = function () {}; // Placeholder
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('disconnect', function () {
team.offline = true;
team.sendEvent('NETWORK_DISCONNECT', team.id);
});
proxy.on('reconnect', function () {
team.offline = false;
team.sendEvent('NETWORK_RECONNECT', team.id);
});
}
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); }
});
}
}
}
if (o && !n && Array.isArray(p) && (p[0] === UserObject.FILES_DATA ||
(p[0] === 'drive' && p[1] === UserObject.FILES_DATA))) {
setTimeout(function () {
ctx.Store.checkDeletedPad(o && o.channel);
});
}
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
});
});
};
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.cache[teamId];
delete ctx.store.proxy.teams[teamId];
ctx.emit('LEAVE_TEAM', teamId, team.clients);
ctx.updateMetadata();
if (ctx.store.mailbox) {
ctx.store.mailbox.close('team-'+teamId, function () {
// Close team mailbox
});
}
};
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); }
var state = store.roster.getState();
if (state.members) {
Object.keys(state.members).forEach(function (curve) {
var m = state.members[curve];
if (m.inviteChannel && m.pending) { list.push(m.inviteChannel); }
if (m.previewChannel && m.pending) { list.push(m.previewChannel); }
});
}
list.sort();
return list;
};
var handleSharedFolder = function (ctx, id, sfId, rt) {
var t = ctx.teams[id];
if (!t) { return; }
if (!rt) {
delete t.sharedFolders[sfId];
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;
team.onRpcReadyEvt.fire();
cb();
}, Cache);
});
};
var onCacheReady = function (ctx, id, lm, roster, keys, cId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (ctx.cache[id]) { return void 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,
onRpcReadyEvt: Util.mkEvent(true),
offline: true
};
ctx.cache[id] = team;
// 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 || !Object.keys(state.members).length) {
// invalid roster, don't leave the team
console.error(JSON.stringify(state));
return;
}
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;
});
// 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
};
};
team.pin = function (data, cb) {
if (!keys.drive.edPrivate) { return void cb({error: 'EFORBIDDEN'}); }
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 (!keys.drive.edPrivate) { return void cb({error: 'EFORBIDDEN'}); }
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});
});
};
// Create the proxy manager
var loadSharedFolder = function (id, data, cb, isNew) {
SF.load({
isNew: isNew,
network: ctx.store.network || ctx.store.networkPromise,
store: team,
isNewChannel: ctx.Store.isNewChannel,
Store: ctx.Store
}, id, data, cb);
};
var teamData = ctx.store.proxy.teams[team.id];
var hash = teamData.hash || teamData.roHash;
var 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'])
},
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);
},
Store: ctx.Store
}, {
outer: true,
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;
nThen(function (waitFor) {
// Load the shared folders
ctx.teams[id] = team;
registerChangeEvents(ctx, team, proxy);
var network = ctx.store.network || ctx.store.networkPromise;
SF.loadSharedFolders(ctx.Store, network, team,
team.userObject, waitFor, function (data) {
ctx.progress += 70/(ctx.numberOfTeams * data.max);
ctx.updateProgress({
progress: ctx.progress
});
}, true);
}).nThen(function () {
cb();
});
};
var onReady = function (ctx, id, lm, roster, keys, cId, cb) {
// Update metadata
var state = roster.getState();
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', id]);
if (teamData) { teamData.metadata = state.metadata; }
var team;
if (!ctx.store.proxy.teams[id]) { return; }
nThen(function (waitFor) {
onCacheReady(ctx, id, lm, roster, keys, cId, waitFor());
team = ctx.teams[id] || ctx.cache[id];
// Init Team RPC
if (!keys.drive.edPrivate) { return; }
initRpc(ctx, team, keys.drive, waitFor(function () {}));
}).nThen(function (waitFor) {
// Load the shared folders
team.userObject.fixFiles();
SF.checkMigration(team.secondaryKey, team.proxy, team.userObject, waitFor());
SF.loadSharedFolders(ctx.Store, ctx.store.network, team,
team.userObject, waitFor, function (data) {
ctx.progress += 70/(ctx.numberOfTeams * data.max);
ctx.updateProgress({
progress: ctx.progress
});
});
}).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 () {
team.offline = false;
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 checkTeamChannels = function (ctx, id, channel, roster, waitFor, cb) {
var close = function () {
if (ctx.cache[id] || ctx.teams[id]) { closeTeam(ctx, id); }
delete ctx.store.proxy.teams[id];
delete ctx.onReadyHandlers[id];
waitFor.abort();
cb({error: 'ENOENT'});
};
if (channel) {
ctx.store.anon_rpc.send("IS_NEW_CHANNEL", channel, waitFor(function (e, res) {
if (res && res.length && typeof(res[0]) === 'boolean' && res[0]) {
// Channel is empty: remove this team
close();
}
}));
}
if (roster) {
ctx.store.anon_rpc.send("IS_NEW_CHANNEL", roster, waitFor(function (e, res) {
if (res && res.length && typeof(res[0]) === 'boolean' && res[0]) {
// Channel is empty: remove this team
close();
}
}));
}
};
// Progress:
// One team = (30/(#teams))%
// One shared folder = (70/(#teams * #folders))%
var openChannel = function (ctx, teamData, id, _cb, cache) {
var cb = Util.once(Util.mkAsync(_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) {
if (cache) {
// If we're in cache mode, make sure we have a cache for this team
Cache.getChannelCache(secret.channel, waitFor(function (err, obj) {
var c = obj && obj.c; // content
if (!c) {
waitFor.abort();
cb({error: 'NOCACHE_DRIVE'});
}
}));
Cache.getChannelCache(rosterKeys.channel, waitFor(function (err, obj) {
var c = obj && obj.c; // content
var k = obj && obj.k;
if (k && !rosterKeys.teamEdPublic) {
rosterKeys.teamEdPublic = k;
}
if (!c) {
waitFor.abort();
cb({error: 'NOCACHE_ROSTER'});
}
}));
return;
}
checkTeamChannels(ctx, id, secret.channel, rosterKeys.channel, waitFor, cb);
}).nThen(function (waitFor) {
var cacheRdy = {
lm: false,
roster: false,
check: function () {
if (!this.lm || !this.roster) { return; }
if (!cache) { return; }
// Both are cacheready!
ctx.progress += 30/ctx.numberOfTeams;
ctx.updateProgress({
progress: ctx.progress
});
onCacheReady(ctx, id, lm, roster, keys, null, waitFor(cb));
this.check = function () {};
}
};
// Load the proxy
var cfg = {
data: {},
readOnly: !Boolean(secret.keys.signKey),
network: ctx.store.network || ctx.store.networkPromise,
channel: secret.channel,
crypto: crypto,
ChainPad: ChainPad,
Cache: Cache,
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('cacheready', function () {
cacheRdy.lm = true;
cacheRdy.check();
});
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 || ctx.store.networkPromise,
channel: rosterKeys.channel,
keys: rosterKeys,
store: ctx.store,
lastKnownHash: rosterData.lastKnownHash,
onCacheReady: function (_roster) {
if (!cache) { return; }
console.error('Corrupted roster cache, cant load this team offline', teamData);
if (_roster && _roster.error === "CORRUPTED") {
if (lm && typeof(lm.stop) === "function") { lm.stop(); }
waitFor.abort();
cb({error: 'CACHE_CORRUPTED_ROSTER'});
return;
}
roster = _roster;
cacheRdy.roster = true;
cacheRdy.check();
},
Cache: Cache
}, 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; }
onStoreReady.reg(function () {
// 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']);
// FIXME roster history temporarily corrupted, don't leave the team
if (!state.members || !Object.keys(state.members).length) {
lm.stop();
roster.stop();
lm.proxy = {};
cb({error: 'EINVAL'});
waitFor.abort();
console.error(JSON.stringify(state));
Feedback.send("ROSTER_CORRUPTED");
return;
}
// Kicked from the team
if (!state.members[me]) {
lm.stop();
roster.stop();
lm.proxy = {};
delete ctx.store.proxy.teams[id];
ctx.updateMetadata();
cb({error: 'EFORBIDDEN'});
waitFor.abort();
return;
}
// Check access rights
// If we're not a viewer, make sure we have edit rights
var s = state.members[me];
var teamEdPrivate = Util.find(teamData, ['keys', 'drive', 'edPrivate']);
if ((!teamData.hash || !teamEdPrivate) && ['ADMIN', 'MEMBER'].indexOf(s.role) !== -1) {
console.warn("Missing edit rights: demote to viewer");
var data = {};
data[ctx.store.proxy.curvePublic] = {
role: "VIEWER"
};
roster.describe(data, function (err) {
Feedback.send("TEAM_RIGHTS_FIXED");
// Make sure we've removed all the keys
delete teamData.hash;
delete teamData.keys.drive.edPrivate;
delete teamData.keys.chat.edit;
if (!err) { return; }
if (err === 'NO_CHANGE') { return; }
console.error(err);
});
} else if ((!teamData.hash || !teamEdPrivate) && s.role === "OWNER") {
Feedback.send("TEAM_RIGHTS_OWNER");
}
}).nThen(function () {
if (!cache) {
ctx.progress += 30/ctx.numberOfTeams;
ctx.updateProgress({
progress: ctx.progress
});
}
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 curvePair = Nacl.box.keyPair();
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,
Cache: Cache,
owners: [ctx.store.proxy.edPublic]
};
nThen(function (waitFor) {
// Initialize the roster
Roster.create({
network: ctx.store.network || ctx.store.networkPromise,
channel: rosterKeys.channel, //sharedConfig.rosterChannel,
owners: [ctx.store.proxy.edPublic],
keys: rosterKeys,
store: ctx.store,
lastKnownHash: void 0,
Cache: Cache
}, 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 = {
mailbox: {
channel: Hash.createChannelId(),
viewed: [],
keys: {
curvePrivate: Nacl.util.encodeBase64(curvePair.secretKey),
curvePublic: Nacl.util.encodeBase64(curvePair.publicKey)
}
},
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,
}
};
var t = 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.store.mailbox.open('team-'+id, t.keys.mailbox, function () {
// Team mailbox loaded
}, true, {
owners: t.keys.drive.edPublic
});
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) {
// Don't check server metadata for blobs
if (c.length !== 32) { return; }
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 mailbox
var mailboxChan = Util.find(teamData, ['keys', 'mailbox', 'channel']);
team.rpc.removeOwnedChannel(mailboxChan, 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); }
var t = ctx.store.proxy.teams[id];
ctx.store.mailbox.open('team-'+id, t.keys.mailbox, function () {
// Team mailbox loaded
}, true, {
owners: t.keys.drive.edPublic
});
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)
if (ctx.store.messenger) {
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() || {};
var md = state.metadata || {};
md.offline = team.offline;
cb(md);
};
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.offline) { return void cb({error: 'OFFLINE'}); }
if (!team.roster) { return void cb({error: 'NO_ROSTER'}); }
if (data.metadata) { delete data.metadata.offline; }
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
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
}, {
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
ctx.store.mailbox.sendTo("RM_OWNER", {
teamChannel: teamData.channel,
title: teamData.metadata.name,
pending: isPendingOwner
}, {
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];
if (!team) { return true; }
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);
}
if (!secret.keys.secondaryKey && team.rpc) {
team.rpc.destroy();
}
// 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, cb) {
if (!teamId) { return void cb(false); }
var teamData = Util.find(ctx, ['store', 'proxy', 'teams', teamId]);
if (!teamData) { return void cb(false); }
var onReady = ctx.onReadyHandlers[teamId];
var team = ctx.teams[teamId];
if (teamData.channel !== data.channel || teamData.password !== data.password) { return void cb(false); }
// Update our proxy
if (state) {
teamData.hash = data.hash;
teamData.keys.drive.edPrivate = data.keys.drive.edPrivate;
teamData.keys.chat.edit = data.keys.chat.edit;
} else {
delete teamData.hash;
delete teamData.keys.drive.edPrivate;
delete teamData.keys.chat.edit;
}
// Team not ready yet: try again onReady
if (!team && Array.isArray(onReady)) {
onReady.push({
cb: function () {
changeMyRights(ctx, teamId, state, data, cb);
}
});
return;
}
// No team and not initialized at all...
if (!team) { return void cb(false); }
// Team is initialized and ready: update the loaded elements
if (state) {
initRpc(ctx, team, teamData.keys.drive, function () {
team.manager.addPin(team.pin, team.unpin);
});
var secret = Hash.getSecrets('team', data.hash, teamData.password);
team.secondaryKey = secret && secret.keys.secondaryKey;
var crypto = Crypto.createEncryptor(secret.keys);
team.listmap.setReadOnly(false, crypto);
} else {
delete team.secondaryKey;
if (team.rpc && team.rpc.destroy) {
team.rpc.destroy();
}
team.manager.removePin();
team.listmap.setReadOnly(true);
}
updateMyRights(ctx, teamId, data.hash);
cb(true);
};
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
ctx.store.mailbox.sendTo("TEAM_EDIT_RIGHTS", {
state: state,
teamData: getInviteData(ctx, teamId, state)
}, {
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', {
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,
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) {}
openCachedTeamChat = function () {};
if (!id) { return void cb(); }
// If the team is loading, add ourselves to the list
if (ctx.onReadyHandlers[id] && !ctx.teams[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);
};
if (ctx.store.messenger) {
ctx.store.messenger.openTeamChat(team.getChatData(), onUpdate, cId, cb);
} else {
openCachedTeamChat = function () {
ctx.store.messenger.openTeamChat(team.getChatData(), onUpdate, cId, cb);
};
}
};
var createInviteLink = function (ctx, data, cId, _cb) {
var cb = Util.mkAsync(Util.once(_cb));
var teamId = data.teamId;
var team = ctx.teams[data.teamId];
var seeds = data.seeds; // {scrypt, preview}
var bytes64 = data.bytes64;
if (!teamId || !team) { return void cb({error: 'EINVAL'}); }
var roster = team.roster;
var teamName;
try {
teamName = roster.getState().metadata.name;
} catch (err) {
return void cb({ error: "TEAM_NAME_ERR" });
}
var message = data.message;
var name = data.name;
/*
var password = data.password;
var hash = data.hash;
*/
// derive { channel, cryptKey} for the preview content channel
var previewKeys = Invite.derivePreviewKeys(seeds.preview);
// derive {channel, cryptkey} for the invite content channel
var inviteKeys = Invite.deriveInviteKeys(bytes64);
// randomly generate ephemeral keys for ownership of the above content
// and a placeholder in the roster
var ephemeralKeys = Invite.generateKeys();
nThen(function (w) {
(function () {
// a random signing keypair to prevent further writes to the channel
// we don't need to remember it cause we're only writing once
var sign = Invite.generateSignPair(); // { validateKey, signKey}
var putOpts = {
initialState: '{}',
network: ctx.store.network,
metadata: {
owners: [ctx.store.proxy.edPublic, ephemeralKeys.edPublic]
}
};
putOpts.metadata.validateKey = sign.validateKey;
// visible with only the invite link
var previewContent = {
teamName: teamName,
message: message,
author: Messaging.createData(ctx.store.proxy, false),
displayName: name,
};
var cryptput_config = {
channel: previewKeys.channel,
type: 'pad',
version: 2,
keys: { // what would normally be provided by getSecrets
cryptKey: previewKeys.cryptKey,
validateKey: sign.validateKey, // sent to historyKeeper
signKey: sign.signKey, // b64EdPrivate
},
};
Crypt.put(cryptput_config, JSON.stringify(previewContent), w(function (err /*, doc */) {
if (err) {
console.error("CRYPTPUT_ERR", err);
w.abort();
return void cb({ error: "SET_PREVIEW_CONTENT" });
}
}), putOpts);
}());
(function () {
// a different random signing key so that the server can't correlate these documents
// as components of an invite
var sign = Invite.generateSignPair(); // { validateKey, signKey}
var putOpts = {
initialState: '{}',
network: ctx.store.network,
metadata: {
owners: [ctx.store.proxy.edPublic, ephemeralKeys.edPublic]
}
};
putOpts.metadata.validateKey = sign.validateKey;
// available only with the link and the content
var inviteContent = {
teamData: getInviteData(ctx, teamId, false),
ephemeral: {
edPublic: ephemeralKeys.edPublic,
edPrivate: ephemeralKeys.edPrivate,
curvePublic: ephemeralKeys.curvePublic,
curvePrivate: ephemeralKeys.curvePrivate,
},
};
var cryptput_config = {
channel: inviteKeys.channel,
type: 'pad',
version: 2,
keys: {
cryptKey: inviteKeys.cryptKey,
validateKey: sign.validateKey,
signKey: sign.signKey,
},
};
Crypt.put(cryptput_config, JSON.stringify(inviteContent), w(function (err /*, doc */) {
if (err) {
console.error("CRYPTPUT_ERR", err);
w.abort();
return void cb({ error: "SET_PREVIEW_CONTENT" });
}
}), putOpts);
}());
}).nThen(function (w) {
team.pin([inviteKeys.channel, previewKeys.channel], function (obj) {
if (obj && obj.error) { console.error(obj.error); }
});
Invite.createRosterEntry(team.roster, {
curvePublic: ephemeralKeys.curvePublic,
content: {
curvePublic: ephemeralKeys.curvePublic,
displayName: data.name,
pending: true,
inviteChannel: inviteKeys.channel,
previewChannel: previewKeys.channel,
}
}, w(function (err) {
if (err) {
w.abort();
cb(err);
}
}));
}).nThen(function () {
// call back empty if everything worked
cb();
});
};
var getPreviewContent = function (ctx, data, cId, cb) {
var seeds = data.seeds;
var previewKeys;
try {
previewKeys = Invite.derivePreviewKeys(seeds.preview);
} catch (err) {
return void cb({ error: "INVALID_SEEDS" });
}
Crypt.get({ // secrets
channel: previewKeys.channel,
type: 'pad',
version: 2,
keys: {
cryptKey: previewKeys.cryptKey,
},
}, function (err, val) {
if (err) { return void cb({ error: err }); }
if (!val) { return void cb({ error: 'DELETED' }); }
var json = Util.tryParse(val);
if (!json) { return void cb({ error: "parseError" }); }
cb(json);
}, { // cryptget opts
network: ctx.store.network,
initialState: '{}',
});
};
var getInviteContent = function (ctx, data, cId, cb) {
var bytes64 = data.bytes64;
var previewKeys;
try {
previewKeys = Invite.deriveInviteKeys(bytes64);
} catch (err) {
return void cb({ error: "INVALID_SEEDS" });
}
Crypt.get({ // secrets
channel: previewKeys.channel,
type: 'pad',
version: 2,
keys: {
cryptKey: previewKeys.cryptKey,
},
}, function (err, val) {
if (err) { return void cb({error: err}); }
if (!val) { return void cb({error: 'DELETED'}); }
var json = Util.tryParse(val);
if (!json) { return void cb({error: "parseError"}); }
cb(json);
}, { // cryptget opts
network: ctx.store.network,
initialState: '{}',
});
};
var acceptLinkInvitation = function (ctx, data, cId, cb) {
var inviteContent;
var rosterState;
nThen(function (waitFor) {
// Get team keys and ephemeral keys
getInviteContent(ctx, data, cId, waitFor(function (obj) {
if (obj && obj.error) {
waitFor.abort();
return void cb(obj);
}
inviteContent = obj;
}));
}).nThen(function (waitFor) {
// Check if you're already a member of this team
var chan = Util.find(inviteContent, ['teamData', 'channel']);
var myTeams = ctx.store.proxy.teams || {};
var isMember = Object.keys(myTeams).some(function (k) {
var t = myTeams[k];
return t.channel === chan;
});
if (isMember) {
waitFor.abort();
return void cb({error: 'ALREADY_MEMBER'});
}
// Accept the roster invitation: relplace our ephemeral keys with our user keys
var rosterData = Util.find(inviteContent, ['teamData', 'keys', 'roster']);
var myKeys = inviteContent.ephemeral;
if (!rosterData || !myKeys) {
waitFor.abort();
return void cb({error: 'INVALID_INVITE_CONTENT'});
}
var rosterKeys = Crypto.Team.deriveMemberKeys(rosterData.edit, myKeys);
Roster.create({
network: ctx.store.network || ctx.store.networkPromise,
channel: rosterData.channel,
keys: rosterKeys,
store: ctx.store,
Cache: Cache
}, waitFor(function (err, roster) {
if (err) {
waitFor.abort();
console.error(err);
return void cb({error: 'ROSTER_ERROR'});
}
var myData = Messaging.createData(ctx.store.proxy, false);
var state = roster.getState();
rosterState = state.members[myKeys.curvePublic];
roster.accept(myData.curvePublic, waitFor(function (err) {
roster.stop();
if (err) {
waitFor.abort();
console.error(err);
return void cb({error: 'ACCEPT_ERROR'});
}
}));
}));
}).nThen(function () {
var tempRpc = {};
initRpc(ctx, tempRpc, inviteContent.ephemeral, function (err) {
if (err) { return; }
var rpc = tempRpc.rpc;
if (rosterState.inviteChannel) {
rpc.removeOwnedChannel(rosterState.inviteChannel, function (err) {
if (err) { console.error(err); }
});
}
if (rosterState.previewChannel) {
rpc.removeOwnedChannel(rosterState.previewChannel, function (err) {
if (err) { console.error(err); }
});
}
});
// Add the team to our list and join...
joinTeam(ctx, {
team: inviteContent.teamData
}, cId, cb);
});
};
var deriveMailbox = function (team) {
if (!team) { return; }
if (team.keys && team.keys.mailbox) { return team.keys.mailbox; }
var strSeed = Util.find(team, ['keys', 'roster', 'edit']);
if (!strSeed) { return; }
var hash = Nacl.hash(Nacl.util.decodeUTF8(strSeed));
var seed = hash.slice(0,32);
var mailboxChannel = Util.uint8ArrayToHex(hash.slice(32,48));
var curvePair = Nacl.box.keyPair.fromSecretKey(seed);
return {
channel: mailboxChannel,
viewed: [],
keys: {
curvePrivate: Nacl.util.encodeBase64(curvePair.secretKey),
curvePublic: Nacl.util.encodeBase64(curvePair.publicKey)
}
};
};
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: {},
cache: {},
updateMetadata: cfg.updateMetadata,
updateProgress: cfg.updateLoadingProgress,
progress: 0
};
var teams = store.proxy.teams = store.proxy.teams || {};
ctx.numberOfTeams = Object.keys(teams).length;
// 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]);
});
var checkKeyPair = function (edPrivate, edPublic) {
if (!edPrivate || !edPublic) { return true; }
try {
var secretKey = Nacl.util.decodeBase64(edPrivate);
var pair = Nacl.sign.keyPair.fromSecretKey(secretKey);
return Nacl.util.encodeBase64(pair.publicKey) === edPublic;
} catch (e) {
return false;
}
};
// Remove duplicate teams
var removeDuplicates = function () {
var _teams = {};
Object.keys(teams).forEach(function (id) {
try {
var t = teams[id];
var _t = _teams[t.channel];
var edPrivate = Util.find(t, ['keys', 'drive', 'edPrivate']);
var edPublic = Util.find(t, ['keys', 'drive', 'edPublic']);
// If the edPrivate is corrupted, remove it
if (!edPublic) {
Feedback.send("TEAM_CORRUPTED_EDPUBLIC");
} else if (edPrivate && edPublic && !checkKeyPair(edPrivate, edPublic)) {
Feedback.send("TEAM_CORRUPTED_EDPRIVATE");
delete teams[id].keys.drive.edPrivate;
edPrivate = undefined;
}
// If the hash is corrupted, feedback
if (t.hash) {
var parsed = Hash.parseTypeHash('drive', t.hash);
if (parsed.version === 2 && t.hash.length !== 40) {
Feedback.send("TEAM_CORRUPTED_HASH");
// FIXME ?
}
}
// Not found yet? add to the list
if (!_t) {
_teams[t.channel] = id;
return;
}
// Duplicate found: update our team to add missing data
var best = teams[_t]; // This is a proxy!
var bestPrivate = Util.find(best, ['keys', 'drive', 'edPrivate']);
var bestChat = Util.find(best, ['keys', 'chat', 'edit']);
var chat = Util.find(t, ['keys', 'chat', 'edit']);
if (!best.hash && t.hash) {
best.hash = t.hash;
}
if (!bestPrivate && edPrivate) {
best.keys.drive.edPrivate = edPrivate;
}
if (!bestChat && chat) {
best.keys.chat.edit = chat;
}
// Deprecate the duplicate
ctx.store.proxy.duplicateTeams = ctx.store.proxy.duplicateTeams || {};
ctx.store.proxy.duplicateTeams[id] = teams[id];
delete teams[id];
} catch (e) { console.error(e); }
});
};
// Load teams
Object.keys(teams).forEach(function (id) {
ctx.onReadyHandlers[id] = [];
if (!Util.find(teams, [id, 'keys', 'mailbox'])) {
teams[id].keys.mailbox = deriveMailbox(teams[id]);
}
openChannel(ctx, teams[id], id, waitFor(function (err) {
if (err) {
delete ctx.onReadyHandlers[id];
delete ctx.cache[id];
var txt = typeof(err) === "string" ? err : (err.type || err.message);
Feedback.send("TEAM_LOADING_ERROR="+txt);
return void console.error(err);
}
console.debug('Team '+id+' cache ready');
}), true);
});
// Proxy is ready, check if our team list has changed
team.onReady = function (waitFor) {
removeDuplicates();
// Close all the teams from our cache that have been removed and add waitFor to the
// one that still exist
var checkTeam = function (id) {
if (!teams[id]) {
closeTeam(ctx, id);
delete ctx.onReadyHandlers[id];
return true;
}
return false;
};
Object.keys(ctx.teams).forEach(checkTeam);
Object.keys(ctx.onReadyHandlers).forEach(function (id) {
var closed = checkTeam(id);
if (closed) { return; }
var team = ctx.store.proxy.teams[id];
var rosterChan = Util.find(team, ['keys', 'roster', 'channel']);
var _cb = Util.once(Util.mkAsync(waitFor()));
nThen(function (w) {
checkTeamChannels(ctx, id, team.channel, rosterChan, w, _cb);
});
ctx.onReadyHandlers[id].push({
cb: _cb
});
});
// Load all the teams that weren't in our cache
Object.keys(teams).forEach(function (id) {
// Team already loaded? abort
if (ctx.onReadyHandlers[id] || ctx.teams[id]) { return; }
// Load team
ctx.onReadyHandlers[id] = [];
if (!Util.find(teams, [id, 'keys', 'mailbox'])) {
teams[id].keys.mailbox = deriveMailbox(teams[id]);
}
openChannel(ctx, teams[id], id, waitFor(function (err) {
if (err) {
var txt = typeof(err) === "string" ? err : (err.type || err.message);
Feedback.send("TEAM_LOADING_ERROR="+txt);
return void console.error(err);
}
console.debug('Team '+id+' ready');
}));
});
openCachedTeamChat();
onStoreReady.fire();
};
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) {
if (!ctx.teams[id]) { return; }
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']),
notifications: Util.find(teams[id], ['keys', 'mailbox', 'channel']),
curvePublic: Util.find(teams[id], ['keys', 'mailbox', 'keys', 'curvePublic']),
validKeys: checkKeyPair(Util.find(teams[id], ['keys', 'drive', 'edPrivate']), Util.find(teams[id], ['keys', 'drive', 'edPublic']))
};
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);
};
var isPending = function (teamId, curve) {
var team = ctx.teams[teamId];
if (!team) { return; }
var state = team.roster && team.roster.getState();
if (!state.members) { return; }
var m = state.members[curve] || {};
return m.pending;
};
team.removeFromTeam = function (teamId, curve, pendingOnly) {
if (!teams[teamId]) { return; }
// When receiving a negative answer to a team invitation, remove
// the pending user from the roster.
if (pendingOnly && !isPending(teamId, curve)) { 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, cb) {
changeMyRights(ctx, id, edit, teamData, cb);
};
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);
};
var listTeams = function (cb) {
var t = Util.clone(teams);
Object.keys(t).forEach(function (id) {
// If failure to load the team, don't send it
if (ctx.teams[id]) {
t[id].offline = ctx.teams[id].offline;
return;
}
t[id].error = true;
});
cb(t);
};
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 listTeams(cb);
}
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') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void offerOwnership(ctx, data, clientId, cb);
}
if (cmd === 'ANSWER_OWNERSHIP') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void answerOwnership(ctx, data, clientId, cb);
}
if (cmd === 'DESCRIBE_USER') {
return void describeUser(ctx, data, clientId, cb);
}
if (cmd === 'INVITE_TO_TEAM') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void inviteToTeam(ctx, data, clientId, cb);
}
if (cmd === 'LEAVE_TEAM') {
return void leaveTeam(ctx, data, clientId, cb);
}
if (cmd === 'JOIN_TEAM') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void joinTeam(ctx, data, clientId, cb);
}
if (cmd === 'REMOVE_USER') {
return void removeUser(ctx, data, clientId, cb);
}
if (cmd === 'DELETE_TEAM') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void deleteTeam(ctx, data, clientId, cb);
}
if (cmd === 'CREATE_TEAM') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void createTeam(ctx, data, clientId, cb);
}
if (cmd === 'GET_EDITABLE_FOLDERS') {
return void getEditableFolders(ctx, data, clientId, cb);
}
if (cmd === 'CREATE_INVITE_LINK') {
return void createInviteLink(ctx, data, clientId, cb);
}
if (cmd === 'GET_PREVIEW_CONTENT') {
return void getPreviewContent(ctx, data, clientId, cb);
}
if (cmd === 'ACCEPT_LINK_INVITATION') {
if (ctx.store.offline) { return void cb({ error: 'OFFLINE' }); }
return void acceptLinkInvitation(ctx, data, clientId, cb);
}
};
return team;
};
Team.anonGetPreviewContent = function (cfg, data, cb) {
getPreviewContent(cfg, data, null, cb);
};
return Team;
});