diff options
50 files changed, 1737 insertions, 229 deletions
diff --git a/app/controllers/connections.js b/app/controllers/connections.js index 1fcdb1d..17108de 100644 --- a/app/controllers/connections.js +++ b/app/controllers/connections.js @@ -44,7 +44,7 @@ module.exports = function() { query.user = req.param('user');
}
- var connections = core.presence.connections.query(query);
+ var connections = core.presence.system.connections.query(query);
res.json(connections);
}
});
diff --git a/app/controllers/files.js b/app/controllers/files.js index d87ce6e..20fdc23 100644 --- a/app/controllers/files.js +++ b/app/controllers/files.js @@ -109,6 +109,9 @@ module.exports = function() { },
list: function(req, res) {
var options = {
+ userId: req.user._id,
+ password: req.param('password'),
+
room: req.param('room'),
reverse: req.param('reverse'),
skip: req.param('skip'),
diff --git a/app/controllers/messages.js b/app/controllers/messages.js index 9f0b182..aff70d8 100644 --- a/app/controllers/messages.js +++ b/app/controllers/messages.js @@ -62,6 +62,9 @@ module.exports = function() { },
list: function(req, res) {
var options = {
+ userId: req.user._id,
+ password: req.param('password'),
+
room: req.param('room'),
since_id: req.param('since_id'),
from: req.param('from'),
diff --git a/app/controllers/rooms.js b/app/controllers/rooms.js index 749d4bb..36a04b2 100644 --- a/app/controllers/rooms.js +++ b/app/controllers/rooms.js @@ -4,7 +4,8 @@ 'use strict';
-var _ = require('lodash');
+var _ = require('lodash'),
+ settings = require('./../config').rooms;
module.exports = function() {
var app = this.app,
@@ -18,7 +19,12 @@ module.exports = function() { User.findById(data.userId, function (err, user) {
user = user.toJSON();
user.room = data.roomId;
- app.io.emit('users:join', user);
+
+ if (data.roomHasPassword) {
+ app.io.to(data.roomId).emit('users:join', user);
+ } else {
+ app.io.emit('users:join', user);
+ }
});
});
@@ -26,7 +32,12 @@ module.exports = function() { User.findById(data.userId, function (err, user) {
user = user.toJSON();
user.room = data.roomId;
- app.io.emit('users:leave', user);
+
+ if (data.roomHasPassword) {
+ app.io.to(data.roomId).emit('users:leave', user);
+ } else {
+ app.io.emit('users:leave', user);
+ }
});
});
@@ -80,6 +91,9 @@ module.exports = function() { app.io.route('rooms', {
list: function(req, res) {
var options = {
+ userId: req.user._id,
+ users: req.param('users'),
+
skip: req.param('skip'),
take: req.param('take')
};
@@ -90,26 +104,6 @@ module.exports = function() { return res.status(400).json(err);
}
- if (req.param('users')) {
- rooms = _.map(rooms, function(room) {
- room = room.toJSON();
- room.users = core.presence.getUsersForRoom(room.id.toString());
- room.userCount = room.users.length;
- return room;
- });
- }
- else if (req.param('userCounts')) {
- rooms = _.map(rooms, function(room) {
- room = room.toJSON();
- room.userCount =
- core.presence.getUserCountForRoom(room.id);
- return room;
- });
- }
-
- rooms = _.sortByAll(rooms, ['userCount', 'lastActive'])
- .reverse();
-
res.json(rooms);
});
},
@@ -131,11 +125,16 @@ module.exports = function() { },
create: function(req, res) {
var options = {
- owner: req.user._id,
- name: req.param('name'),
- slug: req.param('slug'),
- description: req.param('description')
- };
+ owner: req.user._id,
+ name: req.param('name'),
+ slug: req.param('slug'),
+ description: req.param('description'),
+ password: req.param('password')
+ };
+
+ if(!settings.passwordProtected) {
+ delete options.password;
+ }
core.rooms.create(options, function(err, room) {
if (err) {
@@ -152,7 +151,9 @@ module.exports = function() { var options = {
name: req.param('name'),
slug: req.param('slug'),
- description: req.param('description')
+ description: req.param('description'),
+ password: req.param('password'),
+ user: req.user
};
core.rooms.update(roomId, options, function(err, room) {
@@ -185,21 +186,40 @@ module.exports = function() { });
},
join: function(req, res) {
- var roomId = req.data;
- core.rooms.get(roomId, function(err, room) {
+ var options = {
+ userId: req.user._id,
+ saveMembership: true
+ };
+
+ if (typeof req.data === 'string') {
+ options.id = req.data;
+ } else {
+ options.id = req.param('roomId');
+ options.password = req.param('password');
+ }
+
+ core.rooms.canJoin(options, function(err, room, canJoin) {
if (err) {
console.error(err);
return res.sendStatus(400);
}
if (!room) {
- return res.sendStatus(400);
+ return res.sendStatus(404);
+ }
+
+ if(!canJoin) {
+ return res.status(403).json({
+ status: 'error',
+ message: 'password required',
+ errors: 'password required'
+ });
}
var user = req.user.toJSON();
user.room = room._id;
- core.presence.join(req.socket.conn, room._id, room.slug);
+ core.presence.join(req.socket.conn, room);
req.socket.join(room._id);
res.json(room.toJSON());
});
@@ -227,7 +247,7 @@ module.exports = function() { }
var users = core.presence.rooms
- .getOrAdd(room._id, room.slug)
+ .getOrAdd(room)
.getUsers()
.map(function(user) {
// TODO: Do we need to do this?
diff --git a/app/controllers/usermessages.js b/app/controllers/usermessages.js new file mode 100644 index 0000000..fee5311 --- /dev/null +++ b/app/controllers/usermessages.js @@ -0,0 +1,88 @@ +//
+// UserMessages Controller
+//
+
+'use strict';
+
+var _ = require('lodash'),
+ settings = require('./../config');
+
+module.exports = function() {
+
+ var app = this.app,
+ core = this.core,
+ middlewares = this.middlewares,
+ models = this.models,
+ Room = models.room;
+
+
+ if (!settings.private.enable) {
+ return;
+ }
+
+ core.on('user-messages:new', function(message, user, owner) {
+ _.each(message.users, function(userId) {
+ var connections = core.presence.system.connections.query({
+ type: 'socket.io', userId: userId.toString()
+ });
+
+ _.each(connections, function(connection) {
+ connection.socket.emit('user-messages:new', message);
+ });
+ });
+ });
+
+ //
+ // Routes
+ //
+
+ app.route('/users/:user/messages')
+ .all(middlewares.requireLogin)
+ .get(function(req, res) {
+ req.io.route('user-messages:list');
+ })
+ .post(function(req, res) {
+ req.io.route('user-messages:create');
+ });
+
+ //
+ // Sockets
+ //
+ app.io.route('user-messages', {
+ create: function(req, res) {
+ var options = {
+ owner: req.user._id,
+ user: req.param('user'),
+ text: req.param('text')
+ };
+
+ core.usermessages.create(options, function(err, message) {
+ if (err) {
+ return res.sendStatus(400);
+ }
+ res.status(201).json(message);
+ });
+ },
+ list: function(req, res) {
+ var options = {
+ currentUser: req.user._id,
+ user: req.param('user'),
+ since_id: req.param('since_id'),
+ from: req.param('from'),
+ to: req.param('to'),
+ reverse: req.param('reverse'),
+ skip: req.param('skip'),
+ take: req.param('take'),
+ expand: req.param('expand')
+ };
+
+ core.usermessages.list(options, function(err, messages) {
+ if (err) {
+ return res.sendStatus(400);
+ }
+ res.json(messages);
+ });
+ }
+ });
+
+};
diff --git a/app/controllers/users.js b/app/controllers/users.js index 903e3d6..a66c28c 100644 --- a/app/controllers/users.js +++ b/app/controllers/users.js @@ -36,24 +36,7 @@ module.exports = function() { take: req.param('take')
};
- options = helpers.sanitizeQuery(options, {
- defaults: {
- take: 500
- },
- maxTake: 5000
- });
-
- var find = User.find();
-
- if (options.skip) {
- find.skip(options.skip);
- }
-
- if (options.take) {
- find.limit(options.take);
- }
-
- find.exec(function(err, users) {
+ core.users.list(options, function(err, users) {
if (err) {
console.log(err);
return res.status(400).json(err);
diff --git a/app/core/account.js b/app/core/account.js index a5a8dea..539eb4e 100644 --- a/app/core/account.js +++ b/app/core/account.js @@ -41,7 +41,7 @@ AccountManager.prototype.update = function(id, options, cb) { } if (options.username && options.username !== user.username) { - var xmppConns = this.core.presence.connections.query({ + var xmppConns = this.core.presence.system.connections.query({ userId: user._id, type: 'xmpp' }); diff --git a/app/core/avatar-cache.js b/app/core/avatar-cache.js new file mode 100644 index 0000000..dac1de5 --- /dev/null +++ b/app/core/avatar-cache.js @@ -0,0 +1,50 @@ +'use strict'; + +var crypto = require('crypto'), + fs = require('fs'), + http = require('http'), + util = require('util'), + _ = require('lodash'); + +function AvatarCache(options) { + this.core = options.core; + this.avatars = {}; + + this.get = this.get.bind(this); + this.add = this.add.bind(this); +} + +AvatarCache.prototype.get = function(userId) { + return this.avatars[userId]; +}; + +AvatarCache.prototype.add = function(user) { + var userId = (user.id || user._id).toString(); + var url = 'http://www.gravatar.com/avatar/' + user.avatar + '?s=64'; + + var request = http.get(url, function(response) { + if (response.statusCode !== 200) { + return; + } + + var buffers = []; + + response.on('data', function(buffer) { + buffers.push(buffer); + }); + + response.on('end', function() { + var buffer = Buffer.concat(buffers); + var image = buffer.toString('base64'); + + this.avatars[userId] = { + base64: buffer.toString('base64'), + sha1: crypto.createHash('sha1').update(buffer).digest('hex') + }; + + this.core.emit('avatar-cache:update', user); + }.bind(this)); + }.bind(this)); +}; + +module.exports = AvatarCache; diff --git a/app/core/files.js b/app/core/files.js index f9ef61d..a271785 100644 --- a/app/core/files.js +++ b/app/core/files.js @@ -32,8 +32,8 @@ FileManager.prototype.create = function(options, cb) { } var File = mongoose.model('File'), - Room = mongoose.model('Room'), - User = mongoose.model('User'); + Room = mongoose.model('Room'), + User = mongoose.model('User'); if (settings.restrictTypes && settings.allowedTypes && @@ -93,6 +93,8 @@ FileManager.prototype.create = function(options, cb) { }; FileManager.prototype.list = function(options, cb) { + var Room = mongoose.model('Room'); + if (!enabled) { return cb(null, []); } @@ -144,14 +146,37 @@ FileManager.prototype.list = function(options, cb) { find.sort({ 'uploaded': 1 }); } - find - .limit(options.take) - .exec(function(err, files) { + Room.findById(options.room, function(err, room) { if (err) { console.error(err); return cb(err); } - cb(null, files); + + var opts = { + userId: options.userId, + password: options.password + }; + + room.canJoin(opts, function(err, canJoin) { + if (err) { + console.error(err); + return cb(err); + } + + if (!canJoin) { + return cb(null, []); + } + + find + .limit(options.take) + .exec(function(err, files) { + if (err) { + console.error(err); + return cb(err); + } + cb(null, files); + }); + }); }); }; diff --git a/app/core/index.js b/app/core/index.js index 77b04c1..83fd0ad 100644 --- a/app/core/index.js +++ b/app/core/index.js @@ -4,10 +4,13 @@ var EventEmitter = require('events').EventEmitter, util = require('util'), _ = require('lodash'), AccountManager = require('./account'), + AvatarCache = require('./avatar-cache'), FileManager = require('./files'), MessageManager = require('./messages'), PresenceManager = require('./presence'), - RoomManager = require('./rooms'); + RoomManager = require('./rooms'), + UserManager = require('./users'), + UserMessageManager = require('./usermessages'); function Core() { EventEmitter.call(this); @@ -32,6 +35,18 @@ function Core() { core: this }); + this.users = new UserManager({ + core: this + }); + + this.usermessages = new UserMessageManager({ + core: this + }); + + this.avatars = new AvatarCache({ + core: this + }); + this.onAccountUpdated = this.onAccountUpdated.bind(this); this.on('account:update', this.onAccountUpdated); diff --git a/app/core/messages.js b/app/core/messages.js index 8c2d893..481a612 100644 --- a/app/core/messages.js +++ b/app/core/messages.js @@ -46,6 +46,8 @@ MessageManager.prototype.create = function(options, cb) { }; MessageManager.prototype.list = function(options, cb) { + var Room = mongoose.model('Room'); + options = options || {}; if (!options.room) { @@ -105,14 +107,37 @@ MessageManager.prototype.list = function(options, cb) { find.sort({ 'posted': 1 }); } - find.limit(options.take) - .exec(function(err, messages) { + Room.findById(options.room, function(err, room) { + if (err) { + console.error(err); + return cb(err); + } + + var opts = { + userId: options.userId, + password: options.password + }; + + room.canJoin(opts, function(err, canJoin) { if (err) { console.error(err); return cb(err); } - cb(null, messages); + + if (!canJoin) { + return cb(null, []); + } + + find.limit(options.take) + .exec(function(err, messages) { + if (err) { + console.error(err); + return cb(err); + } + cb(null, messages); + }); }); + }); }; module.exports = MessageManager; diff --git a/app/core/presence.js b/app/core/presence.js index 08711e5..f6316fd 100644 --- a/app/core/presence.js +++ b/app/core/presence.js @@ -2,15 +2,17 @@ var _ = require('lodash'), Connection = require('./presence/connection'), + Room = require('./presence/room'), ConnectionCollection = require('./presence/connection-collection'), RoomCollection = require('./presence/room-collection'), UserCollection = require('./presence/user-collection'); function PresenceManager(options) { this.core = options.core; + this.system = new Room({ system: true }); this.connections = new ConnectionCollection(); this.rooms = new RoomCollection(); - this.users = new UserCollection(); + this.users = new UserCollection({ core: this.core }); this.rooms.on('user_join', this.onJoin.bind(this)); this.rooms.on('user_leave', this.onLeave.bind(this)); @@ -30,7 +32,8 @@ PresenceManager.prototype.getUsersForRoom = function(roomId) { }; PresenceManager.prototype.connect = function(connection) { - this.connections.add(connection); + this.system.addConnection(connection); + this.core.emit('connect', connection); connection.user = this.users.getOrAdd(connection.user); @@ -40,13 +43,14 @@ PresenceManager.prototype.connect = function(connection) { }; PresenceManager.prototype.disconnect = function(connection) { - this.connections.remove(connection); + this.system.removeConnection(connection); + this.core.emit('disconnect', connection); this.rooms.removeConnection(connection); }; -PresenceManager.prototype.join = function(connection, roomId, roomSlug) { - var room = this.rooms.getOrAdd(roomId, roomSlug); - room.addConnection(connection); +PresenceManager.prototype.join = function(connection, room) { + var pRoom = this.rooms.getOrAdd(room); + pRoom.addConnection(connection); }; PresenceManager.prototype.leave = function(connection, roomId) { diff --git a/app/core/presence/connection-collection.js b/app/core/presence/connection-collection.js index a0ced68..867c5af 100644 --- a/app/core/presence/connection-collection.js +++ b/app/core/presence/connection-collection.js @@ -10,6 +10,7 @@ function ConnectionCollection() { this.get = this.get.bind(this); this.getUsers = this.getUsers.bind(this); this.getUserIds = this.getUserIds.bind(this); + this.getUsernames = this.getUsernames.bind(this); this.add = this.add.bind(this); this.remove = this.remove.bind(this); @@ -28,28 +29,40 @@ ConnectionCollection.prototype.contains = function(connection) { return !!this.connections[connection.id]; }; -ConnectionCollection.prototype.getUsers = function() { - var users = _.map(this.connections, function(value, key) { - return value.user; - }); +ConnectionCollection.prototype.getUsers = function(filter) { + var connections = this.connections; + + if (filter) { + connections = this.query(filter); + } - return _.uniq(users, 'id'); + var users = _.chain(connections) + .filter(function(value, key) { + return !!value.user; + }) + .map(function(value, key) { + return value.user; + }) + .uniq('id') + .value(); + + return users; }; -ConnectionCollection.prototype.getUserIds = function() { - var userIds = _.map(this.connections, function(value, key) { - return value.user.id; - }); +ConnectionCollection.prototype.getUserIds = function(filter) { + var users = this.getUsers(filter); - return _.uniq(userIds); + return _.map(users, function(user) { + return user.id; + }); }; -ConnectionCollection.prototype.getUsernames = function() { - var usernames = _.map(this.connections, function(value, key) { - return value.user.username; - }); +ConnectionCollection.prototype.getUsernames = function(filter) { + var users = this.getUsers(filter); - return _.uniq(usernames); + return _.map(users, function(user) { + return user.username; + }); }; ConnectionCollection.prototype.query = function(options) { diff --git a/app/core/presence/room-collection.js b/app/core/presence/room-collection.js index 2c660e7..7c7d934 100644 --- a/app/core/presence/room-collection.js +++ b/app/core/presence/room-collection.js @@ -29,16 +29,17 @@ RoomCollection.prototype.slug = function(slug) { }); }; -RoomCollection.prototype.getOrAdd = function(roomId, roomSlug) { - roomId = roomId.toString(); - roomSlug = roomSlug && roomSlug.toString() || roomId.toString(); - var room = this.rooms[roomId]; - if (!room) { - room = this.rooms[roomId] = new Room(roomId, roomSlug); - room.on('user_join', this.onJoin); - room.on('user_leave', this.onLeave); +RoomCollection.prototype.getOrAdd = function(room) { + var roomId = room._id.toString(); + var pRoom = this.rooms[roomId]; + if (!pRoom) { + pRoom = this.rooms[roomId] = new Room({ + room: room + }); + pRoom.on('user_join', this.onJoin); + pRoom.on('user_leave', this.onLeave); } - return room; + return pRoom; }; RoomCollection.prototype.onJoin = function(data) { diff --git a/app/core/presence/room.js b/app/core/presence/room.js index 07f1cee..82ce8ca 100644 --- a/app/core/presence/room.js +++ b/app/core/presence/room.js @@ -5,10 +5,23 @@ var EventEmitter = require('events').EventEmitter, _ = require('lodash'), ConnectionCollection = require('./connection-collection'); -function Room(roomId, roomSlug) { +function Room(options) { EventEmitter.call(this); - this.roomId = roomId; - this.roomSlug = roomSlug; + + if (options.system) { + // This is the system room + // Used for tracking what users are online + this.system = true; + this.roomId = undefined; + this.roomSlug = undefined; + this.hasPassword = false; + } else { + this.system = false; + this.roomId = options.room._id.toString(); + this.roomSlug = options.room.slug; + this.hasPassword = options.room.hasPassword; + } + this.connections = new ConnectionCollection(); this.userCount = 0; @@ -25,15 +38,15 @@ function Room(roomId, roomSlug) { util.inherits(Room, EventEmitter); -Room.prototype.getUsers = function() { +Room.prototype.getUsers = function(filter) { return this.connections.getUsers(); }; -Room.prototype.getUserIds = function() { +Room.prototype.getUserIds = function(filter) { return this.connections.getUserIds(); }; -Room.prototype.getUsernames = function() { +Room.prototype.getUsernames = function(filter) { return this.connections.getUsernames(); }; @@ -43,22 +56,41 @@ Room.prototype.containsUser = function(userId) { Room.prototype.emitUserJoin = function(data) { this.userCount++; - this.emit('user_join', { - roomId: this.roomId, - roomSlug: this.roomSlug, + + var d = { userId: data.userId, username: data.username - }); + }; + + if (this.system) { + d.system = true; + } else { + d.roomId = this.roomId; + d.roomSlug = this.roomSlug; + d.roomHasPassword = this.hasPassword; + } + + this.emit('user_join', d); }; Room.prototype.emitUserLeave = function(data) { this.userCount--; - this.emit('user_leave', { - roomId: this.roomId, - roomSlug: this.roomSlug, + + var d = { + user: data.user, userId: data.userId, username: data.username - }); + }; + + if (this.system) { + d.system = true; + } else { + d.roomId = this.roomId; + d.roomSlug = this.roomSlug; + d.roomHasPassword = this.hasPassword; + } + + this.emit('user_leave', d); }; Room.prototype.usernameChanged = function(data) { @@ -86,6 +118,7 @@ Room.prototype.addConnection = function(connection) { !this.containsUser(connection.user.id)) { // User joining room this.emitUserJoin({ + user: connection.user, userId: connection.user.id, username: connection.user.username }); @@ -104,6 +137,7 @@ Room.prototype.removeConnection = function(connection) { !this.containsUser(connection.user.id)) { // Leaving room altogether this.emitUserLeave({ + user: connection.user, userId: connection.user.id, username: connection.user.username }); diff --git a/app/core/presence/user-collection.js b/app/core/presence/user-collection.js index e5187ce..b6ac709 100644 --- a/app/core/presence/user-collection.js +++ b/app/core/presence/user-collection.js @@ -1,10 +1,14 @@ 'use strict'; var EventEmitter = require('events').EventEmitter, + crypto = require('crypto'), + http = require('http'), util = require('util'), _ = require('lodash'); -function UserCollection() { +function UserCollection(options) { + EventEmitter.call(this); + this.core = options.core; this.users = {}; this.get = this.get.bind(this); @@ -12,16 +16,25 @@ function UserCollection() { this.remove = this.remove.bind(this); } +util.inherits(UserCollection, EventEmitter); + UserCollection.prototype.get = function(userId) { return this.users[userId]; }; +UserCollection.prototype.getByUsername = function(username) { + return _.find(this.users, function(user) { + return user.username === username; + }); +}; + UserCollection.prototype.getOrAdd = function(user) { - user = typeof user.toJSON === 'function' ? user.toJSON() : user; - var userId = user.id.toString(); + var user2 = typeof user.toJSON === 'function' ? user.toJSON() : user; + var userId = user2.id.toString(); if (!this.users[userId]) { - _.assign(user, { id: userId }); - this.users[userId] = user; + _.assign(user2, { id: userId }); + this.users[userId] = user2; + this.core.avatars.add(user); } return this.users[userId]; }; diff --git a/app/core/rooms.js b/app/core/rooms.js index 88ad540..a358b18 100644 --- a/app/core/rooms.js +++ b/app/core/rooms.js @@ -1,12 +1,33 @@ 'use strict'; var mongoose = require('mongoose'), + _ = require('lodash'), + bcrypt = require('bcryptjs'), helpers = require('./helpers'); function RoomManager(options) { this.core = options.core; } +RoomManager.prototype.canJoin = function(options, cb) { + var method = options.id ? 'get' : 'slug', + roomId = options.id ? options.id : options.slug; + + this[method](roomId, function(err, room) { + if (err) { + return cb(err); + } + + if (!room) { + return cb(); + } + + room.canJoin(options, function(err, canJoin) { + cb(err, room, canJoin); + }); + }); +}; + RoomManager.prototype.create = function(options, cb) { var Room = mongoose.model('Room'); Room.create(options, function(err, room) { @@ -36,10 +57,19 @@ RoomManager.prototype.update = function(roomId, options, cb) { return cb('Room does not exist.'); } + if(room.hasPassword && !room.owner.equals(options.user.id)) { + return cb('Only owner can change password-protected room.'); + } + room.name = options.name; // DO NOT UPDATE SLUG // room.slug = options.slug; room.description = options.description; + + if (room.hasPassword && options.password) { + room.password = options.password; + } + room.save(function(err, room) { if (err) { console.error(err); @@ -105,9 +135,43 @@ RoomManager.prototype.list = function(options, cb) { if (options.sort) { var sort = options.sort.replace(',', ' '); find.sort(sort); + } else { + find.sort('-lastActive'); } - find.exec(cb); + find.exec(function(err, rooms) { + if (err) { + return cb(err); + } + + if (options.users) { + rooms = _.map(rooms, function(room) { + var users = []; + + // Better approach would be this, + // but need to fix join/leave events: + // var auth = room.isAuthorized(options.userId); + + if (!room.password) { + users = this.core.presence + .getUsersForRoom(room.id.toString()); + } + + room = room.toJSON(); + room.users = users || []; + room.userCount = room.users.length; + return room; + }, this); + + if (!options.sort) { + rooms = _.sortByAll(rooms, ['userCount', 'lastActive']) + .reverse(); + } + } + + cb(null, rooms); + + }.bind(this)); }; RoomManager.prototype.get = function(identifier, cb) { diff --git a/app/core/usermessages.js b/app/core/usermessages.js new file mode 100644 index 0000000..fe6ac11 --- /dev/null +++ b/app/core/usermessages.js @@ -0,0 +1,128 @@ +'use strict'; + +var _ = require('lodash'), + mongoose = require('mongoose'), + helpers = require('./helpers'); + +function UserMessageManager(options) { + this.core = options.core; +} + +// options.currentUser, options.user + +UserMessageManager.prototype.onMessageCreated = function(message, user, cb) { + var User = mongoose.model('User'); + + User.findOne(message.owner, function(err, owner) { + if (err) { + console.error(err); + return cb(err); + } + if (cb) { + cb(null, message, user, owner); + } + + this.core.emit('user-messages:new', message, user, owner); + }.bind(this)); +}; + +UserMessageManager.prototype.create = function(options, cb) { + var UserMessage = mongoose.model('UserMessage'), + User = mongoose.model('User'); + + User.findById(options.user, function(err, user) { + if (err) { + console.error(err); + return cb(err); + } + if (!user) { + return cb('User does not exist.'); + } + + var data = { + users: [options.owner, options.user], + owner: options.owner, + text: options.text + }; + + var message = new UserMessage(data); + + // Test if this message is OTR + if (data.text.match(/^\?OTR/)) { + message._id = 'OTR'; + this.onMessageCreated(message, user, cb); + } else { + message.save(function(err) { + if (err) { + console.error(err); + return cb(err); + } + this.onMessageCreated(message, user, cb); + }.bind(this)); + } + }.bind(this)); +}; + +UserMessageManager.prototype.list = function(options, cb) { + options = options || {}; + + if (!options.room) { + return cb(null, []); + } + + options = helpers.sanitizeQuery(options, { + defaults: { + reverse: true, + take: 500 + }, + maxTake: 5000 + }); + + var UserMessage = mongoose.model('Message'), + User = mongoose.model('User'); + + var find = UserMessage.find({ + users: { $all: [options.currentUser, options.user] } + }); + + if (options.since_id) { + find.where('_id').gt(options.since_id); + } + + if (options.from) { + find.where('posted').gt(options.from); + } + + if (options.to) { + find.where('posted').lte(options.to); + } + + if (options.expand) { + var includes = options.expand.split(','); + + if (_.includes(includes, 'owner')) { + find.populate('owner', 'id username displayName email avatar'); + } + } + + if (options.skip) { + find.skip(options.skip); + } + + if (options.reverse) { + find.sort({ 'posted': -1 }); + } else { + find.sort({ 'posted': 1 }); + } + + find.limit(options.take) + .exec(function(err, messages) { + if (err) { + console.error(err); + return cb(err); + } + cb(null, messages); + }); +}; + +module.exports = UserMessageManager; diff --git a/app/core/users.js b/app/core/users.js new file mode 100644 index 0000000..19aba48 --- /dev/null +++ b/app/core/users.js @@ -0,0 +1,47 @@ +'use strict'; + +var mongoose = require('mongoose'), + helpers = require('./helpers'); + +function UserManager(options) { + this.core = options.core; +} + +UserManager.prototype.list = function(options, cb) { + options = options || {}; + + options = helpers.sanitizeQuery(options, { + defaults: { + take: 500 + }, + maxTake: 5000 + }); + + var User = mongoose.model('User'); + + var find = User.find(); + + if (options.skip) { + find.skip(options.skip); + } + + if (options.take) { + find.limit(options.take); + } + + find.exec(cb); +}; + +UserManager.prototype.get = function(identifier, cb) { + var User = mongoose.model('User'); + User.findById(identifier, cb); +}; + +UserManager.prototype.username = function(username, cb) { + var User = mongoose.model('User'); + User.findOne({ + username: username + }, cb); +}; + +module.exports = UserManager; diff --git a/app/models/room.js b/app/models/room.js index 86ad322..bb597ce 100644 --- a/app/models/room.js +++ b/app/models/room.js @@ -7,6 +7,7 @@ var mongoose = require('mongoose'), ObjectId = mongoose.Schema.Types.ObjectId, uniqueValidator = require('mongoose-unique-validator'), + bcrypt = require('bcryptjs'), settings = require('./../config'); var RoomSchema = new mongoose.Schema({ @@ -36,6 +37,10 @@ var RoomSchema = new mongoose.Schema({ ref: 'User', required: true }, + participants: [{ // We can have an array per role + type: ObjectId, + ref: 'User' + }], messages: [{ type: ObjectId, ref: 'Message' @@ -47,6 +52,10 @@ var RoomSchema = new mongoose.Schema({ lastActive: { type: Date, default: Date.now + }, + password: { + type: String, + required: false//only for password-protected room } }); @@ -54,10 +63,78 @@ RoomSchema.virtual('handle').get(function() { return this.slug || this.name.replace(/\W/i, ''); }); +RoomSchema.virtual('hasPassword').get(function() { + return !!this.password; +}); + +RoomSchema.pre('save', function(next) { + var room = this; + if (!room.password || !room.isModified('password')) { + return next(); + } + + bcrypt.hash(room.password, 10, function(err, hash) { + if (err) { + return next(err); + } + room.password = hash; + next(); + }); +}); + RoomSchema.plugin(uniqueValidator, { message: 'Expected {PATH} to be unique' }); +RoomSchema.method('isAuthorized', function(userId) { + if(!this.password) { + return true; + } + + if (this.owner.equals(userId)) { + return true; + } + + return this.participants.some(function(participant) { + return participant.equals(userId); + }); +}); + +RoomSchema.method('canJoin', function(options, cb) { + var userId = options.userId, + password = options.password, + saveMembership = options.saveMembership; + + if (this.isAuthorized(userId)) { + return cb(null, true); + } + + bcrypt.compare(password || '', this.password, function(err, isMatch) { + if(err) { + return cb(err); + } + + if (!isMatch) { + return cb(null, false); + } + + if (!saveMembership) { + return cb(null, true); + } + + this.participants.push(userId); + + this.save(function(err) { + if(err) { + return cb(err); + } + + cb(null, true); + }); + + }.bind(this)); +}); + RoomSchema.method('toJSON', function() { var room = this.toObject(); return { @@ -67,7 +144,8 @@ RoomSchema.method('toJSON', function() { description: room.description, lastActive: room.lastActive, created: room.created, - owner: room.owner + owner: room.owner, + hasPassword: this.hasPassword }; }); diff --git a/app/models/usermessage.js b/app/models/usermessage.js new file mode 100644 index 0000000..8d3c10a --- /dev/null +++ b/app/models/usermessage.js @@ -0,0 +1,54 @@ +// +// Message +// + +'use strict'; + +var mongoose = require('mongoose'), + ObjectId = mongoose.Schema.Types.ObjectId, + settings = require('./../config'); + +var MessageSchema = new mongoose.Schema({ + users: [{ + type: ObjectId, + ref: 'User' + }], + owner: { + type: ObjectId, + ref: 'User', + required: true + }, + text: { + type: String, + required: true, + trim: true + }, + posted: { + type: Date, + default: Date.now + } +}); + +if (settings.private.expire !== false) { + var defaultExpire = 6 * 60; // 6 hours + + MessageSchema.index({ posted: 1 }, { + expireAfterSeconds: (settings.private.expire || defaultExpire) * 60 + }); +} + +MessageSchema.index({ users: 1, posted: -1, _id: 1 }); + +// EXPOSE ONLY CERTAIN FIELDS +// This helps ensure that the client gets +// data that can be digested properly +MessageSchema.method('toJSON', function() { + return { + id: this._id, + owner: this.owner, + text: this.text, + posted: this.posted + }; +}); + +module.exports = mongoose.model('UserMessage', MessageSchema); diff --git a/app/xmpp/events/user-avatar-ready.js b/app/xmpp/events/user-avatar-ready.js new file mode 100644 index 0000000..362cc65 --- /dev/null +++ b/app/xmpp/events/user-avatar-ready.js @@ -0,0 +1,48 @@ +'use strict'; + +var _ = require('lodash'), + Stanza = require('node-xmpp-core').Stanza, + settings = require('./../../config'), + helper = require('./../helper'), + EventListener = require('./../event-listener'); + +module.exports = EventListener.extend({ + + on: 'avatar-cache:update', + + then: function(user) { + if (!settings.private.enable) { + return; + } + + var user_connections = this.core.presence.system.connections.query({ + type: 'xmpp', + userid: user.id + }); + + if (!user_connections.length) { + // Don't publish presence for this user + return; + } + + var connections = this.core.presence.system.connections.query({ + type: 'xmpp' + }); + + _.each(connections, function(x) { + if (x.user.id === user.id) { + return; + } + + // Reannounce presence + var presence = new Stanza.Presence({ + from: helper.getUserJid(user.username) + }); + + helper.populateVcard(presence, user, this.core); + + this.send(x, presence); + }, this); + } + +}); diff --git a/app/xmpp/events/user-connect.js b/app/xmpp/events/user-connect.js new file mode 100644 index 0000000..822e34f --- /dev/null +++ b/app/xmpp/events/user-connect.js @@ -0,0 +1,71 @@ +'use strict'; + +var _ = require('lodash'), + Stanza = require('node-xmpp-core').Stanza, + settings = require('./../../config'), + helper = require('./../helper'), + EventListener = require('./../event-listener'); + +module.exports = EventListener.extend({ + + on: 'connect', + + then: function(connection) { + if (!settings.private.enable) { + return; + } + + if (connection.type !== 'xmpp') { + return; + } + + var existing = this.core.presence.system.connections.query({ + userId: connection.user.id, + type: 'xmpp' + }); + + if (existing.length > 1) { + // Was already connected via XMPP + return; + } + + var connections = this.core.presence.system.connections.query({ + type: 'xmpp' + }); + + _.each(connections, function(x) { + if (x.user.id === connection.user.id) { + return; + } + + // Update rosters + var roster = new Stanza.Iq({ + id: connection.user.id, + type: 'set', + to: helper.getUserJid(x.user.username) + }); + + roster.c('query', { + xmlns: 'jabber:iq:roster' + }).c('item', { + jid: helper.getUserJid(connection.user.username), + name: connection.user.displayName, + subscription: 'both' + }).c('group').t('Let\'s Chat'); + + this.send(x, roster); + + + // Announce presence + var presence = new Stanza.Presence({ + from: helper.getUserJid(connection.user.username) + }); + + helper.populateVcard(presence, connection.user, this.core); + + this.send(x, presence); + + }, this); + } + +}); diff --git a/app/xmpp/events/user-disconnect.js b/app/xmpp/events/user-disconnect.js new file mode 100644 index 0000000..83407d1 --- /dev/null +++ b/app/xmpp/events/user-disconnect.js @@ -0,0 +1,52 @@ +'use strict'; + +var _ = require('lodash'), + Stanza = require('node-xmpp-core').Stanza, + settings = require('./../../config'), + helper = require('./../helper'), + EventListener = require('./../event-listener'); + +module.exports = EventListener.extend({ + + on: 'disconnect', + + then: function(connection) { + if (!settings.private.enable) { + return; + } + + if (connection.type !== 'xmpp') { + return; + } + + var existing = this.core.presence.system.connections.query({ + userId: connection.user.id, + type: 'xmpp' + }); + + if (existing.length > 0) { + // Still has other XMPP connections + return; + } + + var connections = this.core.presence.system.connections.query({ + type: 'xmpp' + }); + + _.each(connections, function(x) { + if (x.user.id === connection.user.id) { + return; + } + + var presence = new Stanza.Presence({ + to: helper.getUserJid(x.user.username), + from: helper.getUserJid(connection.user.username), + type: 'unavailable' + }); + + this.send(x, presence); + + }, this); + } + +}); diff --git a/app/xmpp/events/user-join.js b/app/xmpp/events/user-join.js index b52a405..d0a41bb 100644 --- a/app/xmpp/events/user-join.js +++ b/app/xmpp/events/user-join.js @@ -27,6 +27,10 @@ module.exports = EventListener.extend({ role: 'participant' }); + if (data.user) { + helper.populateVcard(presence, data.user, this.core); + } + this.send(connection, presence); }, this); } diff --git a/app/xmpp/events/usermessage-created.js b/app/xmpp/events/usermessage-created.js new file mode 100644 index 0000000..921d820 --- /dev/null +++ b/app/xmpp/events/usermessage-created.js @@ -0,0 +1,44 @@ +'use strict'; + +var Stanza = require('node-xmpp-core').Stanza, + settings = require('./../../config'), + helper = require('./../helper'), + EventListener = require('./../event-listener'); + +var mentionPattern = /\B@(\w+)(?!@)\b/g; + +module.exports = EventListener.extend({ + + on: 'user-messages:new', + + then: function(msg, user, owner) { + if (!settings.private.enable) { + return; + } + + var connections = this.core.presence.system.connections.query({ + userId: user._id.toString(), + type: 'xmpp' + }); + + connections.forEach(function(connection) { + + var stanza = new Stanza.Message({ + id: msg._id, + type: 'chat', + to: helper.getUserJid(user.username), + from: helper.getUserJid(owner.username) + }); + + stanza.c('active', { + xmlns: 'http://jabber.org/protocol/chatstates' + }); + + stanza.c('body').t(msg.text); + + this.send(connection, stanza); + + }, this); + } + +}); diff --git a/app/xmpp/helper.js b/app/xmpp/helper.js index e84d785..449e01f 100644 --- a/app/xmpp/helper.js +++ b/app/xmpp/helper.js @@ -17,7 +17,18 @@ function getRoomJid(roomId, username) { return jid; } +function populateVcard(presence, user, core) { + var vcard = presence.c('x', { xmlns: 'vcard-temp:x:update' }); + var photo = vcard.c('photo'); + + var avatar = core.avatars.get(user.id); + if (avatar) { + photo.t(avatar.sha1); + } +} + module.exports = { getUserJid: getUserJid, - getRoomJid: getRoomJid + getRoomJid: getRoomJid, + populateVcard: populateVcard }; diff --git a/app/xmpp/index.js b/app/xmpp/index.js index dce0053..8017f5a 100644 --- a/app/xmpp/index.js +++ b/app/xmpp/index.js @@ -47,7 +47,7 @@ function xmppStart(core) { var conn = new XmppConnection(user, client); core.presence.connect(conn); - + cb(null, opts); }); }); @@ -61,11 +61,40 @@ function xmppStart(core) { return processor.run(); }); - if (!handled && settings.xmpp.debug.unhandled) { + if (handled) { + return; + } + + if (settings.xmpp.debug.unhandled) { // Print unhandled request console.log(' '); console.log(stanza.root().toString().red); } + + if (stanza.name !== 'iq') { + return; + } + + var msg = new Stanza.Iq({ + type: 'error', + id: stanza.attrs.id, + to: stanza.attrs.from, + from: stanza.attrs.to + }); + + msg.c('not-implemented', { + code: 501, + type: 'CANCEL' + }).c('feature-not-implemented', { + xmlns: 'urn:ietf:params:xml:n:xmpp-stanzas' + }); + + + if (settings.xmpp.debug.unhandled) { + console.log(msg.root().toString().green); + } + + client.send(msg); }); // On Disconnect event. When a client disconnects diff --git a/app/xmpp/msg-processors/room-join.js b/app/xmpp/msg-processors/room-join.js index 1282b7e..2e26789 100644 --- a/app/xmpp/msg-processors/room-join.js +++ b/app/xmpp/msg-processors/room-join.js @@ -43,35 +43,52 @@ module.exports = MessageProcessor.extend({ // TODO: Do we need to track nickname for each individual room? this.connection.nickname = nickname; - this.core.rooms.slug(roomSlug, function(err, room) { + var options = { + userId: this.connection.user.id, + slug: roomSlug, + password: this.getPassword(), + saveMembership: true + }; + + this.core.rooms.canJoin(options, function(err, room, canJoin) { if (err) { return cb(err); } - if (room) { + if (room && canJoin) { return this.handleJoin(room, cb); } + if (room && !canJoin) { + return this.sendErrorPassword(room, cb); + } + if (!settings.xmpp.roomCreation) { return this.cantCreateRoom(roomSlug, cb); } - this.createRoom(roomSlug, function(err, room) { + return this.createRoom(roomSlug, function(err, room) { if (err) { return cb(err); } this.handleJoin(room, cb); }.bind(this)); + }.bind(this)); }, createRoom: function(roomSlug, cb) { + var password = this.getPassword(); var options = { owner: this.connection.user.id, name: roomSlug, slug: roomSlug, - description: '' + description: '', + password: password }; + if(!settings.rooms.passwordProtected) { + delete options.password; + } this.core.rooms.create(options, cb); }, @@ -95,6 +112,63 @@ module.exports = MessageProcessor.extend({ cb(null, presence); }, + _getXNode: function() { + if(!this.xNode) { + this.xNode = _.find(this.request.children, function(child) { + return child.name === 'x'; + }); + } + return this.xNode; + }, + + getHistoryNode: function() { + var xNode = this._getXNode(); + if (xNode) { + return _.find(xNode.children, function(child) { + return child.name === 'history'; + }); + } + }, + + getPassword: function() { + var xNode = this._getXNode(); + if (xNode) { + var passwordNode = _.find(xNode.children, function(child) { + return child.name === 'password'; + }); + if(passwordNode && passwordNode.children) { + return passwordNode.children[0]; + } + } + + return ''; + }, + + sendErrorPassword: function(room, cb) { + var connection = this.client.conn; + var username = connection.user.username; + + //from http://xmpp.org/extensions/xep-0045.html#enter-pw + var presence = this.Presence({ + type: 'error' + }); + + presence + .c('x', { + xmlns:'http://jabber.org/protocol/muc' + }); + presence + .c('error', { + type: 'auth', + by: helper.getRoomJid(room.slug) + }) + .c('not-authorized', { + xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' + }); + + return cb(null, presence); + }, + handleJoin: function(room, cb) { var username = this.connection.user.username; @@ -124,6 +198,9 @@ module.exports = MessageProcessor.extend({ role: 'participant' }); + // TODO: Add avatar for each room user + // helper.populateVcard(presence, user, this.core); + return presence; }, this); @@ -134,26 +211,18 @@ module.exports = MessageProcessor.extend({ subject.c('subject').t(room.name + ' | ' + room.description); - var xNode = _.find(this.request.children, function(child) { - return child.name === 'x'; - }); - - var historyNode; - if (xNode) { - historyNode = _.find(xNode.children, function(child) { - return child.name === 'history'; - }); - } + var historyNode = this.getHistoryNode(); if (!historyNode || historyNode.attrs.maxchars === 0 || historyNode.attrs.maxchars === '0') { // Send no history - this.core.presence.join(this.connection, room._id, room.slug); + this.core.presence.join(this.connection, room); return cb(null, presences, subject); } var query = { + userId: this.connection.user.id, room: room._id, expand: 'owner' }; @@ -205,7 +274,7 @@ module.exports = MessageProcessor.extend({ }, this); - this.core.presence.join(this.connection, room._id, room.slug); + this.core.presence.join(this.connection, room); cb(null, presences, msgs, subject); }.bind(this)); diff --git a/app/xmpp/msg-processors/root-join.js b/app/xmpp/msg-processors/root-join.js new file mode 100644 index 0000000..4f7a492 --- /dev/null +++ b/app/xmpp/msg-processors/root-join.js @@ -0,0 +1,49 @@ +'use strict'; + +var _ = require('lodash'), + moment = require('moment'), + Stanza = require('node-xmpp-core').Stanza, + MessageProcessor = require('./../msg-processor'), + settings = require('./../../config'), + helper = require('./../helper'); + +module.exports = MessageProcessor.extend({ + + if: function() { + return !this.request.to && + !this.request.type && + this.request.name === 'presence'; + }, + + then: function(cb) { + if (!settings.private.enable) { + return cb(); + } + + var msgs = []; + + var users = this.core.presence.system.connections.getUsers({ + type: 'xmpp' // Only XMPP supports private messaging - for now + }); + + _.each(users, function(user) { + if (user.id === this.connection.user.id) { + return; + } + + + var presence = this.Presence({ + from: helper.getUserJid(user.username), + type: undefined + }); + + helper.populateVcard(presence, user, this.core); + + msgs.push(presence); + + }, this); + + cb(null, msgs); + } + +}); diff --git a/app/xmpp/msg-processors/roster-get.js b/app/xmpp/msg-processors/roster-get.js index ac5e8b5..0e3ad95 100644 --- a/app/xmpp/msg-processors/roster-get.js +++ b/app/xmpp/msg-processors/roster-get.js @@ -1,7 +1,9 @@ 'use strict'; -var MessageProcessor = require('./../msg-processor'), - settings = require('./../../config'); +var _ = require('lodash'), + MessageProcessor = require('./../msg-processor'), + settings = require('./../../config'), + helper = require('./../helper'); module.exports = MessageProcessor.extend({ @@ -9,15 +11,58 @@ module.exports = MessageProcessor.extend({ return this.request.type === 'get' && this.ns['jabber:iq:roster']; }, - // Roster is always empty - everyone is friendless - // This should only be implemented if we support 1-to-1 private convos then: function(cb) { + if (!settings.private.enable) { + return this.sendRoster([], cb); + } + + if (settings.private.roster === 'all') { + return this.sendAllUsers(cb); + } + + this.sendOnlineUsers(cb); + }, + + sendOnlineUsers: function(cb) { + var users = this.core.presence.system.connections.getUsers({ + type: 'xmpp' // Only XMPP supports private messaging - for now + }); + + this.sendRoster(users, cb); + }, + + sendAllUsers: function(cb) { + var users = this.core.users.list({}, function(err, users) { + if (err) { + return cb(err); + } + + this.sendRoster(users, cb); + }.bind(this)); + }, + + sendRoster: function(users, cb) { var stanza = this.Iq(); var v = stanza.c('query', { xmlns: 'jabber:iq:roster' }); + _.each(users, function(user) { + if (user._id && user._id.equals(this.connection.user.id)) { + return; + } + if (user.id && user.id === this.connection.user.id) { + return; + } + + v.c('item', { + jid: helper.getUserJid(user.username), + name: user.displayName, + subscription: 'both' + }).c('group').t('Let\'s Chat'); + }, this); + cb(null, stanza); } diff --git a/app/xmpp/msg-processors/user-message.js b/app/xmpp/msg-processors/user-message.js new file mode 100644 index 0000000..e53606c --- /dev/null +++ b/app/xmpp/msg-processors/user-message.js @@ -0,0 +1,53 @@ +'use strict'; + +var _ = require('lodash'), + MessageProcessor = require('./../msg-processor'), + settings = require('./../../config'); + +var mentionPattern = /^([a-z0-9_]+\:)\B/; + +module.exports = MessageProcessor.extend({ + + if: function() { + return this.request.name === 'message' && + this.request.type === 'chat' && + !this.toARoom && + this.request.attrs.to; + }, + + then: function(cb) { + if (!settings.private.enable) { + return cb(); + } + + var username = this.request.attrs.to.split('@')[0]; + + var body = _.find(this.request.children, function (child) { + return child.name === 'body'; + }); + + if (!body) { + return cb(); + } + + this.core.users.username(username, function(err, user) { + if (err) { + return cb(err); + } + + if (!user) { + return cb(); + } + + this.core.usermessages.create({ + owner: this.connection.user.id, + user: user._id, + text: body.text() + }, function(err) { + cb(err); + }); + + }.bind(this)); + } + +}); diff --git a/app/xmpp/msg-processors/vcard-get.js b/app/xmpp/msg-processors/vcard-get.js index a1bedf4..3885f33 100644 --- a/app/xmpp/msg-processors/vcard-get.js +++ b/app/xmpp/msg-processors/vcard-get.js @@ -12,42 +12,56 @@ module.exports = MessageProcessor.extend({ }, then: function(cb) { - var jid = helper.getUserJid(this.client.conn.user.username); + var jid = helper.getUserJid(this.connection.user.username); var other = this.to && this.to !== jid; - var sendVcard = function (user) { - var stanza = this.Iq(); + if (!other) { + return this.sendVcard(this.connection.user, cb); + } + + var username = this.to.split('@')[0]; + var user = this.core.presence.users.getByUsername(username); - var v = stanza.c('vCard', { - xmlns: 'vcard-temp' - }); + if (user) { + return this.sendVcard(user, cb); + } - v.c('FN').t(user.firstName + ' ' + user.lastName); + var User = mongoose.model('User'); + User.findByIdentifier(username, function(err, user) { + if (user) { + this.sendVcard(user, cb); + } + }); + }, + sendVcard: function(user, cb) { + var stanza = this.Iq(); - var name = v.c('N'); - name.c('GIVEN').t(user.firstName); - name.c('FAMILY').t(user.lastName); + var vcard = stanza.c('vCard', { + xmlns: 'vcard-temp' + }); - v.c('NICKNAME').t(user.username); + vcard.c('FN').t(user.firstName + ' ' + user.lastName); - v.c('JABBERID').t(helper.getUserJid(user.username)); - cb(null, stanza); + var name = vcard.c('N'); + name.c('GIVEN').t(user.firstName); + name.c('FAMILY').t(user.lastName); - }.bind(this); + vcard.c('NICKNAME').t(user.username); - if (other) { - var User = mongoose.model('User'); - var username = this.to.split('@')[0]; - User.findByIdentifier(username, function(err, user) { - if (user) { - sendVcard(user); - } - }); - } else { - sendVcard(this.client.user); + vcard.c('JABBERID').t(helper.getUserJid(user.username)); + + var userId = (user.id || user._id).toString(); + + var avatar = this.core.avatars.get(userId); + if (avatar) { + var photo = vcard.c('PHOTO'); + photo.c('TYPE').t('image/jpeg'); + photo.c('BINVAL').t(avatar.base64); } + + cb(null, stanza); } }); diff --git a/defaults.yml b/defaults.yml index c707a62..8c8592b 100644 --- a/defaults.yml +++ b/defaults.yml @@ -57,4 +57,12 @@ auth: passwordRegex: ^.{8,64}$ salt: secretsauce # Required when upgrading from version < 0.3 +private: + enable: false + roster: online # online / all + expire: 360 # false or number of minutes + noRobots: true # Serve robots.txt with disallow + +rooms: + passwordProtected: false diff --git a/media/js/client.js b/media/js/client.js index c638d03..12386c4 100644 --- a/media/js/client.js +++ b/media/js/client.js @@ -3,6 +3,29 @@ // (function(window, $, _) { + + var RoomStore = { + add: function(id) { + var rooms = store.get('openrooms') || []; + if (!_.contains(rooms, id)) { + rooms.push(id); + store.set('openrooms', rooms); + } + }, + remove: function(id) { + var rooms = store.get('openrooms') || []; + if (_.contains(rooms, id)) { + store.set('openrooms', _.without(rooms, id)); + } + }, + get: function() { + var rooms = store.get('openrooms') || []; + rooms = _.uniq(rooms); + store.set('openrooms', rooms); + return rooms; + } + }; + // // Base // @@ -39,7 +62,8 @@ var room = { name: data.name, slug: data.slug, - description: data.description + description: data.description, + password: data.password }; var callback = data.callback; this.socket.emit('rooms:create', room, function(room) { @@ -88,8 +112,10 @@ replace: true }); return; + } else if(room) { + this.joinRoom(room, true); } else { - this.joinRoom(id, true); + this.joinRoom({id: id}, true); } }; Client.prototype.updateRoom = function(room) { @@ -123,38 +149,81 @@ this.leaveRoom(room.id); this.rooms.remove(room.id); }; - Client.prototype.rejoinRoom = function(id) { - this.joinRoom(id, undefined, true); + Client.prototype.rejoinRoom = function(room) { + this.joinRoom(room, undefined, true); }; - Client.prototype.joinRoom = function(id, switchRoom, rejoin) { - var that = this; + Client.prototype.lockJoin = function(id) { + if (_.contains(this.joining, id)) { + return false; + } - // We need an id and unlocked joining - if (!id || _.contains(this.joining, id)) { - // Nothing to do + this.joining = this.joining || []; + this.joining.push(id); + return true; + }; + Client.prototype.unlockJoin = function(id) { + var that = this; + _.defer(function() { + that.joining = _.without(that.joining, id); + }); + }; + Client.prototype.joinRoom = function(room, switchRoom, rejoin) { + if (!room || !room.id) { return; } + var that = this; + var id = room.id; + var password = room.password; + if (!rejoin) { // Must not have already joined - var room = that.rooms.get(id); - if (room && room.get('joined')) { + var room1 = that.rooms.get(id); + if (room1 && room1.get('joined')) { return; } } - // - // Setup joining lock - // - this.joining = this.joining || []; - this.joining.push(id); - this.socket.emit('rooms:join', id, function(resRoom) { + if (!this.lockJoin(id)) { + return; + } + + var passwordCB = function(password) { + room.password = password; + that.joinRoom(room, switchRoom, rejoin); + }; + + this.socket.emit('rooms:join', {roomId: id, password: password}, function(resRoom) { // Room was likely archived if this returns if (!resRoom) { return; } + + if (resRoom && resRoom.errors && + resRoom.errors === 'password required') { + + that.passwordModal.show({ + callback: passwordCB + }); + + that.unlockJoin(id); + return; + } + + if (resRoom && resRoom.errors) { + that.unlockJoin(id); + return; + } + var room = that.addRoom(resRoom); room.set('joined', true); + + if (room.get('hasPassword')) { + that.getRoomUsers(room.id, _.bind(function(users) { + this.setUsers(room.id, users); + }, that)); + } + // Get room history that.getMessages({ room: room.id, @@ -167,6 +236,7 @@ that.addMessages(messages, !rejoin && !room.lastMessage.get('id')); !rejoin && room.lastMessage.set(messages[messages.length - 1]); }); + if (that.options.filesEnabled) { that.getFiles({ room: room.id, @@ -183,21 +253,9 @@ // // Add room id to localstorage so we can reopen it on refresh // - var openRooms = store.get('openrooms'); - if (openRooms instanceof Array) { - // Check for duplicates - if (!_.contains(openRooms, id)) { - openRooms.push(id); - } - store.set('openrooms', openRooms); - } else { - store.set('openrooms', [id]); - } - - // Remove joining lock - _.defer(function() { - that.joining = _.without(that.joining, id); - }); + RoomStore.add(id); + + that.unlockJoin(id); }); }; Client.prototype.leaveRoom = function(id) { @@ -205,6 +263,9 @@ if (room) { room.set('joined', false); room.lastMessage.clear(); + if (room.get('hasPassword')) { + room.users.set([]); + } } this.socket.emit('rooms:leave', id); if (id === this.rooms.current.get('id')) { @@ -212,7 +273,7 @@ this.switchRoom(room && room.get('joined') ? room.id : ''); } // Remove room id from localstorage - store.set('openrooms', _.without(store.get('openrooms'), id)); + RoomStore.remove(id); }; Client.prototype.getRoomUsers = function(id, callback) { this.socket.emit('rooms:users', { @@ -399,19 +460,16 @@ return room.id; }); - var openRooms = store.get('openrooms'); - if (openRooms instanceof Array) { - // Flush the stored array - store.set('openrooms', []); - - openRooms = _.uniq(openRooms); - // Let's open some rooms! + var openRooms = RoomStore.get(); + // Let's open some rooms! + _.defer(function() { + //slow down because router can start a join with no password _.each(openRooms, function(id) { - if (roomIds.indexOf(id) !== -1) { - that.joinRoom(id); + if (_.contains(roomIds, id)) { + that.joinRoom({ id: id }); } }); - } + }.bind(this)); } // @@ -430,7 +488,7 @@ }); this.socket.on('reconnect', function() { _.each(that.rooms.where({ joined: true }), function(room) { - that.rejoinRoom(room.id); + that.rejoinRoom(room); }); }); this.socket.on('messages:new', function(message) { @@ -470,6 +528,7 @@ this.events.on('rooms:switch', this.switchRoom, this); this.events.on('rooms:archive', this.archiveRoom, this); this.events.on('profile:update', this.updateProfile, this); + this.events.on('rooms:join', this.joinRoom, this); }; // // Start @@ -482,6 +541,9 @@ this.view = new window.LCB.ClientView({ client: this }); + this.passwordModal = new window.LCB.RoomPasswordModalView({ + el: $('#lcb-password') + }); return this; }; // diff --git a/media/js/views/browser.js b/media/js/views/browser.js index 858f059..6235a59 100644 --- a/media/js/views/browser.js +++ b/media/js/views/browser.js @@ -44,11 +44,16 @@ $input = $target.is(':checkbox') && $target || $target.siblings('[type="checkbox"]'), id = $input.data('id'), room = this.rooms.get(id); + if (!room) { return; } - (!$input.is(':checked') && this.client.joinRoom(room.id)) || - (this.rooms.get(room.id).get('joined') && this.client.leaveRoom(room.id)); + + if (room.get('joined')) { + this.client.leaveRoom(room.id); + } else { + this.client.joinRoom(room); + } }, add: function(room) { var room = room.toJSON ? room.toJSON() : room, @@ -100,28 +105,60 @@ }); }, create: function(e) { + var that = this; e.preventDefault(); - var $modal = this.$('#lcb-add-room'), - $form = this.$(e.target), + var $form = this.$(e.target), + $modal = this.$('#lcb-add-room'), + $name = this.$('.lcb-room-name'), + $slug = this.$('.lcb-room-slug'), + $description = this.$('.lcb-room-description'), + $password = this.$('.lcb-room-password'), + $confirmPassword = this.$('.lcb-room-confirm-password'), data = { - name: this.$('.lcb-room-name').val().trim(), - slug: this.$('.lcb-room-slug').val().trim(), - description: this.$('.lcb-room-description').val(), + name: $name.val().trim(), + slug: $slug.val().trim(), + description: $description.val(), + password: $password.val(), callback: function success() { $modal.modal('hide'); $form.trigger('reset'); } }; + + $name.parent().removeClass('has-error'); + $slug.parent().removeClass('has-error'); + $confirmPassword.parent().removeClass('has-error'); + // we require name is non-empty if (!data.name) { $name.parent().addClass('has-error'); return; } + // we require slug is non-empty if (!data.slug) { $slug.parent().addClass('has-error'); return; } + + // remind the user, that users may share the password with others + if (data.password) { + if (data.password !== $confirmPassword.val()) { + $confirmPassword.parent().addClass('has-error'); + return; + } + + swal({ + title: 'Password-protected room', + text: 'You\'re creating a room with a shared password.\n' + + 'Anyone who obtains the password may enter the room.', + showCancelButton: true + }, function(){ + that.client.events.trigger('rooms:create', data); + }); + return; + } + this.client.events.trigger('rooms:create', data); }, addUser: function(user, room) { @@ -135,4 +172,4 @@ }); -}(window, $, _);
\ No newline at end of file +}(window, $, _); diff --git a/media/js/views/client.js b/media/js/views/client.js index 6231f08..b14b305 100644 --- a/media/js/views/client.js +++ b/media/js/views/client.js @@ -61,6 +61,7 @@ rooms: this.client.rooms }); } + // // Modals // @@ -78,6 +79,9 @@ this.notificationsModal = new window.LCB.NotificationsModalView({ el: this.$el.find('#lcb-notifications') }); + this.giphyModal = new window.LCB.GiphyModalView({ + el: this.$el.find('#lcb-giphy') + }); // // Misc // diff --git a/media/js/views/modals.js b/media/js/views/modals.js index e1d2fa7..a6d1c41 100644 --- a/media/js/views/modals.js +++ b/media/js/views/modals.js @@ -86,6 +86,29 @@ } }); + window.LCB.RoomPasswordModalView = Backbone.View.extend({ + events: { + 'click .btn-primary': 'enterRoom' + }, + initialize: function(options) { + this.render(); + this.$password = this.$('input.lcb-room-password-required'); + }, + render: function() { + // this.$el.on('shown.bs.modal hidden.bs.modal', + // _.bind(this.refresh, this)); + }, + show: function(options) { + this.callback = options.callback; + this.$password.val(''); + this.$el.modal('show'); + }, + enterRoom: function() { + this.$el.modal('hide'); + this.callback(this.$password.val()); + } + }); + window.LCB.AuthTokensModalView = Backbone.View.extend({ events: { 'click .generate-token': 'generateToken', @@ -176,4 +199,67 @@ } }); + window.LCB.GiphyModalView = Backbone.View.extend({ + events: { + 'keypress .search-giphy': 'stopReturn', + 'keyup .search-giphy': 'loadGifs' + }, + initialize: function(options) { + this.render(); + }, + render: function() { + this.$el.on('shown.bs.modal hidden.bs.modal', + _.bind(this.refresh, this)); + }, + refresh: function() { + this.$el.find('.giphy-results ul').empty(); + this.$('.search-giphy').val('').focus(); + }, + stopReturn: function(e) { + if(e.keyCode === 13) { + return false; + } + }, + loadGifs: _.throttle(function() { + console.log(1) + var that = this; + var search = this.$el.find('.search-giphy').val(); + + $.get('https://api.giphy.com/v1/gifs/search?limit=24&rating=pg-13&api_key=dc6zaTOxFJmzC&q=' + search) + .done(function(result) { + var images = result.data.filter(function(entry) { + return entry.images.fixed_width.url; + }).map(function(entry) { + return entry.images.fixed_width.url; + }); + + that.appendGifs(images); + }); + }, 400, {leading: false}), + appendGifs: function(images) { + var eles = images.map(function(url) { + var that = this; + var $img = $('<img src="' + url + + '" alt="gif" data-dismiss="modal"/></li>'); + + $img.click(function() { + var src = $(this).attr('src'); + $('.lcb-entry-input:visible').val(src); + $('.lcb-entry-button:visible').click(); + that.$el.modal('hide'); + }); + + return $("<li>").append($img); + }, this); + + var $div = this.$el.find('.giphy-results ul'); + + $div.empty(); + + eles.forEach(function($ele) { + $div.append($ele); + }); + } + }); + }(window, $, _); diff --git a/media/js/views/room.js b/media/js/views/room.js index 48071e9..736f81b 100644 --- a/media/js/views/room.js +++ b/media/js/views/room.js @@ -25,6 +25,13 @@ }, initialize: function(options) { this.client = options.client; + + var iAmOwner = this.model.get('owner') === this.client.user.id; + var iCanEdit = iAmOwner || !this.model.get('hasPassword'); + + this.model.set('iAmOwner', iAmOwner); + this.model.set('iCanEdit', iCanEdit); + this.template = options.template; this.messageTemplate = Handlebars.compile($('#template-message').html()); @@ -76,11 +83,17 @@ } }, getAtwhoUserFilter: function(collection) { + var currentUser = this.client.user; + return function filter(query, data, searchKey) { var q = query.toLowerCase(); var results = collection.filter(function(user) { var attr = user.attributes; + if (user.id === currentUser.id) { + return false; + } + if (!attr.safeName) { attr.safeName = attr.displayName.replace(/\W/g, ''); } @@ -213,7 +226,19 @@ if (e) { e.preventDefault(); } - this.$('.lcb-room-edit').modal(); + + var $modal = this.$('.lcb-room-edit'), + $name = $modal.find('input[name="name"]'), + $description = $modal.find('textarea[name="description"]'), + $password = $modal.find('input[name="password"]'), + $confirmPassword = $modal.find('input[name="confirmPassword"]'); + + $name.val(this.model.get('name')); + $description.val(this.model.get('description')); + $password.val(''); + $confirmPassword.val(''); + + $modal.modal(); }, hideEditRoom: function(e) { if (e) { @@ -225,14 +250,34 @@ if (e) { e.preventDefault(); } - var name = this.$('.edit-room input[name="name"]').val(); - var description = this.$('.edit-room textarea[name="description"]').val(); + + var $modal = this.$('.lcb-room-edit'), + $name = $modal.find('input[name="name"]'), + $description = $modal.find('textarea[name="description"]'), + $password = $modal.find('input[name="password"]'), + $confirmPassword = $modal.find('input[name="confirmPassword"]'); + + $name.parent().removeClass('has-error'); + $confirmPassword.parent().removeClass('has-error'); + + if (!$name.val()) { + $name.parent().addClass('has-error'); + return; + } + + if ($password.val() && $password.val() !== $confirmPassword.val()) { + $confirmPassword.parent().addClass('has-error'); + return; + } + this.client.events.trigger('rooms:update', { id: this.model.id, - name: name, - description: description + name: $name.val(), + description: $description.val(), + password: $password.val() }); - this.$('.lcb-room-edit').modal('hide'); + + $modal.modal('hide'); }, archiveRoom: function(e) { var that = this; diff --git a/media/js/views/transcript.js b/media/js/views/transcript.js index 0cab630..195343f 100644 --- a/media/js/views/transcript.js +++ b/media/js/views/transcript.js @@ -72,7 +72,7 @@ search: _.throttle(function() { this.query = this.$query.val() this.loadTranscript(); - }, 400), + }, 400, {leading: false}), loadTranscript: function() { var that = this; this.clearMessages(); diff --git a/media/js/views/window.js b/media/js/views/window.js index bf79ca2..e6f3c97 100644 --- a/media/js/views/window.js +++ b/media/js/views/window.js @@ -110,6 +110,7 @@ keys: { 'up+shift+alt down+shift+alt': 'nextRoom', 's+shift+alt': 'toggleRoomSidebar', + 'g+shift+alt': 'openGiphyModal', 'space+shift+alt': 'recallRoom' }, initialize: function(options) { @@ -132,6 +133,10 @@ e.preventDefault(); var view = this.client.view.panes.views[this.rooms.current.get('id')]; view && view.toggleSidebar && view.toggleSidebar(); + }, + openGiphyModal: function(e) { + e.preventDefault(); + $('.lcb-giphy').modal('show'); } }); diff --git a/media/less/style/chat/browser.less b/media/less/style/chat/browser.less index 2c53f18..79ff3e2 100644 --- a/media/less/style/chat/browser.less +++ b/media/less/style/chat/browser.less @@ -85,7 +85,7 @@ font-size: 20px; } -.lcb-rooms-list-item-slug { +.lcb-rooms-list-item-slug, .lcb-rooms-list-item-password { font-size: 16px; font-weight: 300; color: #aaa; diff --git a/media/less/style/chat/client.less b/media/less/style/chat/client.less index 9022653..e7ec6fe 100644 --- a/media/less/style/chat/client.less +++ b/media/less/style/chat/client.less @@ -193,3 +193,26 @@ .lcb-avatar { border-radius: 100%; } + +.lcb-giphy .giphy-results { + max-height: 350px; + overflow: auto; + + ul { + margin: 0; + padding: 0; + width: 100%; + text-align: center; + + li { + display: inline-block; + *display: inline; + *zoom: 1; + margin:5px; + + img { + cursor: pointer; + } + } + } +} diff --git a/media/less/style/chat/rooms.less b/media/less/style/chat/rooms.less index 41438de..e6e5997 100644 --- a/media/less/style/chat/rooms.less +++ b/media/less/style/chat/rooms.less @@ -77,6 +77,10 @@ box-shadow: none; } } + .password { + font-size: 14px; + cursor: default; + } .btn:hover { color: #666; } diff --git a/templates/chat.html b/templates/chat.html index 26eabf8..64c01f4 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -102,6 +102,7 @@ </header> <ul class="lcb-rooms-list"></ul> <% include 'includes/modals/add-room.html' %> + <% include 'includes/modals/password.html' %> </div> </section> <div class="lcb-loading lcb-client-loading"> @@ -113,5 +114,6 @@ <% include 'includes/modals/xmpp.html' %> <% include 'includes/modals/tokens.html' %> <% include 'includes/modals/upload.html' %> + <% include 'includes/modals/giphy.html' %> </div><!-- lcb-client end --> <% endblock %> diff --git a/templates/includes/js/browser-item.html b/templates/includes/js/browser-item.html index 456a4a9..6c6ba2b 100644 --- a/templates/includes/js/browser-item.html +++ b/templates/includes/js/browser-item.html @@ -1,7 +1,12 @@ <script type="text/x-handlebars-template" id="template-room-browser-item"> <li class="lcb-rooms-list-item" data-id="{{id}}"> <div class="lcb-rooms-list-item-top"> - <a class="lcb-rooms-list-item-name" href="#!/room/{{id}}">{{name}}</a> + {{#if hasPassword}} + <span class="lcb-rooms-list-item-password fa fa-lock"></span> + <a class="lcb-rooms-list-item-name" href="#!/room/{{id}}">{{name}}</a> + {{else}} + <a class="lcb-rooms-list-item-name" href="#!/room/{{id}}">{{name}}</a> + {{/if}} <span class="lcb-rooms-list-item-slug">#{{slug}}</span> </div> <div class="lcb-rooms-list-item-description">{{description}}</div> diff --git a/templates/includes/js/room.html b/templates/includes/js/room.html index 8afe4ef..6144d75 100644 --- a/templates/includes/js/room.html +++ b/templates/includes/js/room.html @@ -6,10 +6,15 @@ <h2 class="lcb-room-heading"> <span class="name">{{name}}</span> <span class="slug">#{{slug}}</span> - <a class="btn hidden-xs show-edit-room" - title="Edit Room"> - <i class="fa fa-edit"></i> - </a> + {{#if hasPassword}} + <span class="fa fa-lock btn password" title="This room require password to enter."></span> + {{/if}} + {{#if iCanEdit}} + <a class="btn hidden-xs show-edit-room" + title="Edit Room"> + <i class="fa fa-edit"></i> + </a> + {{/if}} <a class="btn hidden-xs" title="Chat History" href="./transcript?room={{ id }}" target="_blank"> <i class="fa fa-history"></i> @@ -19,6 +24,9 @@ <i class="fa fa-upload"></i> </a> <% endif %> + <a class="btn hidden-xs lcb-giphy" href="#lcb-giphy" title="Giphy" data-toggle="modal"> + <i class="fa fa-gift"></i> + </a> </h2> <div class="lcb-room-description"> <p>{{description}}</p> @@ -84,6 +92,24 @@ "description">{{description}}</textarea> </div> </div> + {{#if hasPassword}} + <div class="form-group"> + <label class= + "control-label col-sm-4">Password</label> + <div class="col-sm-7"> + <input class="form-control" name= + "password" type="password" value="{{password}}"> + </div> + </div> + <div class="form-group"> + <label class= + "control-label col-sm-4">Confirm Password</label> + <div class="col-sm-7"> + <input class="form-control" name= + "confirmPassword" type="password" value="{{password}}"> + </div> + </div> + {{/if}} <p class="response" style="display: none;"></p> </div> <div class="modal-footer"> diff --git a/templates/includes/modals/add-room.html b/templates/includes/modals/add-room.html index 5c26fe0..7e7de7a 100644 --- a/templates/includes/modals/add-room.html +++ b/templates/includes/modals/add-room.html @@ -14,7 +14,7 @@ </div> <div class="form-group"> - <label for="lcb-room-slug">Slug</label> + <label>Slug</label> <div class="input-group"> <span class="input-group-addon">#</span> <input type="text" placeholder="Slug" @@ -22,10 +22,25 @@ </div> </div> <div class="form-group"> - <label for="lcb-room-name">Description</label> + <label>Description</label> <input type="text" placeholder="Description" class="lcb-room-description form-control"> </div> + <% if settings.rooms.passwordProtected %> + <div class="row"> + <div class="form-group col-sm-9"> + <label>Password</label> + <input type="password" + placeholder="Empty for public room" + class="lcb-room-password form-control"> + </div> + <div class="form-group col-sm-9"> + <label>Confirm Password</label> + <input type="password" + class="lcb-room-confirm-password form-control"> + </div> + </div> + <% endif %> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> diff --git a/templates/includes/modals/giphy.html b/templates/includes/modals/giphy.html new file mode 100644 index 0000000..8590756 --- /dev/null +++ b/templates/includes/modals/giphy.html @@ -0,0 +1,26 @@ +<div id="lcb-giphy" class="lcb-giphy modal fade"> + <div class="modal-dialog"> + <div class="modal-content"> + <form class="validate form-horizontal" action="./messages" method="POST"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h4 class="modal-title"><i class="icon-edit"></i> Giphy</h4> + </div> + <div class="modal-body"> + <div class="form-group"> + <label class="control-label col-sm-6">Search</label> + <div class="col-sm-11"> + <input class="form-control search-giphy" name="search" /> + <p class="help-block"> + Powered by <a href="http://giphy.com/" target="_blank">Giphy</a> + </p> + </div> + </div> + <div class="giphy-results"> + <ul></ul> + </div> + </div> + </form> + </div> + </div> +</div> diff --git a/templates/includes/modals/password.html b/templates/includes/modals/password.html new file mode 100644 index 0000000..8791602 --- /dev/null +++ b/templates/includes/modals/password.html @@ -0,0 +1,23 @@ +<div id="lcb-password" class="modal fade" data-backdrop=false> + <div class="modal-dialog"> + <div class="modal-content"> + <form role="form" class="lcb-password"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> + <h4 class="modal-title">Password required</h4> + </div> + <div class="modal-body"> + <div class="form-group"> + <label for="lcb-room-password">Password</label> + <input type="password" + class="lcb-room-password-required form-control"> + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> + <button type="submit" class="btn btn-primary">Enter</button> + </div> + </form> + </div><!-- /.modal-content --> + </div><!-- /.modal-dialog --> +</div><!-- /.modal --> |