Real-time Chat (#7664)

* feat(realtime-chat): add Pusher library to the server

* feat(realtime-chat): only for private groups

* feat(realtime-chat): add authentication endpoint for Pusher

* feat(realtime-chat): client proof of concept

* fix typo in apidoc

* feat(realtime-chat): redo authentication and write integration tests

* remove firebase code

* fix client side tests

* fix line ending in bower.json

* feat(realtime chat): use presence channels for parties, send events & disconnect clients if user leaves or is removed from party, automatically update UI

* pusher: enable all events in the background

* fix pusher integration tests
This commit is contained in:
Matteo Pagliazzi
2016-07-02 15:17:24 +02:00
committed by GitHub
parent 889c41fa18
commit 0880850408
26 changed files with 393 additions and 285 deletions

View File

@@ -41,7 +41,9 @@
"sticky": "1.0.3", "sticky": "1.0.3",
"swagger-ui": "wordnik/swagger-ui#v2.0.24", "swagger-ui": "wordnik/swagger-ui#v2.0.24",
"smart-app-banner": "78ef9c0679723b25be1a0ae04f7b4aef7cbced4f", "smart-app-banner": "78ef9c0679723b25be1a0ae04f7b4aef7cbced4f",
"habitica-markdown": "1.2.2" "habitica-markdown": "1.2.2",
"pusher-js-auth": "^2.0.0",
"pusher-websocket-iso": "pusher#^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"angular-mocks": "1.3.9" "angular-mocks": "1.3.9"

View File

@@ -67,9 +67,10 @@
"GCM_SERVER_API_KEY": "", "GCM_SERVER_API_KEY": "",
"APN_ENABLED": "true" "APN_ENABLED": "true"
}, },
"FIREBASE": { "PUSHER": {
"APP": "app-name", "ENABLED": "false",
"SECRET": "secret", "APP_ID": "appId",
"ENABLED": "false" "KEY": "key",
"SECRET": "secret"
} }
} }

View File

@@ -31,8 +31,6 @@
"express": "~4.13.3", "express": "~4.13.3",
"express-csv": "~0.6.0", "express-csv": "~0.6.0",
"express-validator": "^2.18.0", "express-validator": "^2.18.0",
"firebase": "^2.2.9",
"firebase-token-generator": "^2.0.0",
"glob": "^4.3.5", "glob": "^4.3.5",
"got": "^6.1.1", "got": "^6.1.1",
"grunt": "~0.4.1", "grunt": "~0.4.1",
@@ -80,6 +78,7 @@
"pretty-data": "^0.40.0", "pretty-data": "^0.40.0",
"ps-tree": "^1.0.0", "ps-tree": "^1.0.0",
"push-notify": "habitrpg/push-notify#v1.2.0", "push-notify": "habitrpg/push-notify#v1.2.0",
"pusher": "^1.3.0",
"request": "~2.72.0", "request": "~2.72.0",
"rimraf": "^2.4.3", "rimraf": "^2.4.3",
"run-sequence": "^1.1.4", "run-sequence": "^1.1.4",

View File

@@ -1,18 +0,0 @@
import {
generateUser,
} from '../../../../../helpers/api-integration/v3';
import moment from 'moment';
describe('POST /user/auth/firebase', () => {
let user;
before(async () => {
user = await generateUser();
});
it('returns a Firebase token', async () => {
let {token, expires} = await user.post('/user/auth/firebase');
expect(moment(expires).isValid()).to.be.true;
expect(token).to.be.a('string');
});
});

View File

@@ -0,0 +1,102 @@
/* eslint-disable camelcase */
import {
generateUser,
requester,
translate as t,
} from '../../../../../helpers/api-integration/v3';
import { v4 as generateUUID } from 'uuid';
describe('POST /user/auth/pusher', () => {
let user;
let endpoint = '/user/auth/pusher';
beforeEach(async () => {
user = await generateUser();
});
it('requires authentication', async () => {
let api = requester();
await expect(api.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('missingAuthHeaders'),
});
});
it('returns an error if req.body.socket_id is missing', async () => {
await expect(user.post(endpoint, {
channel_name: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns an error if req.body.channel_name is missing', async () => {
await expect(user.post(endpoint, {
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns an error if req.body.channel_name is badly formatted', async () => {
await expect(user.post(endpoint, {
channel_name: '123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher channel type.',
});
});
it('returns an error if an invalid channel type is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'invalid-group-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher channel type.',
});
});
it('returns an error if an invalid resource type is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'presence-user-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher resource type.',
});
});
it('returns an error if an invalid resource id is passed', async () => {
await expect(user.post(endpoint, {
channel_name: 'presence-group-123',
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid Pusher resource id, must be a UUID.',
});
});
it('returns an error if the passed resource id doesn\'t match the user\'s party', async () => {
await expect(user.post(endpoint, {
channel_name: `presence-group-${generateUUID()}`,
socket_id: '123',
})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: 'Resource id must be the user\'s party.',
});
});
});

View File

@@ -29,13 +29,14 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A
$scope.postChat = function(group, message){ $scope.postChat = function(group, message){
if (_.isEmpty(message) || $scope._sending) return; if (_.isEmpty(message) || $scope._sending) return;
$scope._sending = true; $scope._sending = true;
var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; // var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false;
Chat.postChat(group._id, message, previousMsg) Chat.postChat(group._id, message) //, previousMsg) not sending the previousMsg as we have real time updates
.then(function(response) { .then(function(response) {
var message = response.data.data.message; var message = response.data.data.message;
if (message) { if (message) {
group.chat.unshift(message); group.chat.unshift(message);
group.chat.splice(200);
} else { } else {
group.chat = response.data.data.chat; group.chat = response.data.data.chat;
} }

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', 'Pusher',
function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics) { function($scope, Groups, User, Challenges, $rootScope, $state, $location, $compile, Analytics, Pusher) {
$scope.groups = { $scope.groups = {
guilds: [], guilds: [],
public: [], public: [],

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', 'Pusher',
function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social) { function($rootScope, $scope, Groups, Chat, User, Challenges, $state, $compile, Analytics, Quests, Social, Pusher) {
var user = User.user; var user = User.user;

View File

@@ -7,14 +7,19 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) { function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrl, Payments, $sce, $window, Analytics, TAVERN_ID) {
var user = User.user; var user = User.user;
var initSticky = _.once(function(){ // Setup page once user is synced
$timeout(function () { var clearAppLoadedListener = $rootScope.$watch('appLoaded', function (after) {
if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return; if (after === true) {
$('.header-wrap').sticky({topSpacing:0}); // Initialize sticky header
}); $timeout(function () {
}); if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return;
$('.header-wrap').sticky({topSpacing:0});
});
$rootScope.$on('userUpdated',initSticky); // Remove listener
clearAppLoadedListener();
}
});
$rootScope.$on('$stateChangeSuccess', $rootScope.$on('$stateChangeSuccess',
function(event, toState, toParams, fromState, fromParams){ function(event, toState, toParams, fromState, fromParams){

View File

@@ -1,8 +1,8 @@
'use strict'; 'use strict';
angular.module('habitrpg') angular.module('habitrpg')
.factory('Chat', ['$http', 'ApiUrl', 'User', .factory('Chat', ['$http', 'ApiUrl', 'User', 'Pusher',
function($http, ApiUrl, User) { function($http, ApiUrl, User, Pusher) {
var apiV3Prefix = '/api/v3'; var apiV3Prefix = '/api/v3';
function getChat (groupId) { function getChat (groupId) {
@@ -24,6 +24,7 @@ angular.module('habitrpg')
url: url, url: url,
data: { data: {
message: message, message: message,
pusherSocketId: Pusher.socketId, // to make sure the send doesn't get notified of it's own message
} }
}); });
} }

View File

@@ -0,0 +1,94 @@
'use strict';
angular.module('habitrpg')
.factory('Pusher', ['$rootScope', 'STORAGE_SETTINGS_ID', 'Groups',
function($rootScope, STORAGE_SETTINGS_ID, Groups) {
var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID));
var IS_PUSHER_ENABLED = window.env['PUSHER:ENABLED'] === 'true';
var api = {
pusher: undefined,
socketId: undefined, // when defined the user is connected
};
// Setup chat channels once app is ready, only for parties for now
var clearAppLoadedListener = $rootScope.$watch('appLoaded', function (after) {
if (!after) return;
clearAppLoadedListener(); // clean the event listerner
if (!IS_PUSHER_ENABLED) return;
var user = $rootScope.user;
// Connect the user to Pusher and to the party's chat channel
var partyId = user && $rootScope.user.party && $rootScope.user.party._id;
if (!partyId) return;
api.pusher = new Pusher(window.env['PUSHER:KEY'], {
encrypted: true,
authEndpoint: '/api/v3/user/auth/pusher',
auth: {
headers: {
'x-api-user': settings && settings.auth && settings.auth.apiId,
'x-api-key': settings && settings.auth && settings.auth.apiToken,
},
},
});
api.pusher.connection.bind('error', function(err) {
console.error(err);
// TODO if( err.data.code === 4004 ) detected connection limit
});
api.pusher.connection.bind('connected', function () {
api.socketId = api.pusher.connection.socket_id;
});
var partyChannelName = 'presence-group-' + partyId;
var partyChannel = api.pusher.subscribe(partyChannelName);
// When an error occurs while joining the channel
partyChannel.bind('pusher:subscription_error', function(status) {
console.error('Impossible to join the Pusher channel for your party, status: ', status);
});
// When the user correctly enters the party channel
partyChannel.bind('pusher:subscription_succeeded', function(members) {
// TODO members = [{id, info}]
});
// When a member enters the party channel
partyChannel.bind('pusher:member_added', function(member) {
// TODO member = {id, info}
});
// When a member leaves the party channel
partyChannel.bind('pusher:member_removed', function(member) {
// TODO member = {id, info}
});
// When the user is booted from the party, they get disconnected from Pusher
partyChannel.bind('user-removed', function (data) {
if (data.userId === user._id) {
api.pusher.unsubscribe(partyChannelName);
}
});
// Same when the user leaves the party
partyChannel.bind('user-left', function (data) {
if (data.userId === user._id) {
api.pusher.unsubscribe(partyChannelName);
}
});
// When a new chat message is posted
partyChannel.bind('new-chat', function (data) {
Groups.party().then(function () {
// Groups.data.party.chat.unshift(data);
// Groups.data.party.chat.splice(200);
});
});
});
return api;
}]);

View File

@@ -1,6 +1,7 @@
{ {
"app": { "app": {
"js": [ "js": [
"bower_components/pusher-websocket-iso/dist/web/pusher.js",
"bower_components/jquery/dist/jquery.min.js", "bower_components/jquery/dist/jquery.min.js",
"bower_components/jquery.cookie/jquery.cookie.js", "bower_components/jquery.cookie/jquery.cookie.js",
"bower_components/pnotify/jquery.pnotify.min.js", "bower_components/pnotify/jquery.pnotify.min.js",
@@ -58,6 +59,7 @@
"js/services/userNotificationsService.js", "js/services/userNotificationsService.js",
"js/services/userServices.js", "js/services/userServices.js",
"js/services/hallServices.js", "js/services/hallServices.js",
"js/services/pusherService.js",
"js/filters/money.js", "js/filters/money.js",
"js/filters/roundLargeNumbers.js", "js/filters/roundLargeNumbers.js",

View File

@@ -6,7 +6,6 @@ var async = require('async');
var utils = require('../../libs/api-v2/utils'); var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf'); var nconf = require('nconf');
var request = require('request'); var request = require('request');
var FirebaseTokenGenerator = require('firebase-token-generator');
import { import {
model as User, model as User,
} from '../../models/user'; } from '../../models/user';
@@ -351,28 +350,6 @@ api.changePassword = function(req, res, next) {
}) })
}; };
// DISABLED FOR API v2
/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
api.getFirebaseToken = function(req, res, next) {
var user = res.locals.user;
// Expires 24 hours after now (60*60*24*1000) (in milliseconds)
var expires = new Date();
expires.setTime(expires.getTime() + 86400000);
var token = firebaseTokenGeneratorInstance
.createToken({
uid: user._id,
isHabiticaUser: true
}, {
expires: expires
});
res.status(200).json({
token: token,
expires: expires
});
};*/
// DISABLED FOR API v2 // DISABLED FOR API v2
/*api.setupPassport = function(router) { /*api.setupPassport = function(router) {

View File

@@ -32,7 +32,6 @@ var isProd = nconf.get('NODE_ENV') === 'production';
var api = module.exports; var api = module.exports;
var pushNotify = require('./pushNotifications'); var pushNotify = require('./pushNotifications');
var analytics = utils.analytics; var analytics = utils.analytics;
var firebase = require('../../libs/api-v2/firebase');
/* /*
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -233,8 +232,6 @@ api.create = function(req, res, next) {
function(cb){user.save(cb)}, function(cb){user.save(cb)},
function(saved,ct,cb){group.save(cb)}, function(saved,ct,cb){group.save(cb)},
function(saved,ct,cb){ function(saved,ct,cb){
firebase.updateGroupData(saved);
firebase.addUserToGroup(saved._id, user._id);
saved.getTransformedData({ saved.getTransformedData({
populateMembers: nameFields, populateMembers: nameFields,
cb, cb,
@@ -278,7 +275,6 @@ api.update = function(req, res, next) {
group.save(function(err, saved){ group.save(function(err, saved){
if (err) return next(err); if (err) return next(err);
firebase.updateGroupData(saved);
res.sendStatus(204); res.sendStatus(204);
}); });
} }
@@ -548,7 +544,6 @@ api.join = function(req, res, next) {
group.save(cb); group.save(cb);
}, },
function(cb){ function(cb){
firebase.addUserToGroup(group._id, user._id);
group.getTransformedData({ group.getTransformedData({
cb, cb,
populateMembers: group.type === 'party' ? partyFields : nameFields, populateMembers: group.type === 'party' ? partyFields : nameFields,

View File

@@ -33,7 +33,6 @@ import v3UserController from '../api-v3/user';
let i18n = shared.i18n; let i18n = shared.i18n;
var api = module.exports; var api = module.exports;
var firebase = require('../../libs/api-v2/firebase');
var webhook = require('../../libs/api-v2/webhook'); var webhook = require('../../libs/api-v2/webhook');
const partyMembersFields = 'profile.name stats achievements items.special'; const partyMembersFields = 'profile.name stats achievements items.special';
@@ -468,7 +467,6 @@ api.delete = function(req, res, next) {
return user.remove(); return user.remove();
}) })
.then(() => { .then(() => {
firebase.deleteUser(user._id);
res.sendStatus(200); res.sendStatus(200);
}) })
.catch(next); .catch(next);

View File

@@ -18,8 +18,8 @@ import { model as Group } from '../../models/group';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { decrypt } from '../../libs/api-v3/encryption'; import { decrypt } from '../../libs/api-v3/encryption';
import FirebaseTokenGenerator from 'firebase-token-generator';
import { send as sendEmail } from '../../libs/api-v3/email'; import { send as sendEmail } from '../../libs/api-v3/email';
import pusher from '../../libs/api-v3/pusher';
let api = {}; let api = {};
@@ -282,6 +282,76 @@ api.loginSocial = {
}, },
}; };
/*
* @apiIgnore Private route
* @api {post} /api/v3/user/auth/pusher Pusher.com authentication
* @apiDescription Authentication for Pusher.com private and presence channels
* @apiVersion 3.0.0
* @apiName UserAuthPusher
* @apiGroup User
*
* @apiParam {String} socket_id Body parameter
* @apiParam {String} channel_name Body parameter
*
* @apiSuccess {String} auth The authentication token
*/
api.pusherAuth = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/auth/pusher',
async handler (req, res) {
let user = res.locals.user;
req.checkBody('socket_id').notEmpty();
req.checkBody('channel_name').notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let socketId = req.body.socket_id;
let channelName = req.body.channel_name;
// Channel names are in the form of {presence|private}-{group|...}-{resourceId}
let [channelType, resourceType, ...resourceId] = channelName.split('-');
if (['presence'].indexOf(channelType) === -1) { // presence is used only for parties, private for guilds too
throw new BadRequest('Invalid Pusher channel type.');
}
if (resourceType !== 'group') { // only groups are supported
throw new BadRequest('Invalid Pusher resource type.');
}
resourceId = resourceId.join('-'); // the split at the beginning had split resourceId too
if (!validator.isUUID(resourceId)) {
throw new BadRequest('Invalid Pusher resource id, must be a UUID.');
}
// Only the user's party is supported for now
if (user.party._id !== resourceId) {
throw new NotFound('Resource id must be the user\'s party.');
}
let authResult;
// Max 100 members for presence channel - parties only
if (channelType === 'presence') {
let presenceData = {
user_id: user._id, // eslint-disable-line camelcase
// Max 1KB
user_info: {}, // eslint-disable-line camelcase
};
authResult = pusher.authenticate(socketId, channelName, presenceData);
} else {
authResult = pusher.authenticate(socketId, channelName);
}
// Not using res.respond because Pusher requires a different response format
res.status(200).json(authResult);
},
};
/** /**
* @api {put} /api/v3/user/auth/update-username Update username * @api {put} /api/v3/user/auth/update-username Update username
* @apiDescription Update the username of a local user * @apiDescription Update the username of a local user
@@ -472,28 +542,6 @@ api.updateEmail = {
}, },
}; };
const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
// Internal route
api.getFirebaseToken = {
method: 'POST',
url: '/user/auth/firebase',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
// Expires 24 hours from now (60*60*24*1000) (in milliseconds)
let expires = new Date();
expires.setTime(expires.getTime() + 86400000);
let token = firebaseTokenGenerator.createToken({
uid: user._id,
isHabiticaUser: true,
}, { expires });
res.respond(200, {token, expires});
},
};
/** /**
* @api {delete} /api/v3/user/auth/social/:network Delete social authentication method * @api {delete} /api/v3/user/auth/social/:network Delete social authentication method
* @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled * @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled

View File

@@ -8,6 +8,7 @@ import {
import _ from 'lodash'; import _ from 'lodash';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/api-v3/email'; import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/api-v3/email';
import pusher from '../../libs/api-v3/pusher';
import nconf from 'nconf'; import nconf from 'nconf';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
@@ -53,8 +54,8 @@ api.getChat = {
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {message} Body parameter - message The message to post * @apiParam {string} message Body parameter - message The message to post
* @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat * @apiParam {UUID} previousMsg Query parameter - The previous chat message which will force a return of the full group chat
* *
* @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message * @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message
*/ */
@@ -83,7 +84,7 @@ api.postChat = {
let lastClientMsg = req.query.previousMsg; let lastClientMsg = req.query.previousMsg;
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
group.sendChat(req.body.message, user); let newChatMessage = group.sendChat(req.body.message, user);
let toSave = [group.save()]; let toSave = [group.save()];
@@ -93,6 +94,14 @@ api.postChat = {
} }
let [savedGroup] = await Bluebird.all(toSave); let [savedGroup] = await Bluebird.all(toSave);
// real-time chat is only enabled for private groups (for now only for parties)
if (savedGroup.privacy === 'private' && savedGroup.type === 'party') {
// req.body.pusherSocketId is sent from official clients to identify the sender user's real time socket
// see https://pusher.com/docs/server_api_guide/server_excluding_recipients
pusher.trigger(`presence-group-${savedGroup._id}`, 'new-chat', newChatMessage, req.body.pusherSocketId);
}
if (chatUpdated) { if (chatUpdated) {
res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
} else { } else {
@@ -107,8 +116,8 @@ api.postChat = {
* @apiName LikeChat * @apiName LikeChat
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message _id * @apiParam {UUID} chatId The chat message _id
* *
* @apiSuccess {Object} data The liked chat message * @apiSuccess {Object} data The liked chat message
*/ */
@@ -154,8 +163,8 @@ api.likeChat = {
* @apiName FlagChat * @apiName FlagChat
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message id * @apiParam {UUID} chatId The chat message id
* *
* @apiSuccess {object} data The flagged chat message * @apiSuccess {object} data The flagged chat message
*/ */
@@ -243,8 +252,8 @@ api.flagChat = {
* @apiName ClearFlags * @apiName ClearFlags
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {chatId} chatId The chat message id * @apiParam {UUID} chatId The chat message id
* *
* @apiSuccess {Object} data An empty object * @apiSuccess {Object} data An empty object
*/ */
@@ -318,7 +327,7 @@ api.clearChatFlags = {
* @apiName SeenChat * @apiName SeenChat
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* *
* @apiSuccess {Object} data An empty object * @apiSuccess {Object} data An empty object
*/ */
@@ -354,8 +363,8 @@ api.seenChat = {
* @apiGroup Chat * @apiGroup Chat
* *
* @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime * @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {string} chatId The chat message id * @apiParam {UUID} chatId The chat message id
* *
* @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg * @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg
* @apiSuccess {Object} data An empty object when the previous message was deleted * @apiSuccess {Object} data An empty object when the previous message was deleted

View File

@@ -17,10 +17,11 @@ import {
NotAuthorized, NotAuthorized,
} from '../../libs/api-v3/errors'; } from '../../libs/api-v3/errors';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as firebase from '../../libs/api-v3/firebase';
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { encrypt } from '../../libs/api-v3/encryption'; import { encrypt } from '../../libs/api-v3/encryption';
import sendPushNotification from '../../libs/api-v3/pushNotifications'; import sendPushNotification from '../../libs/api-v3/pushNotifications';
import pusher from '../../libs/api-v3/pusher';
let api = {}; let api = {};
/** /**
@@ -66,9 +67,6 @@ api.createGroup = {
profile: {name: user.profile.name}, profile: {name: user.profile.name},
}; };
res.respond(201, response); // do not remove chat flags data as we've just created the group res.respond(201, response); // do not remove chat flags data as we've just created the group
firebase.updateGroupData(savedGroup);
firebase.addUserToGroup(savedGroup._id, user._id);
}, },
}; };
@@ -179,8 +177,6 @@ api.updateGroup = {
}; };
} }
res.respond(200, response); res.respond(200, response);
firebase.updateGroupData(savedGroup);
}, },
}; };
@@ -278,8 +274,6 @@ api.joinGroup = {
response.leader = leader.toJSON({minimize: true}); response.leader = leader.toJSON({minimize: true});
} }
res.respond(200, response); res.respond(200, response);
firebase.addUserToGroup(group._id, user._id);
}, },
}; };
@@ -449,7 +443,15 @@ api.removeGroupMember = {
if (isInGroup === 'guild') { if (isInGroup === 'guild') {
removeFromArray(member.guilds, group._id); removeFromArray(member.guilds, group._id);
} }
if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()? if (isInGroup === 'party') {
// Tell the realtime clients that a user is being removed
// If the user that is being removed is still connected, they'll get disconnected automatically
pusher.trigger(`presence-group-${group._id}`, 'user-removed', {
userId: user._id,
});
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
}
if (member.newMessages[group._id]) { if (member.newMessages[group._id]) {
member.newMessages[group._id] = undefined; member.newMessages[group._id] = undefined;

View File

@@ -13,7 +13,6 @@ import {
import { model as User } from '../../models/user'; import { model as User } from '../../models/user';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
import _ from 'lodash'; import _ from 'lodash';
import * as firebase from '../../libs/api-v3/firebase';
import * as passwordUtils from '../../libs/api-v3/password'; import * as passwordUtils from '../../libs/api-v3/password';
let api = {}; let api = {};
@@ -230,8 +229,6 @@ api.deleteUser = {
await user.remove(); await user.remove();
res.respond(200, {}); res.respond(200, {});
firebase.deleteUser(user._id);
}, },
}; };

View File

@@ -1,83 +0,0 @@
var Firebase = require('firebase');
var nconf = require('nconf');
var isProd = nconf.get('NODE_ENV') === 'production';
var firebaseConfig = nconf.get('FIREBASE');
var firebaseRef;
var isFirebaseEnabled = (nconf.get('NODE_ENV') === 'production') && (firebaseConfig.ENABLED === 'true');
import { TAVERN_ID } from '../../models/group';
// Setup
if(isFirebaseEnabled){
firebaseRef = new Firebase('https://' + firebaseConfig.APP + '.firebaseio.com');
// TODO what happens if an op is sent before client is authenticated?
firebaseRef.authWithCustomToken(firebaseConfig.SECRET, function(err, authData){
// TODO it's ok to kill the server here? what if FB is offline?
if(err) throw new Error('Impossible to authenticate Firebase');
});
}
var api = module.exports = {};
api.updateGroupData = function(group){
if(!isFirebaseEnabled) return;
// TODO is throw ok? we don't have callbacks
if(!group) throw new Error('group is required.');
// Return in case of tavern (comparison working because we use string for _id)
if(group._id === TAVERN_ID) return;
firebaseRef.child('rooms/' + group._id)
.set({
name: group.name
});
};
api.addUserToGroup = function(groupId, userId){
if(!isFirebaseEnabled) return;
if(!userId || !groupId) throw new Error('groupId, userId are required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('members/' + groupId + '/' + userId)
.set(true);
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
.set(true);
};
api.removeUserFromGroup = function(groupId, userId){
if(!isFirebaseEnabled) return;
if(!userId || !groupId) throw new Error('groupId, userId are required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('members/' + groupId + '/' + userId)
.remove();
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
.remove();
};
api.deleteGroup = function(groupId){
if(!isFirebaseEnabled) return;
if(!groupId) throw new Error('groupId is required.');
if(groupId === TAVERN_ID) return;
firebaseRef.child('rooms/' + groupId)
.remove();
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted (/members/... in future...)
firebaseRef.child('members/' + groupId)
.remove();
};
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted
api.deleteUser = function(userId){
if(!isFirebaseEnabled) return;
if(!userId) throw new Error('userId is required.');
firebaseRef.child('users/' + userId)
.remove();
};

View File

@@ -1,69 +0,0 @@
import Firebase from 'firebase';
import nconf from 'nconf';
import { TAVERN_ID } from '../../models/group';
const FIREBASE_CONFIG = nconf.get('FIREBASE');
const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true';
let firebaseRef;
if (FIREBASE_ENABLED) {
firebaseRef = new Firebase(`https://${FIREBASE_CONFIG.APP}.firebaseio.com`);
// TODO what happens if an op is sent before client is authenticated?
firebaseRef.authWithCustomToken(FIREBASE_CONFIG.SECRET, (err) => {
// TODO it's ok to kill the server here? what if FB is offline?
if (err) throw new Error('Impossible to authenticate Firebase');
});
}
export function updateGroupData (group) {
if (!FIREBASE_ENABLED) return;
// TODO is throw ok? we don't have callbacks
if (!group) throw new Error('group obj is required.');
// Return in case of tavern (comparison working because we use string for _id)
if (group._id === TAVERN_ID) return;
firebaseRef.child(`rooms/${group._id}`)
.set({
name: group.name,
});
}
export function addUserToGroup (groupId, userId) {
if (!FIREBASE_ENABLED) return;
if (!userId || !groupId) throw new Error('groupId, userId are required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}/${userId}`).set(true);
firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true);
}
export function removeUserFromGroup (groupId, userId) {
if (!FIREBASE_ENABLED) return;
if (!userId || !groupId) throw new Error('groupId, userId are required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}/${userId}`).remove();
firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove();
}
export function deleteGroup (groupId) {
if (!FIREBASE_ENABLED) return;
if (!groupId) throw new Error('groupId is required.');
if (groupId === TAVERN_ID) return;
firebaseRef.child(`members/${groupId}`).remove();
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted (/members/... in future...)
firebaseRef.child(`rooms/${groupId}`).remove();
}
// TODO not really necessary as long as we only store room data,
// as empty objects are automatically deleted
export function deleteUser (userId) {
if (!FIREBASE_ENABLED) return;
if (!userId) throw new Error('userId is required.');
firebaseRef.child(`users/${userId}`).remove();
}

View File

@@ -0,0 +1,42 @@
import Pusher from 'pusher';
import nconf from 'nconf';
import { InternalServerError } from './errors';
const IS_PUSHER_ENABLED = nconf.get('PUSHER:ENABLED') === 'true';
let pusherInstance;
if (IS_PUSHER_ENABLED) {
pusherInstance = new Pusher({
appId: nconf.get('PUSHER:APP_ID'),
key: nconf.get('PUSHER:KEY'),
secret: nconf.get('PUSHER:SECRET'),
encrypted: true,
});
}
let api = {
// https://github.com/pusher/pusher-http-node#publishing-events
trigger (channel, event, data, socketId = null) {
if (!IS_PUSHER_ENABLED) return Promise.resolve(null);
return new Promise((resolve, reject) => {
pusherInstance.trigger(channel, event, data, socketId, (err, req, res) => {
if (err) {
reject(err);
} else {
resolve([req, res]);
}
});
});
},
// https://github.com/pusher/pusher-http-node#authenticating-private-channels
authenticate (...args) {
if (!IS_PUSHER_ENABLED) throw new InternalServerError('Pusher is not enabled.');
return pusherInstance.authenticate(...args);
},
};
module.exports = api;

View File

@@ -15,7 +15,7 @@ import { mods } from '../../models/user';
const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations',
'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID', 'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID',
'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
'worldDmg', 'mods', 'IS_MOBILE']; 'worldDmg', 'mods', 'IS_MOBILE', 'PUSHER:KEY', 'PUSHER:ENABLED'];
let env = { let env = {
getManifestFiles, getManifestFiles,
@@ -32,9 +32,11 @@ let env = {
}, },
}; };
'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => { 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED'
env[key] = nconf.get(key); .split(' ')
}); .forEach(key => {
env[key] = nconf.get(key);
});
module.exports = function locals (req, res, next) { module.exports = function locals (req, res, next) {
let language = _.find(i18n.availableLanguages, {code: req.language}); let language = _.find(i18n.availableLanguages, {code: req.language});

View File

@@ -12,12 +12,12 @@ import {
InternalServerError, InternalServerError,
BadRequest, BadRequest,
} from '../libs/api-v3/errors'; } from '../libs/api-v3/errors';
import * as firebase from '../libs/api-v2/firebase';
import baseModel from '../libs/api-v3/baseModel'; import baseModel from '../libs/api-v3/baseModel';
import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
import nconf from 'nconf'; import nconf from 'nconf';
import sendPushNotification from '../libs/api-v3/pushNotifications'; import sendPushNotification from '../libs/api-v3/pushNotifications';
import pusher from '../libs/api-v3/pusher';
const questScrolls = shared.content.quests; const questScrolls = shared.content.quests;
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
@@ -31,8 +31,6 @@ const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = shared.constants.LARGE_GROUP_COUNT_MESS
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true'; const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API
// changes made directly to the db will cause Firebase to get out of sync
export let schema = new Schema({ export let schema = new Schema({
name: {type: String, required: true}, name: {type: String, required: true},
description: String, description: String,
@@ -109,10 +107,6 @@ schema.pre('remove', true, async function preRemoveGroup (next, done) {
} }
}); });
schema.post('remove', function postRemoveGroup (group) {
firebase.deleteGroup(group._id);
});
// return a clean object for user.quest // return a clean object for user.quest
function _cleanQuestProgress (merge) { function _cleanQuestProgress (merge) {
let clean = { let clean = {
@@ -239,12 +233,14 @@ schema.statics.getGroups = async function getGroups (options = {}) {
// Not putting into toJSON because there we can't access user // Not putting into toJSON because there we can't access user
schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
let toJSON = group.toJSON(); let toJSON = group.toJSON();
if (!user.contributor.admin) { if (!user.contributor.admin) {
_.remove(toJSON.chat, chatMsg => { _.remove(toJSON.chat, chatMsg => {
chatMsg.flags = {}; chatMsg.flags = {};
return chatMsg.flagCount >= 2; return chatMsg.flagCount >= 2;
}); });
} }
return toJSON; return toJSON;
}; };
@@ -308,7 +304,9 @@ export function chatDefaults (msg, user) {
} }
schema.methods.sendChat = function sendChat (message, user) { schema.methods.sendChat = function sendChat (message, user) {
this.chat.unshift(chatDefaults(message, user)); let newMessage = chatDefaults(message, user);
this.chat.unshift(newMessage);
this.chat.splice(200); this.chat.splice(200);
// do not send notifications for guilds with more than 5000 users and for the tavern // do not send notifications for guilds with more than 5000 users and for the tavern
@@ -331,6 +329,8 @@ schema.methods.sendChat = function sendChat (message, user) {
query._id = { $ne: user ? user._id : ''}; query._id = { $ne: user ? user._id : ''};
User.update(query, lastSeenUpdate, {multi: true}).exec(); User.update(query, lastSeenUpdate, {multi: true}).exec();
return newMessage;
}; };
schema.methods.startQuest = async function startQuest (user) { schema.methods.startQuest = async function startQuest (user) {
@@ -719,6 +719,11 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec()); promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec());
} else { } else {
promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec()); promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec());
// Tell the realtime clients that a user has left
// If the user that left is still connected, they'll get disconnected
pusher.trigger(`presence-group-${group._id}`, 'user-left', {
userId: user._id,
});
} }
// If user is the last one in group and group is private, delete it // If user is the last one in group and group is private, delete it
@@ -740,8 +745,6 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
promises.push(group.update(update).exec()); promises.push(group.update(update).exec());
} }
firebase.removeUserFromGroup(group._id, user._id);
return await Bluebird.all(promises); return await Bluebird.all(promises);
}; };

View File

@@ -16,6 +16,5 @@ router.post('/user/reset-password', getUserLanguage, auth.resetPassword);
router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword); router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword);
router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername); router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername);
router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail); router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail);
// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken);
module.exports = router; module.exports = router;

View File

@@ -17,7 +17,6 @@ import './libs/api-v3/i18n';
// Load config files // Load config files
import './libs/api-v3/setupMongoose'; import './libs/api-v3/setupMongoose';
import './libs/api-v3/firebase';
import './libs/api-v3/setupPassport'; import './libs/api-v3/setupPassport';
// Load some schemas & models // Load some schemas & models