diff --git a/bower.json b/bower.json index 0575241efe..51d060e599 100644 --- a/bower.json +++ b/bower.json @@ -41,7 +41,9 @@ "sticky": "1.0.3", "swagger-ui": "wordnik/swagger-ui#v2.0.24", "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": { "angular-mocks": "1.3.9" diff --git a/config.json.example b/config.json.example index c7551fff04..a6b4fd0cf1 100644 --- a/config.json.example +++ b/config.json.example @@ -67,9 +67,10 @@ "GCM_SERVER_API_KEY": "", "APN_ENABLED": "true" }, - "FIREBASE": { - "APP": "app-name", - "SECRET": "secret", - "ENABLED": "false" + "PUSHER": { + "ENABLED": "false", + "APP_ID": "appId", + "KEY": "key", + "SECRET": "secret" } } diff --git a/package.json b/package.json index b2cdaed6ae..1ea9b6965e 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,6 @@ "express": "~4.13.3", "express-csv": "~0.6.0", "express-validator": "^2.18.0", - "firebase": "^2.2.9", - "firebase-token-generator": "^2.0.0", "glob": "^4.3.5", "got": "^6.1.1", "grunt": "~0.4.1", @@ -80,6 +78,7 @@ "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", "push-notify": "habitrpg/push-notify#v1.2.0", + "pusher": "^1.3.0", "request": "~2.72.0", "rimraf": "^2.4.3", "run-sequence": "^1.1.4", diff --git a/test/api/v3/integration/user/auth/POST-firebase.test.js b/test/api/v3/integration/user/auth/POST-firebase.test.js deleted file mode 100644 index 7ebd5a20cb..0000000000 --- a/test/api/v3/integration/user/auth/POST-firebase.test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/test/api/v3/integration/user/auth/POST-user_auth_pusher.test.js b/test/api/v3/integration/user/auth/POST-user_auth_pusher.test.js new file mode 100644 index 0000000000..02691d0845 --- /dev/null +++ b/test/api/v3/integration/user/auth/POST-user_auth_pusher.test.js @@ -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.', + }); + }); +}); diff --git a/website/client/js/controllers/chatCtrl.js b/website/client/js/controllers/chatCtrl.js index 73aa1ab060..891abed5f6 100644 --- a/website/client/js/controllers/chatCtrl.js +++ b/website/client/js/controllers/chatCtrl.js @@ -29,13 +29,14 @@ habitrpg.controller('ChatCtrl', ['$scope', 'Groups', 'Chat', 'User', '$http', 'A $scope.postChat = function(group, message){ if (_.isEmpty(message) || $scope._sending) return; $scope._sending = true; - var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; - Chat.postChat(group._id, message, previousMsg) + // var previousMsg = (group.chat && group.chat[0]) ? group.chat[0].id : false; + Chat.postChat(group._id, message) //, previousMsg) not sending the previousMsg as we have real time updates .then(function(response) { var message = response.data.data.message; if (message) { group.chat.unshift(message); + group.chat.splice(200); } else { group.chat = response.data.data.chat; } diff --git a/website/client/js/controllers/guildsCtrl.js b/website/client/js/controllers/guildsCtrl.js index 34a1ab23a1..ec548cb35f 100644 --- a/website/client/js/controllers/guildsCtrl.js +++ b/website/client/js/controllers/guildsCtrl.js @@ -1,7 +1,7 @@ 'use strict'; -habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$rootScope', '$state', '$location', '$compile', 'Analytics', - function($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, Pusher) { $scope.groups = { guilds: [], public: [], diff --git a/website/client/js/controllers/partyCtrl.js b/website/client/js/controllers/partyCtrl.js index d6efe9a32a..6f2974703a 100644 --- a/website/client/js/controllers/partyCtrl.js +++ b/website/client/js/controllers/partyCtrl.js @@ -1,7 +1,7 @@ 'use strict'; -habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','Challenges','$state','$compile','Analytics','Quests','Social', - function($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, Pusher) { var user = User.user; diff --git a/website/client/js/controllers/rootCtrl.js b/website/client/js/controllers/rootCtrl.js index 3e3429da98..b2c85ceafe 100644 --- a/website/client/js/controllers/rootCtrl.js +++ b/website/client/js/controllers/rootCtrl.js @@ -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) { var user = User.user; - var initSticky = _.once(function(){ - $timeout(function () { - if (window.env.IS_MOBILE || User.user.preferences.stickyHeader === false) return; - $('.header-wrap').sticky({topSpacing:0}); - }); - }); + // Setup page once user is synced + var clearAppLoadedListener = $rootScope.$watch('appLoaded', function (after) { + if (after === true) { + // 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', function(event, toState, toParams, fromState, fromParams){ diff --git a/website/client/js/services/chatServices.js b/website/client/js/services/chatServices.js index 4020c9dd92..b6eab706a0 100644 --- a/website/client/js/services/chatServices.js +++ b/website/client/js/services/chatServices.js @@ -1,8 +1,8 @@ 'use strict'; angular.module('habitrpg') -.factory('Chat', ['$http', 'ApiUrl', 'User', - function($http, ApiUrl, User) { +.factory('Chat', ['$http', 'ApiUrl', 'User', 'Pusher', + function($http, ApiUrl, User, Pusher) { var apiV3Prefix = '/api/v3'; function getChat (groupId) { @@ -24,6 +24,7 @@ angular.module('habitrpg') url: url, data: { message: message, + pusherSocketId: Pusher.socketId, // to make sure the send doesn't get notified of it's own message } }); } diff --git a/website/client/js/services/pusherService.js b/website/client/js/services/pusherService.js new file mode 100644 index 0000000000..14198b249c --- /dev/null +++ b/website/client/js/services/pusherService.js @@ -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; + }]); diff --git a/website/client/manifest.json b/website/client/manifest.json index f9aa63bbf5..3b2756d188 100644 --- a/website/client/manifest.json +++ b/website/client/manifest.json @@ -1,6 +1,7 @@ { "app": { "js": [ + "bower_components/pusher-websocket-iso/dist/web/pusher.js", "bower_components/jquery/dist/jquery.min.js", "bower_components/jquery.cookie/jquery.cookie.js", "bower_components/pnotify/jquery.pnotify.min.js", @@ -58,6 +59,7 @@ "js/services/userNotificationsService.js", "js/services/userServices.js", "js/services/hallServices.js", + "js/services/pusherService.js", "js/filters/money.js", "js/filters/roundLargeNumbers.js", diff --git a/website/server/controllers/api-v2/auth.js b/website/server/controllers/api-v2/auth.js index cfa5c40a0d..9b27708614 100644 --- a/website/server/controllers/api-v2/auth.js +++ b/website/server/controllers/api-v2/auth.js @@ -6,7 +6,6 @@ var async = require('async'); var utils = require('../../libs/api-v2/utils'); var nconf = require('nconf'); var request = require('request'); -var FirebaseTokenGenerator = require('firebase-token-generator'); import { model as 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 /*api.setupPassport = function(router) { diff --git a/website/server/controllers/api-v2/groups.js b/website/server/controllers/api-v2/groups.js index 31a21d7dc9..961faabb0f 100644 --- a/website/server/controllers/api-v2/groups.js +++ b/website/server/controllers/api-v2/groups.js @@ -32,7 +32,6 @@ var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; var pushNotify = require('./pushNotifications'); 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(saved,ct,cb){group.save(cb)}, function(saved,ct,cb){ - firebase.updateGroupData(saved); - firebase.addUserToGroup(saved._id, user._id); saved.getTransformedData({ populateMembers: nameFields, cb, @@ -278,7 +275,6 @@ api.update = function(req, res, next) { group.save(function(err, saved){ if (err) return next(err); - firebase.updateGroupData(saved); res.sendStatus(204); }); } @@ -548,7 +544,6 @@ api.join = function(req, res, next) { group.save(cb); }, function(cb){ - firebase.addUserToGroup(group._id, user._id); group.getTransformedData({ cb, populateMembers: group.type === 'party' ? partyFields : nameFields, diff --git a/website/server/controllers/api-v2/user.js b/website/server/controllers/api-v2/user.js index b9439de09a..3812accfb6 100644 --- a/website/server/controllers/api-v2/user.js +++ b/website/server/controllers/api-v2/user.js @@ -33,7 +33,6 @@ import v3UserController from '../api-v3/user'; let i18n = shared.i18n; var api = module.exports; -var firebase = require('../../libs/api-v2/firebase'); var webhook = require('../../libs/api-v2/webhook'); const partyMembersFields = 'profile.name stats achievements items.special'; @@ -468,7 +467,6 @@ api.delete = function(req, res, next) { return user.remove(); }) .then(() => { - firebase.deleteUser(user._id); res.sendStatus(200); }) .catch(next); diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js index 692b2e0c49..d69597808d 100644 --- a/website/server/controllers/api-v3/auth.js +++ b/website/server/controllers/api-v3/auth.js @@ -18,8 +18,8 @@ import { model as Group } from '../../models/group'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { decrypt } from '../../libs/api-v3/encryption'; -import FirebaseTokenGenerator from 'firebase-token-generator'; import { send as sendEmail } from '../../libs/api-v3/email'; +import pusher from '../../libs/api-v3/pusher'; 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 * @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 * @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index ee629cebb7..5d79475df5 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -8,6 +8,7 @@ import { import _ from 'lodash'; import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/api-v3/email'; +import pusher from '../../libs/api-v3/pusher'; import nconf from 'nconf'; import Bluebird from 'bluebird'; @@ -53,8 +54,8 @@ api.getChat = { * @apiGroup Chat * * @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 {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat + * @apiParam {string} message Body parameter - message The message to post + * @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 */ @@ -83,7 +84,7 @@ api.postChat = { let lastClientMsg = req.query.previousMsg; 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()]; @@ -93,6 +94,14 @@ api.postChat = { } 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) { res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); } else { @@ -107,8 +116,8 @@ api.postChat = { * @apiName LikeChat * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message _id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {UUID} chatId The chat message _id * * @apiSuccess {Object} data The liked chat message */ @@ -154,8 +163,8 @@ api.likeChat = { * @apiName FlagChat * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {UUID} chatId The chat message id * * @apiSuccess {object} data The flagged chat message */ @@ -243,8 +252,8 @@ api.flagChat = { * @apiName ClearFlags * @apiGroup Chat * - * @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {chatId} chatId The chat message id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @apiParam {UUID} chatId The chat message id * * @apiSuccess {Object} data An empty object */ @@ -318,7 +327,7 @@ api.clearChatFlags = { * @apiName SeenChat * @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 */ @@ -354,8 +363,8 @@ api.seenChat = { * @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} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) - * @apiParam {string} chatId The chat message id + * @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted) + * @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 {Object} data An empty object when the previous message was deleted diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 3f332f3b7c..1c03d71f35 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -17,10 +17,11 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; 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 { encrypt } from '../../libs/api-v3/encryption'; import sendPushNotification from '../../libs/api-v3/pushNotifications'; +import pusher from '../../libs/api-v3/pusher'; + let api = {}; /** @@ -66,9 +67,6 @@ api.createGroup = { profile: {name: user.profile.name}, }; 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); - - firebase.updateGroupData(savedGroup); }, }; @@ -278,8 +274,6 @@ api.joinGroup = { response.leader = leader.toJSON({minimize: true}); } res.respond(200, response); - - firebase.addUserToGroup(group._id, user._id); }, }; @@ -449,7 +443,15 @@ api.removeGroupMember = { if (isInGroup === 'guild') { 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]) { member.newMessages[group._id] = undefined; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 0a546cf945..8a6db2a01d 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -13,7 +13,6 @@ import { import { model as User } from '../../models/user'; import Bluebird from 'bluebird'; import _ from 'lodash'; -import * as firebase from '../../libs/api-v3/firebase'; import * as passwordUtils from '../../libs/api-v3/password'; let api = {}; @@ -230,8 +229,6 @@ api.deleteUser = { await user.remove(); res.respond(200, {}); - - firebase.deleteUser(user._id); }, }; diff --git a/website/server/libs/api-v2/firebase.js b/website/server/libs/api-v2/firebase.js deleted file mode 100644 index 8a8d9f002c..0000000000 --- a/website/server/libs/api-v2/firebase.js +++ /dev/null @@ -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(); -}; diff --git a/website/server/libs/api-v3/firebase.js b/website/server/libs/api-v3/firebase.js deleted file mode 100644 index 324183e85f..0000000000 --- a/website/server/libs/api-v3/firebase.js +++ /dev/null @@ -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(); -} diff --git a/website/server/libs/api-v3/pusher.js b/website/server/libs/api-v3/pusher.js new file mode 100644 index 0000000000..74ac1e10a5 --- /dev/null +++ b/website/server/libs/api-v3/pusher.js @@ -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; \ No newline at end of file diff --git a/website/server/middlewares/api-v3/locals.js b/website/server/middlewares/api-v3/locals.js index e6e06e3b16..306e4b26c7 100644 --- a/website/server/middlewares/api-v3/locals.js +++ b/website/server/middlewares/api-v3/locals.js @@ -15,7 +15,7 @@ import { mods } from '../../models/user'; const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations', 'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID', 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY', - 'worldDmg', 'mods', 'IS_MOBILE']; + 'worldDmg', 'mods', 'IS_MOBILE', 'PUSHER:KEY', 'PUSHER:ENABLED']; let env = { getManifestFiles, @@ -32,9 +32,11 @@ let env = { }, }; -'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => { - env[key] = nconf.get(key); -}); +'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED' + .split(' ') + .forEach(key => { + env[key] = nconf.get(key); + }); module.exports = function locals (req, res, next) { let language = _.find(i18n.availableLanguages, {code: req.language}); diff --git a/website/server/models/group.js b/website/server/models/group.js index 0a6e2f119c..0474104fad 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -12,12 +12,12 @@ import { InternalServerError, BadRequest, } from '../libs/api-v3/errors'; -import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import Bluebird from 'bluebird'; import nconf from 'nconf'; import sendPushNotification from '../libs/api-v3/pushNotifications'; +import pusher from '../libs/api-v3/pusher'; const questScrolls = shared.content.quests; 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_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({ name: {type: String, required: true}, 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 function _cleanQuestProgress (merge) { let clean = { @@ -239,12 +233,14 @@ schema.statics.getGroups = async function getGroups (options = {}) { // Not putting into toJSON because there we can't access user schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { let toJSON = group.toJSON(); + if (!user.contributor.admin) { _.remove(toJSON.chat, chatMsg => { chatMsg.flags = {}; return chatMsg.flagCount >= 2; }); } + return toJSON; }; @@ -308,7 +304,9 @@ export function chatDefaults (msg, 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); // 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 : ''}; User.update(query, lastSeenUpdate, {multi: true}).exec(); + + return newMessage; }; 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()); } else { 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 @@ -740,8 +745,6 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { promises.push(group.update(update).exec()); } - firebase.removeUserFromGroup(group._id, user._id); - return await Bluebird.all(promises); }; diff --git a/website/server/routes/api-v2/auth.js b/website/server/routes/api-v2/auth.js index f8af1b4338..66d1bebb3a 100644 --- a/website/server/routes/api-v2/auth.js +++ b/website/server/routes/api-v2/auth.js @@ -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-username', getUserLanguage, auth.auth, auth.changeUsername); router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail); -// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken); module.exports = router; diff --git a/website/server/server.js b/website/server/server.js index f86e6c63b7..2271552b21 100644 --- a/website/server/server.js +++ b/website/server/server.js @@ -17,7 +17,6 @@ import './libs/api-v3/i18n'; // Load config files import './libs/api-v3/setupMongoose'; -import './libs/api-v3/firebase'; import './libs/api-v3/setupPassport'; // Load some schemas & models