blob: 4b7ba5c520f3f76ee9792ca203014ce8cd87af92 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
module.exports = Channel;
var _ = require('lodash');
var EventEmitter = require('events').EventEmitter;
var inherits = require('inherits');
var path = require('path');
var url = require('url');
var access = require('vanadium/src/security/access');
var naming = require('vanadium').naming;
var noop = require('./noop');
var ServiceVdl = require('./v.io/x/chat/vdl');
var util = require('./util');
// Default channel name. Override by setting "channel" query param in url.
var DEFAULT_CHANNNEL = 'users/vanadium.bot@gmail.com/apps/chat/public';
// Member is a member of the channel.
function Member(blessings, path) {
// The member's blessings.
this.blessings = blessings;
// The name of the member.
this.name = util.firstShortName(blessings);
// The path at which the member is mounted in the mounttable.
this.path = path;
}
// memberNames takes an array of members and returns a sorted list of this
// names.
function memberNames(members) {
return _.map(members, function(member) {
return member.name;
}).sort();
}
// Channel encapsulates the logic for a client of the Vanadium Chat. It
// inherits from EventEmitter and emits 'members', 'message', and 'ready'
// events.
function Channel(rt) {
EventEmitter.call(this);
var u = url.parse(window.location.href, true);
this.channelName_ = u.query.channel || DEFAULT_CHANNNEL;
this.accountName_ = rt.accountName;
this.namespace_ = rt.getNamespace();
this.context_ = rt.getContext();
this.client_ = rt.getClient();
this.rt_ = rt;
this.server_ = null;
this.globbing_ = false;
this.ready_ = false;
this.members_ = [];
this.intervalID_ = null;
}
inherits(Channel, EventEmitter);
// join creates a Vanadium server and mounts it in the mounttable under a
// random "locked" name.
Channel.prototype.join = function(cb) {
cb = cb || noop;
var that = this;
// Create our service implementation, which defines a single method:
// "SendMessage". We inherit from ServiceVdl.Chat which causes our defined
// VDL types to be used when serializing values on the wire. This is
// necessary for the web client to be able to communicate with the shell
// client.
var Service = function() {
ServiceVdl.Chat.call(this);
};
inherits(Service, ServiceVdl.Chat);
// The implementation of sendMessage emits the message with the sender's
// name and timestamp.
Service.prototype.sendMessage = function(ctx, serverCall, text) {
var secCall = serverCall.securityCall;
that.emit('message', {
sender: util.firstShortName(secCall.remoteBlessingStrings),
text: text,
timestamp: new Date()
});
};
// allowEveryoneAuthorizer allows RPCs from all clients.
var options = {authorizer: access.allowEveryoneAuthorizer()};
// Get a locked name to mount under.
this.getLockedName_(function(err, name) {
if (err) {
return cb(err);
}
that.mountedName_ = name;
// Create a new chat server under the locked name.
// Note, newServer() performs the mount() for us.
that.rt_.newServer(name, new Service(), options, function(err, server) {
if (err) return cb(err);
that.server_ = server;
that.updateMembers_();
that.intervalID_ = setInterval(that.updateMembers_.bind(that), 2000);
return cb();
});
});
};
// getLockedName picks a random name and attempts to "lock" it by setting
// restrictive permissions. It tries repeatedly until it picks a name that is
// not locked by another client.
Channel.prototype.getLockedName_ = function(cb) {
// openACL gives everybody permission.
var openACL = new access.AccessList({
'in': ['...']
});
// myACL only gives my blessings and decendants permission.
var myACL = new access.AccessList({
'in': [this.accountName_]
});
// Create a tagged acl map with the desired permissions.
var tam = new access.Permissions(new Map([
// Give everybody the ability to read and resolve the name.
['Read', openACL],
['Resolve', openACL],
// All other permissions are only for us.
['Admin', myACL],
['Create', myACL],
['Mount', myACL]
]));
var that = this;
// Repeatedly pick random names and try to setPermissions on them until we get
// one that has not already been locked.
var maxRetries = 25;
function attemptToGetName(tries) {
if (tries >= maxRetries) {
return cb(new Error('Tried ' + maxRetries + ' to get an unlocked name ' +
'but did not succeed.'));
}
// Choose a random name under the channel name.
var name = path.join(that.channelName_, util.randomHex(32));
var ctx = that.context_.withTimeout(5000);
that.namespace_.setPermissions(ctx, name, tam, '', function(err) {
ctx.done();
if (err) {
// Try again.
return attemptToGetName(tries + 1);
}
return cb(null, name);
});
}
attemptToGetName(0);
};
// leave stops the chat server, and deletes our name from the mounttable.
Channel.prototype.leave = function(cb) {
cb = cb || noop;
// Stop updating member names.
if (this.intervalID_) {
clearInterval(this.intervalID_);
this.intervalID_ = null;
}
// Delete our name from the mounttable.
if (this.mountedName_) {
this.namespace_.delete(this.context_, this.mountedName_, true, noop);
}
// Stop the server.
if (this.server_) {
this.server_.stop(cb);
}
};
// sendMessageTo sends a message to a particular member.
Channel.prototype.sendMessageTo = function(member, messageText, cb) {
cb = cb || noop;
// The allowedServersPolicy options require that the server matches the
// blessings we got when we globbed it.
var callOpts = this.client_.callOption({
allowedServersPolicy: member.blessings
});
var ctx = this.context_.withTimeout(5000);
// Bind to the member's chat server.
this.client_.bindTo(ctx, member.path, function(err, s) {
if (err) {
ctx.done();
return cb(err);
}
// Invoke sendMessage on the member's chat server with messageText.
s.sendMessage(ctx, messageText, callOpts, function(err) {
if (err) {
return cb(err);
}
ctx.done();
return cb(null);
});
});
};
// broadcastMessage sends a message to all members in the channel.
Channel.prototype.broadcastMessage = function(messageText, cb) {
cb = cb || noop;
var that = this;
// Schedule all messages to be sent, then return immediately.
_.forEach(this.members_, function(member) {
process.nextTick(function() {
that.sendMessageTo(member, messageText);
});
});
return cb(null);
};
// updateMembers_ globs the mounttable and constructs member objects for each
// server in the glob results. Channel will emit 'members' event if the
// members have changed since the last glob.
Channel.prototype.updateMembers_ = function() {
var that = this;
// If we are already globbing, then do nothing.
if (this.globbing_) {
return;
}
this.globbing_ = true;
// We glob on the channel name to find all the members in our channel. The
// glob will return a stream of mount entries corresponding to the paths where
// channel members are mounted. Then, we get the remote blessings from each
// member, and use those as the member names.
var ctx = this.context_.withTimeout(5000);
var pattern = path.join(this.channelName_, '*');
// Start the glob rpc. This returns a promise with a "stream" property.
// Mount entries are emitted on the stream.
var globRpc = this.namespace_.glob(ctx, pattern);
var globStream = globRpc.stream;
globRpc.catch(function(err) {
console.error(err);
});
var newMembers = [];
// Each time we get a mount entry, we construct a new Member object.
globStream.on('data', function(mountEntry) {
if (!mountEntry.servers || mountEntry.servers.length === 0) {
// No servers mounted at that name, only a lonely ACL. Safe to ignore.
return;
}
// TODO(ashankar,nlacasse): Check with p@ and figure out if
// mountEntry.servers[0].server can have a "suffix" after the address.
// (e.g., @4@...@@/foo/bar).
// This won't be the case for the chat application, but can be in general?
var addr = mountEntry.servers[0].server;
var blessings = naming.blessingNamesFromAddress(addr);
var path = mountEntry.name;
newMembers.push(new Member(blessings, path));
});
globStream.on('end', done);
function done(err) {
that.globbing_ = false;
if (err) {
console.error(err);
}
if (newMembers.length === 0) {
// No glob results, not even us! Don't emit anything yet.
return;
}
// Get the member names for the old and new members and if they differ
// emit a "members" event which the UI will use to update the members
// list.
var oldMemberNames = memberNames(that.members_);
that.members_ = newMembers;
var newMemberNames = memberNames(that.members_);
if (!_.isEqual(oldMemberNames, newMemberNames)) {
that.emit('members', newMemberNames);
}
if (!that.ready_) {
that.ready_ = true;
that.emit('ready');
}
}
};