Просмотр исходного кода

Merge branch 'master' into pr-contact-form-suggestions

Paulo Reis 10 лет назад
Родитель
Сommit
444120f8b1
45 измененных файлов с 1437 добавлено и 161 удалено
  1. 23 13
      services/web/Gruntfile.coffee
  2. 16 0
      services/web/README.md
  3. 9 4
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  4. 1 1
      services/web/app/coffee/Features/Blog/BlogController.coffee
  5. 19 2
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  6. 4 8
      services/web/app/coffee/Features/Compile/CompileController.coffee
  7. 1 1
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  8. 9 6
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  9. 5 3
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  10. 10 7
      services/web/app/coffee/Features/User/UserController.coffee
  11. 126 0
      services/web/app/coffee/Features/User/UserSessionsManager.coffee
  12. 4 1
      services/web/app/coffee/infrastructure/Server.coffee
  13. 43 10
      services/web/app/views/layout.jade
  14. 3 3
      services/web/app/views/layout/footer.jade
  15. 1 1
      services/web/app/views/project/editor/editor.jade
  16. 13 6
      services/web/app/views/project/editor/pdf.jade
  17. 4 0
      services/web/config/settings.defaults.coffee
  18. 0 1
      services/web/package.json
  19. 11 1
      services/web/public/coffee/ide.coffee
  20. 3 1
      services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
  21. 2 1
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
  22. 14 14
      services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
  23. 38 12
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  24. 2 1
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  25. 9 1
      services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
  26. 25 1
      services/web/public/coffee/ide/settings/services/settings.coffee
  27. 3 1
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  28. 1 5
      services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
  29. 18 2
      services/web/public/coffee/main/event.coffee
  30. BIN
      services/web/public/img/sprite.png
  31. 11 2
      services/web/public/stylesheets/app/editor/pdf.less
  32. 105 0
      services/web/public/stylesheets/app/sprites.less
  33. 5 0
      services/web/public/stylesheets/components/footer.less
  34. 1 0
      services/web/public/stylesheets/style.less
  35. 32 25
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  36. 19 4
      services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
  37. 1 5
      services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
  38. 1 1
      services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
  39. 11 1
      services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
  40. 9 1
      services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
  41. 15 12
      services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
  42. 484 0
      services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
  43. 253 0
      services/web/test/acceptance/coffee/SessionTests.coffee
  44. 46 3
      services/web/test/acceptance/coffee/helpers/User.coffee
  45. 27 0
      services/web/test/acceptance/coffee/helpers/redis.coffee

+ 23 - 13
services/web/Gruntfile.coffee

@@ -17,8 +17,9 @@ module.exports = (grunt) ->
 	grunt.loadNpmTasks 'grunt-contrib-watch'
 	grunt.loadNpmTasks 'grunt-parallel'
 	grunt.loadNpmTasks 'grunt-exec'
-	grunt.loadNpmTasks 'grunt-contrib-imagemin'
 	grunt.loadNpmTasks 'grunt-contrib-cssmin'
+	# grunt.loadNpmTasks 'grunt-contrib-imagemin'
+	# grunt.loadNpmTasks 'grunt-sprity'
 
 	config =
 
@@ -47,18 +48,26 @@ module.exports = (grunt) ->
 					stream:true
 
 
-		imagemin:
-			dynamic:                       
-				files: [{
-					expand: true
-					cwd: 'public/img/'
-					src: ['**/*.{png,jpg,gif}']
-					dest: 'public/img/'
-				}]
-			options:
-				interlaced:false
-				optimizationLevel: 7
-
+		# imagemin:
+		# 	dynamic:                       
+		# 		files: [{
+		# 			expand: true
+		# 			cwd: 'public/img/'
+		# 			src: ['**/*.{png,jpg,gif}']
+		# 			dest: 'public/img/'
+		# 		}]
+		# 	options:
+		# 		interlaced:false
+		# 		optimizationLevel: 7
+
+		# sprity:
+		# 	sprite:
+		# 		options:
+		# 			cssPath:"/img/"
+		# 			'style': '../../public/stylesheets/app/sprites.less'
+		# 			margin: 0
+		# 		src: ['./public/img/flags/24/*.png']
+		# 		dest: './public/img/sprite'
 
 
 		coffee:
@@ -218,6 +227,7 @@ module.exports = (grunt) ->
 				pattern: "@@RELEASE@@"
 				replacement: process.env.BUILD_NUMBER || "(unknown build)"
 
+
 			
 
 		availabletasks:

+ 16 - 0
services/web/README.md

@@ -6,9 +6,17 @@ web-sharelatex is the front-end web service of the open-source web-based collabo
 It serves all the HTML pages, CSS and javascript to the client. web-sharelatex also contains 
 a lot of logic around creating and editing projects, and account management.
 
+
 The rest of the ShareLaTeX stack, along with information about contributing can be found in the 
 [sharelatex/sharelatex](https://github.com/sharelatex/sharelatex) repository.
 
+Build process
+----------------
+
+web-sharelatex uses [Grunt](http://gruntjs.com/) to build its front-end related assets.
+
+Image processing tasks are commented out in the gruntfile and the needed packages aren't presently in the project's `package.json`. If the images need to be processed again (minified and sprited), start by fetching the packages (`npm install grunt-contrib-imagemin grunt-sprity`), then *decomment* the tasks in `Gruntfile.coffee`. After this, the tasks can be called (explicitly, via `grunt imagemin` and `grunt sprity`).
+
 Unit test status
 ----------------
 
@@ -42,3 +50,11 @@ in the `public/img/iconshock` directory found via
 [findicons.com](http://findicons.com/icon/498089/height?id=526085#)
 
 
+## Acceptance Tests
+
+To run the Acceptance tests:
+
+- set `allowPublicAccess` to true, either in the configuration file,
+  or by setting the environment variable `SHARELATEX_ALLOW_PUBLIC_ACCESS` to `true`
+- start the server (`grunt`)
+- in a separate terminal, run `grunt test:acceptance`

+ 9 - 4
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -9,11 +9,12 @@ Url = require("url")
 Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 UserHandler = require("../User/UserHandler")
+UserSessionsManager = require("../User/UserSessionsManager")
 
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
 		AuthenticationController.doLogin req.body, req, res, next
-	
+
 	doLogin: (options, req, res, next) ->
 		email = options.email?.toLowerCase()
 		password = options.password
@@ -61,7 +62,7 @@ module.exports = AuthenticationController =
 	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
 			if !req.session.user?
-				AuthenticationController._redirectToLoginOrRegisterPage(req, res) 
+				AuthenticationController._redirectToLoginOrRegisterPage(req, res)
 			else
 				req.user = req.session.user
 				return next()
@@ -92,9 +93,9 @@ module.exports = AuthenticationController =
 
 	_redirectToLoginOrRegisterPage: (req, res)->
 		if req.query.zipUrl? or req.query.project_name?
-			return AuthenticationController._redirectToRegisterPage(req, res) 
+			return AuthenticationController._redirectToRegisterPage(req, res)
 		else
-			AuthenticationController._redirectToLoginPage(req, res) 
+			AuthenticationController._redirectToLoginPage(req, res)
 
 
 	_redirectToLoginPage: (req, res) ->
@@ -132,6 +133,8 @@ module.exports = AuthenticationController =
 			isAdmin: user.isAdmin
 			email: user.email
 			referal_id: user.referal_id
+			session_created: (new Date()).toISOString()
+			ip_address: req.ip
 		# Regenerate the session to get a new sessionID (cookie value) to
 		# protect against session fixation attacks
 		oldSession = req.session
@@ -141,4 +144,6 @@ module.exports = AuthenticationController =
 			req.session[key] = value
 
 		req.session.user = lightUser
+
+		UserSessionsManager.trackSession(user, req.sessionID, () ->)
 		callback()

+ 1 - 1
services/web/app/coffee/Features/Blog/BlogController.coffee

@@ -20,7 +20,7 @@ module.exports = BlogController =
 
 		logger.log url:url, "proxying request to blog api"
 		request.get blogUrl, (err, r, data)->
-			if r?.statusCode == 404
+			if r?.statusCode == 404 or r?.statusCode == 403
 				return ErrorController.notFound(req, res, next)
 			if err?
 				return res.send 500

+ 19 - 2
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -30,12 +30,17 @@ module.exports = CollaboratorsHandler =
 	getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) ->
 		CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) ->
 			return callback(error) if error?
+			result = []
 			async.mapLimit members, 3,
 				(member, cb) ->
 					UserGetter.getUser member.id, (error, user) ->
 						return cb(error) if error?
-						return cb(null, { user: user, privilegeLevel: member.privilegeLevel })
-				callback
+						if user?
+							result.push { user: user, privilegeLevel: member.privilegeLevel }
+						cb()
+				(error) ->
+					return callback(error) if error?
+					callback null, result
 	
 	getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) ->
 		# In future if the schema changes and getting all member ids is more expensive (multiple documents)
@@ -82,6 +87,18 @@ module.exports = CollaboratorsHandler =
 				logger.error err: err, "problem removing user from project collaberators"
 			callback(err)
 	
+	removeUserFromAllProjets: (user_id, callback = (error) ->) ->
+		CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) ->
+			return callback(error) if error?
+			allProjects = readAndWriteProjects.concat(readOnlyProjects)
+			jobs = []
+			for project in allProjects
+				do (project) ->
+					jobs.push (cb) ->
+						return cb() if !project?
+						CollaboratorsHandler.removeUserFromProject project._id, user_id, cb
+			async.series jobs, callback
+	
 	addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) ->
 		emails = mimelib.parseAddresses(unparsed_email)
 		email = emails[0]?.address?.toLowerCase()

+ 4 - 8
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -29,8 +29,6 @@ module.exports = CompileController =
 				options.compiler = req.body.compiler
 			if req.body?.draft
 				options.draft = req.body.draft
-			if req.query?.isolated is "true"
-				options.isolated = true
 			logger.log {options:options, project_id:project_id, user_id:user_id}, "got compile request"
 			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits, validationProblems) ->
 				return next(error) if error?
@@ -44,17 +42,15 @@ module.exports = CompileController =
 				}
 
 	_compileAsUser: (req, callback) ->
-		# callback with user_id if isolated flag is set on request, undefined otherwise
-		isolated = req.query?.isolated is "true"
-		if isolated
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 		else
 			callback() # do a per-project compile, not per-user
 
 	_downloadAsUser: (req, callback) ->
-		# callback with user_id if isolated flag or user_id param is set on request, undefined otherwise
-		isolated = req.query?.isolated is "true" or req.params.user_id?
-		if isolated
+		# callback with user_id if per-user, undefined otherwise
+		if not Settings.disablePerUserCompiles
 			AuthenticationController.getLoggedInUserId req, callback # -> (error, user_id)
 		else
 			callback() # do a per-project compile, not per-user

+ 1 - 1
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -38,7 +38,7 @@ module.exports = CompileManager =
 							for key, value of limits
 								options[key] = value
 							# only pass user_id down to clsi if this is a per-user compile
-							compileAsUser = if options.isolated then user_id else undefined
+							compileAsUser = if Settings.disablePerUserCompiles then undefined else user_id
 							ClsiManager.sendRequest project_id, compileAsUser, options, (error, status, outputFiles, clsiServerId, validationProblems) ->
 								return callback(error) if error?
 								logger.log files: outputFiles, "output files"

+ 9 - 6
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -2,6 +2,7 @@ PasswordResetHandler = require("./PasswordResetHandler")
 RateLimiter = require("../../infrastructure/RateLimiter")
 AuthenticationController = require("../Authentication/AuthenticationController")
 UserGetter = require("../User/UserGetter")
+UserSessionsManager = require("../User/UserSessionsManager")
 logger = require "logger-sharelatex"
 
 module.exports =
@@ -47,11 +48,13 @@ module.exports =
 		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 			return next(err) if err?
 			if found
-				if req.body.login_after
-					UserGetter.getUser user_id, {email: 1}, (err, user) ->
-						return next(err) if err?
-						AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
-				else
-					res.sendStatus 200
+				UserSessionsManager.revokeAllUserSessions {_id: user_id}, [], (err) ->
+					return next(err) if err?
+					if req.body.login_after
+						UserGetter.getUser user_id, {email: 1}, (err, user) ->
+							return next(err) if err?
+							AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+					else
+						res.sendStatus 200
 			else
 				res.sendStatus 404

+ 5 - 3
services/web/app/coffee/Features/Project/ProjectDeleter.coffee

@@ -24,9 +24,11 @@ module.exports = ProjectDeleter =
 		update = {deletedByExternalDataSource: false}
 		Project.update conditions, update, {}, callback
 
-	deleteUsersProjects: (owner_id, callback)->
-		logger.log owner_id:owner_id, "deleting users projects"
-		Project.remove owner_ref:owner_id, callback
+	deleteUsersProjects: (user_id, callback)->
+		logger.log {user_id}, "deleting users projects"
+		Project.remove owner_ref:user_id, (error) ->
+			return callback(error) if error?
+			CollaboratorsHandler.removeUserFromAllProjets user_id, callback
 
 	deleteProject: (project_id, callback = (error) ->) ->
 		# archiveProject takes care of the clean-up

+ 10 - 7
services/web/app/coffee/Features/User/UserController.coffee

@@ -8,6 +8,7 @@ logger = require("logger-sharelatex")
 metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
+UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
 settings = require "settings-sharelatex"
 
@@ -81,9 +82,12 @@ module.exports = UserController =
 	logout : (req, res)->
 		metrics.inc "user.logout"
 		logger.log user: req?.session?.user, "logging out"
+		sessionId = req.sessionID
+		user = req?.session?.user
 		req.session.destroy (err)->
 			if err
 				logger.err err: err, 'error destorying session'
+			UserSessionsManager.untrackSession(user, sessionId)
 			res.redirect '/login'
 
 	register : (req, res, next = (error) ->)->
@@ -117,16 +121,15 @@ module.exports = UserController =
 					logger.log user: user, "password changed"
 					AuthenticationManager.setUserPassword user._id, newPassword1, (error) ->
 						return next(error) if error?
-						res.send
-							message:
-							  type:'success'
-							  text:'Your password has been changed'
+						UserSessionsManager.revokeAllUserSessions user, [req.sessionID], (err) ->
+							return next(err) if err?
+							res.send
+								message:
+									type:'success'
+									text:'Your password has been changed'
 			else
 				logger.log user: user, "current password wrong"
 				res.send
 					message:
 					  type:'error'
 					  text:'Your old password is wrong'
-
-
-

+ 126 - 0
services/web/app/coffee/Features/User/UserSessionsManager.coffee

@@ -0,0 +1,126 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+_ = require('underscore')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports = UserSessionsManager =
+
+	_sessionSetKey: (user) ->
+		return "UserSessions:#{user._id}"
+
+	# mimic the key used by the express sessions
+	_sessionKey: (sessionId) ->
+		return "sess:#{sessionId}"
+
+	trackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to track, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to track, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogin handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.sadd(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while adding session key to UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	untrackSession: (user, sessionId, callback=(err)-> ) ->
+		if !user?
+			logger.log {sessionId}, "no user to untrack, returning"
+			return callback(null)
+		if !sessionId?
+			logger.log {user_id: user._id}, "no sessionId to untrack, returning"
+			return callback(null)
+		logger.log {user_id: user._id, sessionId}, "onLogout handler"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		value = UserSessionsManager._sessionKey sessionId
+		rclient.multi()
+			.srem(sessionSetKey, value)
+			.expire(sessionSetKey, "#{Settings.cookieSessionLength}")
+			.exec (err, response) ->
+				if err?
+					logger.err {err, user_id: user._id, sessionSetKey}, "error while removing session key from UserSessions set"
+					return callback(err)
+				UserSessionsManager._checkSessions(user, () ->)
+				callback()
+
+	revokeAllUserSessions: (user, retain, callback=(err)->) ->
+		if !retain
+			retain = []
+		retain = retain.map((i) -> UserSessionsManager._sessionKey(i))
+		if !user
+			logger.log {}, "no user to revoke sessions for, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "revoking all existing sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			keysToDelete = _.filter(sessionKeys, (k) -> k not in retain)
+			if keysToDelete.length == 0
+				logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
+				return callback(null)
+			logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
+			rclient.multi()
+				.del(keysToDelete)
+				.srem(sessionSetKey, keysToDelete)
+				.exec (err, result) ->
+					if err?
+						logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
+						return callback(err)
+					callback(null)
+
+	touch: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user to touch sessions for, returning"
+			return callback(null)
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
+			if err?
+				logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
+				return callback(err)
+			callback(null)
+
+	_checkSessions: (user, callback=(err)->) ->
+		if !user
+			logger.log {}, "no user, returning"
+			return callback(null)
+		logger.log {user_id: user._id}, "checking sessions for user"
+		sessionSetKey = UserSessionsManager._sessionSetKey(user)
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err?
+				logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
+				return callback(err)
+			logger.log {user_id: user._id, count: sessionKeys.length}, "checking sessions for user"
+			Async.series(
+				sessionKeys.map(
+					(key) ->
+						(next) ->
+							rclient.get key, (err, val) ->
+								if err?
+									return next(err)
+								if !val?
+									logger.log {user_id: user._id, key}, ">> removing key from UserSessions set"
+									rclient.srem sessionSetKey, key, (err, result) ->
+										if err?
+											return next(err)
+										return next(null)
+								else
+									next()
+				)
+				, (err, results) ->
+					logger.log {user_id: user._id}, "done checking sessions for user"
+					return callback(err)
+			)

+ 4 - 1
services/web/app/coffee/infrastructure/Server.coffee

@@ -31,6 +31,7 @@ translations = require("translations-sharelatex").setup(Settings.i18n)
 Modules = require "./Modules"
 
 ErrorController = require "../Features/Errors/ErrorController"
+UserSessionsManager = require "../Features/User/UserSessionsManager"
 
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/node_modules/mongodb"), logger)
@@ -89,6 +90,8 @@ webRouter.use translations.setLangBasedOnDomainMiddlewear
 # Measure expiry from last request, not last login
 webRouter.use (req, res, next) ->
 	req.session.touch()
+	if req?.session?.user?
+		UserSessionsManager.touch(req.session.user, (err)->)
 	next()
 
 webRouter.use ReferalConnect.use
@@ -114,7 +117,7 @@ app.use (req, res, next) ->
 
 apiRouter.get "/status", (req, res)->
 	res.send("web sharelatex is alive")
-	
+
 profiler = require "v8-profiler"
 apiRouter.get "/profile", (req, res) ->
 	time = parseInt(req.query.time || "1000")

+ 43 - 10
services/web/app/views/layout.jade

@@ -48,17 +48,50 @@ html(itemscope, itemtype='http://schema.org/Product')
 			script(type='text/javascript').
 				window.ga = function() { console.log("Sending to GA", arguments) };
 
-		// Heap Analytics
-		if (settings.analytics && settings.analytics.heap && session && session.user)
+		// Countly Analytics
+		if (settings.analytics && settings.analytics.countly && settings.analytics.countly.token)
 			script(type="text/javascript").
-				window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var n=t.forceSSL||"https:"===document.location.protocol,a=document.createElement("script");a.type="text/javascript",a.async=!0,a.src=(n?"https:":"http:")+"//cdn.heapanalytics.com/js/heap-"+e+".js";var o=document.getElementsByTagName("script")[0];o.parentNode.insertBefore(a,o);for(var r=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["clearEventProperties","identify","setEventProperties","track","unsetEventProperty"],c=0;c<p.length;c++)heap[p[c]]=r(p[c])};
-				heap.load("#{settings.analytics.heap.token}");
-			script(type="text/javascript").
-				heap.identify({
-					handle: "#{session.user._id}",
-					email: "#{session.user.email}",
-				})
-		// End Heap Analytics
+				var Countly = Countly || {};
+				Countly.q = Countly.q || [];
+				Countly.app_key = '#{settings.analytics.countly.token}';
+				Countly.url = 'https://try.count.ly'; 
+
+				Countly.q.push(['track_sessions']);
+				Countly.q.push(['track_pageview']);
+
+				(function() {
+					var cly = document.createElement('script'); cly.type = 'text/javascript';
+					cly.async = true;
+					//enter url of script here
+					cly.src = 'https://cdnjs.cloudflare.com/ajax/libs/countly-sdk-web/16.6.0/countly.min.js';
+					cly.onload = function(){Countly.init()};
+					var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(cly, s);
+				})();
+
+			if (session && session.user)
+				script(type="text/javascript").
+					Countly.q.push(['change_id', '#{session.user._id}', true ]);
+
+					Countly.q.push(['user_details', {
+						email: '#{session.user.email}',
+						custom: {
+							userId: '#{session.user._id}',
+						}
+					}]);
+
+			if (justRegistered)
+				script(type="text/javascript").
+					Countly.q.push(['add_event',{
+						key: 'user-registered'
+					}]);
+
+			if (justLoggedIn)
+				script(type="text/javascript").
+
+					Countly.q.push(['add_event',{
+						key: 'user-logged-in'
+					}]);
+		// End countly Analytics
 				
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";

+ 3 - 3
services/web/app/views/layout/footer.jade

@@ -15,7 +15,7 @@ footer.site-footer
 							aria-expanded="false",
 							tooltip="#{translate('language')}"
 						)
-							img(src="/img/flags/24/#{currentLngCode}.png")
+							figure(class="sprite-icon sprite-icon-lang sprite-icon-#{currentLngCode}")
 
 						ul.dropdown-menu(role="menu")
 							li.dropdown-header #{translate("language")}
@@ -23,9 +23,9 @@ footer.site-footer
 								if !subdomainDetails.hide
 									li.lngOption
 										a.menu-indent(href=subdomainDetails.url+currentUrl)
-											img(src="/img/flags/24/#{subdomainDetails.lngCode}.png")
+											figure(class="sprite-icon sprite-icon-lang sprite-icon-#{subdomainDetails.lngCode}")
 											|  #{translate(subdomainDetails.lngCode)}
-										
+										//- img(src="/img/flags/24/.png")
 				each item in nav.left_footer
 					li
 						if item.url

+ 1 - 1
services/web/app/views/project/editor/editor.jade

@@ -35,7 +35,7 @@ div.full-size(
 			resize-on="layout:main:resize,layout:pdf:resize",
 			annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
 			read-only="!permissions.write",
-			on-ctrl-enter="recompile"
+			on-ctrl-enter="recompileViaKey"
 		)
 
 	.ui-layout-east

+ 13 - 6
services/web/app/views/project/editor/pdf.jade

@@ -119,7 +119,11 @@ div.full-size.pdf(ng-controller="PdfController")
 						ng-bind-html="wikiEnabled ? entry.humanReadableHint : stripHTMLFromString(entry.humanReadableHint)")
 					.card-hint-actions.clearfix
 						.card-hint-ext-link(ng-if="wikiEnabled")
-							a(ng-href="{{ entry.extraInfoURL }}", target="_blank")
+							a(
+								ng-href="{{ entry.extraInfoURL }}",
+								ng-click="trackLogHintsLearnMore()"
+								target="_blank"
+							)
 								i.fa.fa-external-link
 								|&nbsp;#{translate("log_hint_extra_info")}
 						.card-hint-feedback(
@@ -128,12 +132,12 @@ div.full-size.pdf(ng-controller="PdfController")
 						)
 							label.card-hint-feedback-label #{translate("log_hint_feedback_label")}
 							a.card-hint-feedback-positive(
-								ng-click="feedbackSent = true;"
+								ng-click="trackLogHintsPositiveFeedback(entry.ruleId); feedbackSent = true;"
 								href
 							) #{translate("answer_yes")}
 							span &nbsp;/&nbsp;
 							a.card-hint-feedback-negative(
-								ng-click="feedbackSent = true;"
+								ng-click="trackLogHintsNegativeFeedback(entry.ruleId); feedbackSent = true;"
 								href
 							) #{translate("answer_no")}
 						.card-hint-feedback(ng-show="feedbackSent")
@@ -141,7 +145,7 @@ div.full-size.pdf(ng-controller="PdfController")
 				p.entry-content(ng-show="entry.content") {{ entry.content.trim() }}
 
 		p
-			.pull-right
+			.files-dropdown-container
 				a.btn.btn-default.btn-sm(
 					href,
 					tooltip="#{translate('clear_cached_files')}",
@@ -151,12 +155,15 @@ div.full-size.pdf(ng-controller="PdfController")
 				)
 					i.fa.fa-trash-o
 				| &nbsp;
-				div.dropdown(style="display: inline-block;", dropdown)
+				div.files-dropdown(
+					ng-class="shouldDropUp ? 'dropup' : 'dropdown'"
+					dropdown
+				)
 					a.btn.btn-default.btn-sm(
 						href
 						dropdown-toggle
 					)
-						| !{translate("other_logs_and_files")} 
+						| !{translate("other_logs_and_files")}
 						span.caret
 					ul.dropdown-menu.dropdown-menu-right
 						li(ng-repeat="file in pdf.outputFiles")

+ 4 - 0
services/web/config/settings.defaults.coffee

@@ -262,6 +262,10 @@ module.exports = settings =
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
+
+	# Use a single compile directory for all users in a project
+	# (otherwise each user has their own directory)
+	# disablePerUserCompiles: true
 	
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb

+ 0 - 1
services/web/package.json

@@ -70,7 +70,6 @@
     "grunt-contrib-clean": "0.5.0",
     "grunt-contrib-coffee": "0.10.0",
     "grunt-contrib-cssmin": "^1.0.1",
-    "grunt-contrib-imagemin": "^1.0.1",
     "grunt-contrib-less": "0.9.0",
     "grunt-contrib-requirejs": "0.4.1",
     "grunt-contrib-watch": "^1.0.0",

+ 11 - 1
services/web/public/coffee/ide.coffee

@@ -42,7 +42,7 @@ define [
 	ReferencesManager
 ) ->
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, event_tracking) ->
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->
@@ -69,6 +69,16 @@ define [
 
 		$scope.chat = {}
 
+		# Tracking code.
+		$scope.$watch "ui.view", (newView, oldView) ->
+			event_tracking.sendCountly "ide-open-view-#{ newView }" if newView?
+
+		$scope.$watch "ui.chatOpen", (isOpen) ->
+			event_tracking.sendCountly "ide-open-chat" if isOpen
+
+		$scope.$watch "ui.leftMenuShown", (isOpen) ->
+			event_tracking.sendCountly "ide-open-left-menu" if isOpen
+		# End of tracking code.
 
 		window._ide = ide
 

+ 3 - 1
services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee

@@ -2,8 +2,10 @@ define [
 	"base"
 	"ace/ace"
 ], (App) ->
-	App.controller "HotkeysController", ($scope, $modal) ->
+	App.controller "HotkeysController", ($scope, $modal, event_tracking) ->
 		$scope.openHotkeysModal = ->
+			event_tracking.sendCountly "ide-open-hotkeys-modal"
+
 			$modal.open {
 				templateUrl: "hotkeysModalTemplate"
 				controller:  "HotkeysModalController"

+ 2 - 1
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee

@@ -12,7 +12,8 @@ define [
 			ruleDetails = _getRule entry.message
 
 			if (ruleDetails?)
-				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/[^a-zA-Z0-9]/g, '_').toLowerCase() if ruleDetails.regexToMatch?
+				entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.regexToMatch?
+				
 				entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint?
 				entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL?
 	

+ 14 - 14
services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee

@@ -12,7 +12,7 @@ define -> [
 		"""
 	,
 		regexToMatch: /Display math should end with \$\$/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Display_math_should_end_with_$$"
 		humanReadableHint: """
 			You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ \u2026 $$. Check that the number of $s match around each math expression.
 		"""
@@ -24,67 +24,67 @@ define -> [
 		"""
 	,
 		regexToMatch: /(undefined )?[rR]eference(s)?.+(undefined)?/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_undefined_references"
 		humanReadableHint: """
 			You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.
 		"""
 	,
 		regexToMatch: /Citation .+ on page .+ undefined on input line .+/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX"
 		humanReadableHint: """
 			You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.
 		"""
 	,
 		regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/There_were_multiply-defined_labels"
 		humanReadableHint: """
 			You have used the same label more than once. Check that each \\label{...} labels only one item.
 		"""
 	,
 		regexToMatch: /`!?h' float specifier changed to `!?ht'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27"
 		humanReadableHint: """
 			The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">float package</a>.
 		"""
 	,
 		regexToMatch: /No positions in optional float specifier/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
 		humanReadableHint: """
-			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
+			You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 		"""
 	,
 		regexToMatch: /Undefined control sequence/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/Undefined_control_sequence"
 		humanReadableHint: """
 			The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.
 		"""
 	,
 		regexToMatch: /File .+ not found/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX"
 		humanReadableHint: """
 			The compiler cannot find the file you want to include. Make sure that you have <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Including_images_in_ShareLaTeX\">uploaded the file</a> and <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.\">specified the file location correctly</a>.
 		"""
 	,
 		regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX"
 		humanReadableHint: """
 			The compiler does not recognise the file type of one of your images. Make sure you are using a <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.\">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.
 		"""
 	,
 		regexToMatch: /LaTeX Error: Unknown float option `H'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27"
 		humanReadableHint: """
 			The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.
 		"""
 	,
-		regexToMatch: /LaTeX Error: Unknown float option `.+'/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27."
+		regexToMatch: /LaTeX Error: Unknown float option `q'/
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27"
 		humanReadableHint: """
 			You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
 		"""
 	,
 		regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/
-		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode."
+		extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode"
 		humanReadableHint: """
 			You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ \u2026 $ or \\begin{math} \u2026 \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.
 		"""

+ 38 - 12
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -15,6 +15,33 @@ define [
 		$scope.shouldShowLogs = false
 		$scope.wikiEnabled = window.wikiEnabled;
 
+		# view logic to check whether the files dropdown should "drop up" or "drop down"
+		$scope.shouldDropUp = false
+
+		logsContainerEl	= document.querySelector ".pdf-logs"
+		filesDropdownEl	= logsContainerEl?.querySelector ".files-dropdown"
+
+		# get the top coordinate of the files dropdown as a ratio (to the logs container height)
+		# logs container supports scrollable content, so it's possible that ratio > 1.
+		getFilesDropdownTopCoordAsRatio = () ->
+			 filesDropdownEl?.getBoundingClientRect().top / logsContainerEl?.getBoundingClientRect().height
+
+		$scope.$watch "shouldShowLogs", (shouldShow) ->
+			if shouldShow
+				$scope.$applyAsync () -> 
+					$scope.shouldDropUp = getFilesDropdownTopCoordAsRatio() > 0.65
+
+		# log hints tracking
+		$scope.trackLogHintsLearnMore = () ->
+			event_tracking.sendCountly "logs-hints-learn-more"
+
+		trackLogHintsFeedback = (isPositive, hintId) ->
+			event_tracking.send "log-hints", (if isPositive then "feedback-positive" else "feedback-negative"), hintId
+			event_tracking.sendCountly (if isPositive then "log-hints-feedback-positive" else "log-hints-feedback-negative"), { hintId }
+
+		$scope.trackLogHintsPositiveFeedback = (hintId) -> trackLogHintsFeedback true, hintId
+		$scope.trackLogHintsNegativeFeedback = (hintId) -> trackLogHintsFeedback false, hintId
+
 		if ace.require("ace/lib/useragent").isMac
 			$scope.modifierKey = "Cmd"
 		else
@@ -50,8 +77,6 @@ define [
 			params = {}
 			if options.isAutoCompile
 				params["auto_compile"]=true
-			if perUserCompile # send ?isolated=true for per-user compiles
-				params["isolated"] = true
 			return $http.post url, {
 				rootDoc_id: options.rootDocOverride_id or null
 				draft: $scope.draft
@@ -125,9 +150,6 @@ define [
 				# convert the qs hash into a query string and append it
 				$scope.pdf.qs = createQueryString qs
 				$scope.pdf.url += $scope.pdf.qs
-				# special case for the download url
-				if perUserCompile
-					qs.isolated = true
 				# Save all downloads as files
 				qs.popupDownload = true
 				$scope.pdf.downloadUrl = "/project/#{$scope.project_id}/output/output.pdf" + createQueryString(qs)
@@ -147,8 +169,6 @@ define [
 					else
 						file.name = file.path
 					qs = {}
-					if perUserCompile
-						qs.isolated = true
 					if response.clsiServerId?
 						qs.clsiserverid = response.clsiServerId
 					file.url = "/project/#{project_id}/output/#{file.path}" +	createQueryString qs
@@ -237,7 +257,7 @@ define [
 			return null
 
 		normalizeFilePath = (path) ->
-			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
+			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/, "")
 			path = path.replace(/^\/compile\//, "")
 
 			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
@@ -248,6 +268,9 @@ define [
 
 		$scope.recompile = (options = {}) ->
 			return if $scope.pdf.compiling
+
+			event_tracking.sendCountlySampled "editor-recompile-sampled", options
+
 			$scope.pdf.compiling = true
 
 			ide.$scope.$broadcast("flush-changes")
@@ -267,6 +290,9 @@ define [
 
 		# This needs to be public.
 		ide.$scope.recompile = $scope.recompile
+		# This method is a simply wrapper and exists only for tracking purposes.
+		ide.$scope.recompileViaKey = () ->
+			$scope.recompile { keyShortcut: true }
 
 		$scope.clearCache = () ->
 			$http {
@@ -274,13 +300,13 @@ define [
 				method: "DELETE"
 				params:
 					clsiserverid:ide.clsiServerId
-					isolated: perUserCompile
 				headers:
 					"X-Csrf-Token": window.csrfToken
 			}
 
 		$scope.toggleLogs = () ->
 			$scope.shouldShowLogs = !$scope.shouldShowLogs
+			event_tracking.sendCountly "ide-open-logs" if $scope.shouldShowLogs
 
 		$scope.showPdf = () ->
 			$scope.pdf.view = "pdf"
@@ -288,6 +314,7 @@ define [
 
 		$scope.toggleRawLog = () ->
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
+			event_tracking.sendCountly "logs-view-raw" if $scope.pdf.showRawLog
 
 		$scope.openClearCacheModal = () ->
 			modalInstance = $modal.open(
@@ -361,7 +388,6 @@ define [
 							line: row + 1
 							column: column
 							clsiserverid:ide.clsiServerId
-							isolated: perUserCompile
 						}
 					})
 					.success (data) ->
@@ -407,7 +433,6 @@ define [
 							h: h.toFixed(2)
 							v: v.toFixed(2)
 							clsiserverid:ide.clsiServerId
-							isolated: perUserCompile
 						}
 					})
 					.success (data) ->
@@ -442,8 +467,9 @@ define [
 					ide.editorManager.openDoc(doc, gotoLine: line)
 	]
 
-	App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
+	App.controller "PdfLogEntryController", ["$scope", "ide", "event_tracking", ($scope, ide, event_tracking) ->
 		$scope.openInEditor = (entry) ->
+			event_tracking.sendCountly 'logs-jump-to-location'
 			entity = ide.fileTreeManager.findEntityByPath(entry.file)
 			return if !entity? or entity.type != "doc"
 			if entry.line?

+ 2 - 1
services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee

@@ -12,7 +12,8 @@ define [
 
 			constructor: (@url, @options) ->
 				# PDFJS.disableFontFace = true  # avoids repaints, uses worker more
-				# PDFJS.disableAutoFetch = true # enable this to prevent loading whole file
+				if @options.disableAutoFetch
+					PDFJS.disableAutoFetch = true # prevent loading whole file
 				# PDFJS.disableStream
 				# PDFJS.disableRange
 				@scale = @options.scale || 1

+ 9 - 1
services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee

@@ -27,13 +27,21 @@ define [
 			$scope.document.destroy() if $scope.document?
 			$scope.loadCount = if $scope.loadCount? then $scope.loadCount + 1 else 1
 			# TODO need a proper url manipulation library to add to query string
-			$scope.document = new PDFRenderer($scope.pdfSrc + '&pdfng=true' , {
+			url = $scope.pdfSrc
+			# add 'pdfng=true' to show that we are using the angular pdfjs viewer
+			queryStringExists = url.match(/\?/)
+			url = url + (if not queryStringExists then '?' else '&') + 'pdfng=true'
+			# for isolated compiles, load the pdf on-demand because nobody will overwrite it
+			onDemandLoading = window.location?.search?.match(/isolated=true/)?
+			$scope.document = new PDFRenderer(url, {
 				scale: 1,
+				disableAutoFetch: if onDemandLoading then true else undefined
 				navigateFn: (ref) ->
 					# this function captures clicks on the annotation links
 					$scope.navigateTo = ref
 					$scope.$apply()
 				progressCallback: (progress) ->
+					return if onDemandLoading is true # don't show progress for on-demand page loading
 					$scope.$emit 'progress', progress
 				loadedCallback: () ->
 					$scope.$emit 'loaded'

+ 25 - 1
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -1,19 +1,43 @@
 define [
 	"base"
 ], (App) ->
-	App.factory "settings", ["ide", (ide) ->
+	App.factory "settings", ["ide", "event_tracking", (ide, event_tracking) ->
 		return {
 			saveSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				ide.$http.post "/user/settings", data
 
+
 			saveProjectSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-setting-changed", { changedSetting, changedSettingVal}
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings", data
+
 				
 			saveProjectAdminSettings: (data) ->
+				# Tracking code.
+				for key in Object.keys(data)
+					changedSetting = key
+					changedSettingVal = data[key]
+					event_tracking.sendCountly "project-admin-setting-changed", { changedSetting, changedSettingVal }
+				# End of tracking code.
+				
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings/admin", data
+
 				
 		}
 	]

+ 3 - 1
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -1,8 +1,10 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
+	App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) ->
 		$scope.openShareProjectModal = () ->
+			event_tracking.sendCountly "ide-open-share-modal"
+
 			$modal.open(
 				templateUrl: "shareProjectModalTemplate"
 				controller:  "ShareProjectModalController"

+ 1 - 5
services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee

@@ -5,15 +5,11 @@ define [
 		$scope.status = 
 			loading:true
 
-		# enable per-user containers by default
-		perUserCompile = true
-
 		opts =
 			url:"/project/#{ide.project_id}/wordcount"
 			method:"GET"
 			params:
 				clsiserverid:ide.clsiServerId
-				isolated: perUserCompile
 		$http opts
 			.success (data) ->
 				$scope.status.loading = false
@@ -22,4 +18,4 @@ define [
 				$scope.status.error = true
 
 		$scope.cancel = () ->
-			$modalInstance.dismiss('cancel')
+			$modalInstance.dismiss('cancel')

+ 18 - 2
services/web/public/coffee/main/event.coffee

@@ -6,10 +6,26 @@ define [
 		return {
 			send: (category, action, label, value)->
 				ga('send', 'event', category, action, label, value)
-				event_name = "#{action}-#{category}"
-				window?.heap?.track?(event_name, {label, value})
+
+			sendCountly: (key, segmentation) ->
+				eventData = { key }
+				eventData.segmentation = segmentation if segmentation?				
+				Countly?.q.push([ "add_event", eventData ]);
+
+			sendCountlySampled: (key, segmentation) ->
+				@sendCountly key, segmentation if Math.random() < .01 
 		}
 
+	# App.directive "countlyTrack", () ->
+	# 	return {
+	# 		restrict: "A"
+	# 		scope: false,
+	# 		link: (scope, el, attrs) ->
+	# 			eventKey  = attrs.countlyTrack
+	# 			if (eventKey?)
+	# 				el.on "click", () ->
+	# 					console.log eventKey
+	# 	}
 
 	#header
 	$('.navbar a').on "click", (e)->

BIN
services/web/public/img/sprite.png


+ 11 - 2
services/web/public/stylesheets/app/editor/pdf.less

@@ -122,7 +122,7 @@
 			font-weight: 700;
 
 			.fa {
-				display: none;
+				opacity: 0;
 			}
 		}
 		.entry-message {
@@ -138,7 +138,7 @@
 		&:hover .line-no {
 			color: inherit;
 			.fa {
-				display: inline-block;
+				opacity: 1;
 			}
 		}
 
@@ -284,3 +284,12 @@
 		margin-top: 20px;
 	}
 }
+
+.files-dropdown-container {
+	.pull-right();
+	position: relative;
+}
+
+	.files-dropdown {
+		display: inline-block;
+	}

+ 105 - 0
services/web/public/stylesheets/app/sprites.less

@@ -0,0 +1,105 @@
+
+.sprite-icon {
+  background-image: url('/img/sprite.png');
+}
+
+.sprite-icon-ko {
+  background-position: -0px -0px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-cn {
+  background-position: -0px -24px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-da {
+  background-position: -0px -48px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-de {
+  background-position: -0px -72px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-en {
+  background-position: -0px -96px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-es {
+  background-position: -0px -120px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-fi {
+  background-position: -0px -144px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-fr {
+  background-position: -0px -168px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-it {
+  background-position: -0px -192px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-ja {
+  background-position: -0px -216px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-cs {
+  background-position: -0px -240px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-nl {
+  background-position: -0px -264px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-no {
+  background-position: -0px -288px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-pl {
+  background-position: -0px -312px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-pt {
+  background-position: -0px -336px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-ru {
+  background-position: -0px -360px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-sv {
+  background-position: -0px -384px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-tr {
+  background-position: -0px -408px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-uk {
+  background-position: -0px -432px;
+  width: 24px;
+  height: 24px;
+}
+.sprite-icon-zh-CN {
+  background-position: -0px -456px;
+  width: 24px;
+  height: 24px;
+}

+ 5 - 0
services/web/public/stylesheets/components/footer.less

@@ -23,3 +23,8 @@ footer.site-footer {
 		}
 	}
 }
+
+.sprite-icon-lang {
+	display: inline-block;
+	vertical-align: middle;
+}

+ 1 - 0
services/web/public/stylesheets/style.less

@@ -73,3 +73,4 @@
 @import "app/wiki.less";
 @import "app/translations.less";
 @import "app/contact-us.less";
+@import "app/sprites.less";

+ 32 - 25
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -21,6 +21,10 @@ describe "AuthenticationController", ->
 			"../User/UserHandler": @UserHandler = {setupLoginData:sinon.stub()}
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"settings-sharelatex": {}
+			"../User/UserSessionsManager": @UserSessionsManager =
+				trackSession: sinon.stub()
+				untrackSession: sinon.stub()
+				revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
 		@user =
 			_id: ObjectId()
 			email: @email = "USER@example.com"
@@ -57,7 +61,7 @@ describe "AuthenticationController", ->
 						@res.statusCode.should.equal 429
 						done()
 				@AuthenticationController.login(@req, @res)
-			
+
 		describe 'when the user is authenticated', ->
 			beforeEach ->
 				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
@@ -76,7 +80,7 @@ describe "AuthenticationController", ->
 				@AuthenticationController.establishUserSession
 					.calledWith(@req, @user)
 					.should.equal true
-					
+
 			it "should set res.session.justLoggedIn", ->
 				@req.session.justLoggedIn.should.equal true
 
@@ -95,7 +99,7 @@ describe "AuthenticationController", ->
 				@logger.log
 					.calledWith(email: @email.toLowerCase(), user_id: @user._id.toString(), "successful log in")
 					.should.equal true
-				
+
 
 		describe 'when the user is not authenticated', ->
 			beforeEach ->
@@ -112,7 +116,7 @@ describe "AuthenticationController", ->
 
 			it "should not establish a session", ->
 				@AuthenticationController.establishUserSession.called.should.equal false
-			
+
 			it "should not setup the user data in the background", ->
 				@UserHandler.setupLoginData.called.should.equal false
 
@@ -137,12 +141,12 @@ describe "AuthenticationController", ->
 	describe "getLoggedInUserId", ->
 
 		beforeEach ->
-			@req = 
+			@req =
 				session :{}
 
 		it "should return the user id from the session", (done)->
 			@user_id = "2134"
-			@req.session.user = 
+			@req.session.user =
 				_id:@user_id
 			@AuthenticationController.getLoggedInUserId @req, (err, user_id)=>
 				expect(user_id).to.equal @user_id
@@ -168,7 +172,7 @@ describe "AuthenticationController", ->
 	describe "getLoggedInUser", ->
 		beforeEach ->
 			@UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user)
-			
+
 		describe "with an established session", ->
 			beforeEach ->
 				@req.session =
@@ -189,7 +193,7 @@ describe "AuthenticationController", ->
 				_id: "user-id-123"
 				email: "user@sharelatex.com"
 			@middleware = @AuthenticationController.requireLogin()
-			
+
 		describe "when the user is logged in", ->
 			beforeEach ->
 				@req.session =
@@ -219,13 +223,13 @@ describe "AuthenticationController", ->
 		beforeEach ->
 			@req.headers = {}
 			@AuthenticationController.httpAuth = sinon.stub()
-		
+
 		describe "with white listed url", ->
 			beforeEach ->
 				@AuthenticationController.addEndpointToLoginWhitelist "/login"
 				@req._parsedUrl.pathname = "/login"
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
 				@next.called.should.equal true
 
@@ -235,9 +239,9 @@ describe "AuthenticationController", ->
 				@req._parsedUrl.pathname = "/login"
 				@req.url = "/login?query=something"
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
-				@next.called.should.equal true		
+				@next.called.should.equal true
 
 		describe "with http auth", ->
 			beforeEach ->
@@ -248,20 +252,20 @@ describe "AuthenticationController", ->
 				@AuthenticationController.httpAuth
 					.calledWith(@req, @res, @next)
 					.should.equal true
-			
+
 		describe "with a user session", ->
 			beforeEach ->
 				@req.session =
 					user: {"mock": "user"}
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-			
+
 			it "should call next() directly", ->
 				@next.called.should.equal true
-			
+
 		describe "with no login credentials", ->
 			beforeEach ->
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
-				
+
 			it "should redirect to the /login page", ->
 				@res.redirectedTo.should.equal "/login"
 
@@ -274,7 +278,7 @@ describe "AuthenticationController", ->
 			@req.query = {}
 
 		describe "they have come directly to the url", ->
-			beforeEach -> 
+			beforeEach ->
 				@req.query = {}
 				@middleware(@req, @res, @next)
 
@@ -284,7 +288,7 @@ describe "AuthenticationController", ->
 
 		describe "they have come via a templates link", ->
 
-			beforeEach -> 
+			beforeEach ->
 				@req.query.zipUrl = "something"
 				@middleware(@req, @res, @next)
 
@@ -293,8 +297,8 @@ describe "AuthenticationController", ->
 				@AuthenticationController._redirectToLoginPage.calledWith(@req, @res).should.equal false
 
 		describe "they have been invited to a project", ->
-			
-			beforeEach -> 
+
+			beforeEach ->
 				@req.query.project_name = "something"
 				@middleware(@req, @res, @next)
 
@@ -333,7 +337,7 @@ describe "AuthenticationController", ->
 		beforeEach ->
 			@UserUpdater.updateUser = sinon.stub().callsArg(2)
 			@AuthenticationController._recordSuccessfulLogin(@user._id, @callback)
-			
+
 		it "should increment the user.login.success metric", ->
 			@Metrics.inc
 				.calledWith("user.login.success")
@@ -349,7 +353,7 @@ describe "AuthenticationController", ->
 	describe "_recordFailedLogin", ->
 		beforeEach ->
 			@AuthenticationController._recordFailedLogin(@callback)
-			
+
 		it "should increment the user.login.failed metric", ->
 			@Metrics.inc
 				.calledWith("user.login.failed")
@@ -365,6 +369,7 @@ describe "AuthenticationController", ->
 				destroy : sinon.stub()
 			@req.sessionStore =
 				generate: sinon.stub()
+			@req.ip = "1.2.3.4"
 			@AuthenticationController.establishUserSession @req, @user, @callback
 
 		it "should set the session user to a basic version of the user", ->
@@ -374,13 +379,15 @@ describe "AuthenticationController", ->
 			@req.session.user.last_name.should.equal @user.last_name
 			@req.session.user.referal_id.should.equal @user.referal_id
 			@req.session.user.isAdmin.should.equal @user.isAdmin
-			
+			@req.session.user.ip_address.should.equal @req.ip
+			expect(typeof @req.session.user.ip_address).to.equal 'string'
+			expect(typeof @req.session.user.session_created).to.equal 'string'
+
 		it "should destroy the session", ->
 			@req.session.destroy.called.should.equal true
-			
+
 		it "should regenerate the session to protect against session fixation", ->
 			@req.sessionStore.generate.calledWith(@req).should.equal true
 
 		it "should return the callback", ->
 			@callback.called.should.equal true
-

+ 19 - 4
services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee

@@ -77,17 +77,19 @@ describe "CollaboratorsHandler", ->
 				{ id: "read-only-ref-2", privilegeLevel: "readOnly" }
 				{ id: "read-write-ref-1", privilegeLevel: "readAndWrite" }
 				{ id: "read-write-ref-2", privilegeLevel: "readAndWrite" }
+				{ id: "doesnt-exist", privilegeLevel: "readAndWrite" }
 			])
 			@UserGetter.getUser = sinon.stub()
 			@UserGetter.getUser.withArgs("read-only-ref-1").yields(null, { _id: "read-only-ref-1" })
 			@UserGetter.getUser.withArgs("read-only-ref-2").yields(null, { _id: "read-only-ref-2" })
 			@UserGetter.getUser.withArgs("read-write-ref-1").yields(null, { _id: "read-write-ref-1" })
 			@UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" })
+			@UserGetter.getUser.withArgs("doesnt-exist").yields(null, null)
 			@CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback
 		
 		it "should return an array of members with their privilege levels", ->
 			@callback
-				.calledWith(undefined, [
+				.calledWith(null, [
 					{ user: { _id: "read-only-ref-1" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-only-ref-2" }, privilegeLevel: "readOnly" }
 					{ user: { _id: "read-write-ref-1" }, privilegeLevel: "readAndWrite" }
@@ -274,6 +276,19 @@ describe "CollaboratorsHandler", ->
 			it "should not add any users to the proejct", ->
 				@CollaboratorHandler.addUserIdToProject.called.should.equal false
 
-
-
-
+	describe "removeUserFromAllProjects", ->
+		beforeEach (done) ->
+			@CollaboratorHandler.getProjectsUserIsCollaboratorOf = sinon.stub()
+			@CollaboratorHandler.getProjectsUserIsCollaboratorOf.withArgs(@user_id, { _id: 1 }).yields(
+				null,
+				[ { _id: "read-and-write-0" }, { _id: "read-and-write-1" }, null ],
+				[ { _id: "read-only-0" }, { _id: "read-only-1" }, null ]
+			)
+			@CollaboratorHandler.removeUserFromProject = sinon.stub().yields()
+			@CollaboratorHandler.removeUserFromAllProjets @user_id, done
+		
+		it "should remove the user from each project", ->
+			for project_id in ["read-and-write-0", "read-and-write-1", "read-only-0", "read-only-1"]
+				@CollaboratorHandler.removeUserFromProject
+					.calledWith(project_id, @user_id)
+					.should.equal true

+ 1 - 5
services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee

@@ -139,7 +139,7 @@ describe "CompileController", ->
 					.should.equal true
 
 			it "should proxy the PDF from the CLSI", ->
-				@CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/output/output.pdf", @req, @res, @next).should.equal true
+				@CompileController.proxyToClsi.calledWith(@project_id, "/project/#{@project_id}/user/#{@user_id}/output/output.pdf", @req, @res, @next).should.equal true
 
 		describe "when the pdf is not going to be used in pdfjs viewer", ->
 
@@ -338,8 +338,6 @@ describe "CompileController", ->
 			@req =
 				params:
 					project_id:@project_id
-				query:
-					isolated: "true"
 			@CompileManager.compile.callsArgWith(3)
 			@CompileController.proxyToClsi = sinon.stub()
 			@res = 
@@ -362,8 +360,6 @@ describe "CompileController", ->
 			@CompileManager.wordCount = sinon.stub().callsArgWith(3, null, {content:"body"})
 			@req.params =
 				Project_id: @project_id
-			@req.query =
-				isolated: "true"
 			@res.send = sinon.stub()
 			@res.contentType = sinon.stub()
 			@CompileController.wordCount @req, @res, @next

+ 1 - 1
services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee

@@ -71,7 +71,7 @@ describe "CompileManager", ->
 
 			it "should run the compile with the compile limits", ->
 				@ClsiManager.sendRequest
-					.calledWith(@project_id, undefined, {
+					.calledWith(@project_id, @user_id, {
 						timeout: @limits.timeout
 					})
 					.should.equal true

+ 11 - 1
services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee

@@ -17,6 +17,8 @@ describe "PasswordResetController", ->
 			setNewUserPassword:sinon.stub()
 		@RateLimiter =
 			addCount: sinon.stub()
+		@UserSessionsManager =
+			revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
 		@PasswordResetController = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"./PasswordResetHandler":@PasswordResetHandler
@@ -24,6 +26,7 @@ describe "PasswordResetController", ->
 			"../../infrastructure/RateLimiter":@RateLimiter
 			"../Authentication/AuthenticationController": @AuthenticationController = {}
 			"../User/UserGetter": @UserGetter = {}
+			"../User/UserSessionsManager": @UserSessionsManager
 
 		@email = "bob@bob.com "
 		@token = "my security token that was emailed to me"
@@ -134,7 +137,14 @@ describe "PasswordResetController", ->
 				@req.session.should.not.have.property 'resetToken'
 				done()
 			@PasswordResetController.setNewUserPassword @req, @res
-		
+
+		it 'should clear sessions', (done) ->
+			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true)
+			@res.sendStatus = (code)=>
+				@UserSessionsManager.revokeAllUserSessions.callCount.should.equal 1
+				done()
+			@PasswordResetController.setNewUserPassword @req, @res
+
 		it "should login user if login_after is set", (done) ->
 			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
 			@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")

+ 9 - 1
services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee

@@ -27,13 +27,15 @@ describe 'ProjectDeleter', ->
 			removeProjectFromAllTags: sinon.stub().callsArgWith(2)
 		@ProjectGetter =
 			getProject:sinon.stub()
+		@CollaboratorsHandler =
+			removeUserFromAllProjets: sinon.stub().yields()
 		@deleter = SandboxedModule.require modulePath, requires:
 			"../Editor/EditorController": @editorController
 			'../../models/Project':{Project:@Project}
 			'../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler
 			"../Tags/TagsHandler":@TagsHandler
 			"../FileStore/FileStoreHandler": @FileStoreHandler = {}
-			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {}
+			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
 			"./ProjectGetter": @ProjectGetter
 			'logger-sharelatex':
 				log:->
@@ -74,6 +76,12 @@ describe 'ProjectDeleter', ->
 				@Project.remove.calledWith(owner_ref:user_id).should.equal true
 				done()
 
+		it "should remove all the projects the user is a collaborator of", (done)->
+			user_id = 1234
+			@deleter.deleteUsersProjects user_id, =>
+				@CollaboratorsHandler.removeUserFromAllProjets.calledWith(user_id).should.equal true
+				done()
+
 	describe "deleteProject", ->
 		beforeEach (done) ->
 			@project_id = "mock-project-id-123"

+ 15 - 12
services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

@@ -19,9 +19,9 @@ describe "UserController", ->
 			save:sinon.stub().callsArgWith(0)
 			ace:{}
 
-		@UserDeleter = 
+		@UserDeleter =
 			deleteUser: sinon.stub().callsArgWith(1)
-		@UserLocator = 
+		@UserLocator =
 			findById: sinon.stub().callsArgWith(1, null, @user)
 		@User =
 			findById: sinon.stub().callsArgWith(1, null, @user)
@@ -36,14 +36,18 @@ describe "UserController", ->
 			setUserPassword: sinon.stub()
 		@ReferalAllocator =
 			allocate:sinon.stub()
-		@SubscriptionDomainHandler = 
+		@SubscriptionDomainHandler =
 			autoAllocate:sinon.stub()
 		@UserUpdater =
 			changeEmailAddress:sinon.stub()
 		@settings =
 			siteUrl: "sharelatex.example.com"
-		@UserHandler = 
+		@UserHandler =
 			populateGroupLicenceInvite:sinon.stub().callsArgWith(1)
+		@UserSessionsManager =
+			trackSession: sinon.stub()
+			untrackSession: sinon.stub()
+			revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
 		@UserController = SandboxedModule.require modulePath, requires:
 			"./UserLocator": @UserLocator
 			"./UserDeleter": @UserDeleter
@@ -56,14 +60,15 @@ describe "UserController", ->
 			"../Referal/ReferalAllocator":@ReferalAllocator
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"./UserHandler":@UserHandler
+			"./UserSessionsManager": @UserSessionsManager
 			"settings-sharelatex": @settings
-			"logger-sharelatex": 
+			"logger-sharelatex":
 				log:->
 				err:->
 			"../../infrastructure/Metrics": inc:->
 
-		@req = 
-			session: 
+		@req =
+			session:
 				destroy:->
 				user :
 					_id : @user_id
@@ -198,12 +203,12 @@ describe "UserController", ->
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail = sinon.stub().callsArgWith(1, null, @user, @url = "mock/url")
 			@req.body.email = @user.email = @email = "email@example.com"
 			@UserController.register @req, @res
-		
+
 		it "should register the user and send them an email", ->
 			@UserRegistrationHandler.registerNewUserAndSendActivationEmail
 				.calledWith(@email)
 				.should.equal true
-		
+
 		it "should return the user and activation url", ->
 			@res.json
 				.calledWith({
@@ -233,7 +238,7 @@ describe "UserController", ->
 			@res.send = =>
 				@AuthenticationManager.setUserPassword.called.should.equal false
 				done()
-			@UserController.changePassword @req, @res			
+			@UserController.changePassword @req, @res
 
 		it "should set the new password if they do match", (done)->
 			@AuthenticationManager.authenticate.callsArgWith(2, null, @user)
@@ -245,5 +250,3 @@ describe "UserController", ->
 				@AuthenticationManager.setUserPassword.calledWith(@user._id, "newpass").should.equal true
 				done()
 			@UserController.changePassword @req, @res
-
-

+ 484 - 0
services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee

@@ -0,0 +1,484 @@
+sinon = require('sinon')
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = "../../../../app/js/Features/User/UserSessionsManager.js"
+SandboxedModule = require('sandboxed-module')
+
+describe 'UserSessionsManager', ->
+
+	beforeEach ->
+		@user =
+			_id: "abcd"
+			email: "user@example.com"
+		@sessionId = 'some_session_id'
+
+		@rclient =
+			multi:    sinon.stub()
+			exec:     sinon.stub()
+			get:      sinon.stub()
+			del:      sinon.stub()
+			sadd:     sinon.stub()
+			srem:     sinon.stub()
+			smembers: sinon.stub()
+			expire:   sinon.stub()
+		@rclient.multi.returns(@rclient)
+		@rclient.get.returns(@rclient)
+		@rclient.del.returns(@rclient)
+		@rclient.sadd.returns(@rclient)
+		@rclient.srem.returns(@rclient)
+		@rclient.smembers.returns(@rclient)
+		@rclient.expire.returns(@rclient)
+		@rclient.exec.callsArgWith(0, null)
+
+		@redis =
+			createClient: () => @rclient
+		@logger =
+			err:   sinon.stub()
+			error: sinon.stub()
+			log:   sinon.stub()
+		@settings =
+			redis:
+				web: {}
+		@UserSessionsManager = SandboxedModule.require modulePath, requires:
+			"redis-sharelatex":    @redis
+			"logger-sharelatex":   @logger
+			"settings-sharelatex": @settings
+
+	describe '_sessionSetKey', ->
+
+		it 'should build the correct key', ->
+			result = @UserSessionsManager._sessionSetKey(@user)
+			result.should.equal 'UserSessions:abcd'
+
+	describe '_sessionKey', ->
+
+		it 'should build the correct key', ->
+			result = @UserSessionsManager._sessionKey(@sessionId)
+			result.should.equal 'sess:some_session_id'
+
+	describe 'trackSession', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager.trackSession @user, @sessionId, callback
+			@rclient.exec.callsArgWith(0, null)
+			@_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+		afterEach ->
+			@_checkSessions.restore()
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.multi.callCount.should.equal 1
+				@rclient.sadd.callCount.should.equal 1
+				@rclient.expire.callCount.should.equal 1
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		it 'should call _checkSessions', (done) ->
+			@call (err) =>
+				@_checkSessions.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.trackSession null, @sessionId, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.sadd.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no sessionId is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.trackSession @user, null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.sadd.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+	describe 'untrackSession', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager.untrackSession @user, @sessionId, callback
+			@rclient.exec.callsArgWith(0, null)
+			@_checkSessions = sinon.stub(@UserSessionsManager, '_checkSessions').returns(null)
+
+		afterEach ->
+			@_checkSessions.restore()
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal undefined
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.multi.callCount.should.equal 1
+				@rclient.srem.callCount.should.equal 1
+				@rclient.expire.callCount.should.equal 1
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		it 'should call _checkSessions', (done) ->
+			@call (err) =>
+				@_checkSessions.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.untrackSession null, @sessionId, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+		describe 'when no sessionId is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.untrackSession @user, null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.multi.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.expire.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+			it 'should not call _checkSessions', (done) ->
+				@call (err) =>
+					@_checkSessions.callCount.should.equal 0
+					done()
+
+	##
+	describe 'revokeAllUserSessions', ->
+
+		beforeEach ->
+			@sessionKeys = ['sess:one', 'sess:two']
+			@retain = []
+			@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+			@rclient.exec.callsArgWith(0, null)
+			@call = (callback) =>
+				@UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal null
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.smembers.callCount.should.equal 1
+				@rclient.multi.callCount.should.equal 1
+
+				@rclient.del.callCount.should.equal 1
+				expect(@rclient.del.firstCall.args[0]).to.deep.equal @sessionKeys
+
+				@rclient.srem.callCount.should.equal 1
+				expect(@rclient.srem.firstCall.args[1]).to.deep.equal @sessionKeys
+
+				@rclient.exec.callCount.should.equal 1
+				done()
+
+		describe 'when a session is retained', ->
+
+			beforeEach ->
+				@sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four']
+				@retain = ['two']
+				@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+				@rclient.exec.callsArgWith(0, null)
+				@call = (callback) =>
+					@UserSessionsManager.revokeAllUserSessions @user, @retain, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.multi.callCount.should.equal 1
+					@rclient.del.callCount.should.equal 1
+					@rclient.srem.callCount.should.equal 1
+					@rclient.exec.callCount.should.equal 1
+					done()
+
+			it 'should remove all sessions except for the retained one', (done) ->
+				@call (err) =>
+					expect(@rclient.del.firstCall.args[0]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+					expect(@rclient.srem.firstCall.args[1]).to.deep.equal(['sess:one', 'sess:three', 'sess:four'])
+					done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.exec.callsArgWith(0, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.revokeAllUserSessions null, @retain, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call the appropriate redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 0
+					@rclient.multi.callCount.should.equal 0
+					@rclient.del.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+		describe 'when there are no keys to delete', ->
+
+			beforeEach ->
+				@rclient.smembers.callsArgWith(1, null, [])
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not do the delete operation', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.multi.callCount.should.equal 0
+					@rclient.del.callCount.should.equal 0
+					@rclient.srem.callCount.should.equal 0
+					@rclient.exec.callCount.should.equal 0
+					done()
+
+	describe 'touch', ->
+
+		beforeEach ->
+			@rclient.expire.callsArgWith(2, null)
+			@call = (callback) =>
+				@UserSessionsManager.touch @user, callback
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal null
+				done()
+
+		it 'should call rclient.expire', (done) ->
+			@call (err) =>
+				@rclient.expire.callCount.should.equal 1
+				done()
+
+		describe 'when rclient produces an error', ->
+
+			beforeEach ->
+				@rclient.expire.callsArgWith(2, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager.touch null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call expire', (done) ->
+				@call (err) =>
+					@rclient.expire.callCount.should.equal 0
+					done()
+
+	describe '_checkSessions', ->
+
+		beforeEach ->
+			@call = (callback) =>
+				@UserSessionsManager._checkSessions @user, callback
+			@sessionKeys = ['one', 'two']
+			@rclient.smembers.callsArgWith(1, null, @sessionKeys)
+			@rclient.get.callsArgWith(1, null, 'some-value')
+			@rclient.srem.callsArgWith(2, null, {})
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.not.be.instanceof Error
+				expect(err).to.equal undefined
+				done()
+
+		it 'should call the appropriate redis methods', (done) ->
+			@call (err) =>
+				@rclient.smembers.callCount.should.equal 1
+				@rclient.get.callCount.should.equal 2
+				@rclient.srem.callCount.should.equal 0
+				done()
+
+		describe 'when one of the keys is not present in redis', ->
+
+			beforeEach ->
+				@rclient.get.onCall(0).callsArgWith(1, null, 'some-val')
+				@rclient.get.onCall(1).callsArgWith(1, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal undefined
+					done()
+
+			it 'should remove that key from the set', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.get.callCount.should.equal 2
+					@rclient.srem.callCount.should.equal 1
+					@rclient.srem.firstCall.args[1].should.equal 'two'
+					done()
+
+		describe 'when no user is supplied', ->
+
+			beforeEach ->
+				@call = (callback) =>
+					@UserSessionsManager._checkSessions null, callback
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.be.instanceof Error
+					expect(err).to.equal null
+					done()
+
+			it 'should not call redis methods', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 0
+					@rclient.get.callCount.should.equal 0
+					done()
+
+		describe 'when one of the get operations produces an error', ->
+
+			beforeEach ->
+				@rclient.get.onCall(0).callsArgWith(1, new Error('woops'), null)
+				@rclient.get.onCall(1).callsArgWith(1, null, null)
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should call the right redis methods, bailing out early', (done) ->
+				@call (err) =>
+					@rclient.smembers.callCount.should.equal 1
+					@rclient.get.callCount.should.equal 1
+					@rclient.srem.callCount.should.equal 0
+					done()

+ 253 - 0
services/web/test/acceptance/coffee/SessionTests.coffee

@@ -0,0 +1,253 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+redis = require "./helpers/redis"
+
+describe "Sessions", ->
+	before (done) ->
+		@timeout(20000)
+		@user1 = new User()
+		@site_admin = new User({email: "admin@example.com"})
+		async.series [
+			(cb) => @user1.login cb
+			(cb) => @user1.logout cb
+		], done
+
+	describe "one session", ->
+
+		it "should have one session in UserSessions set", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# should be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout, should remove session from set
+					, (next) =>
+						@user1.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)
+
+	describe "two sessions", ->
+
+		before ->
+			# set up second session for this user
+			@user2 = new User()
+			@user2.email = @user1.email
+			@user2.password = @user1.password
+
+		it "should have two sessions in UserSessions set", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login again, should add the second session to set
+					, (next) =>
+						@user2.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 2
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# both should be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout first session, should remove session from set
+					, (next) =>
+						@user1.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							next()
+
+					# first session should not have access to settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					# second session should still have access to settings
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout second session, should remove last session from set
+					, (next) =>
+						@user2.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+					# second session should not have access to settings page
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)
+
+	describe 'three sessions, password reset', ->
+
+		before ->
+			# set up second session for this user
+			@user2 = new User()
+			@user2.email = @user1.email
+			@user2.password = @user1.password
+			@user3 = new User()
+			@user3.email = @user1.email
+			@user3.password = @user1.password
+
+		it "should erase both sessions when password is reset", (done) ->
+			async.series(
+				[
+					(next) =>
+						redis.clearUserSessions @user1, next
+
+					# login, should add session to set
+					, (next) =>
+						@user1.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login again, should add the second session to set
+					, (next) =>
+						@user2.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 2
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# login third session, should add the second session to set
+					, (next) =>
+						@user3.login (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 3
+							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
+							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
+							next()
+
+					# password reset from second session, should erase two of the three sessions
+					, (next) =>
+						@user2.changePassword (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user2, (err, sessions) =>
+							expect(sessions.length).to.equal 1
+							next()
+
+					# users one and three should not be able to access settings page
+					, (next) =>
+						@user1.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					, (next) =>
+						@user3.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 302
+							next()
+
+					# user two should still be logged in, and able to access settings page
+					, (next) =>
+						@user2.getUserSettingsPage (err, statusCode) =>
+							expect(err).to.equal null
+							expect(statusCode).to.equal 200
+							next()
+
+					# logout second session, should remove last session from set
+					, (next) =>
+						@user2.logout (err) ->
+							next(err)
+
+					, (next) =>
+						redis.getUserSessions @user1, (err, sessions) =>
+							expect(sessions.length).to.equal 0
+							next()
+
+				], (err, result) =>
+					if err
+						throw err
+					done()
+			)

+ 46 - 3
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -27,11 +27,28 @@ class User
 				db.users.findOne {email: @email}, (error, user) =>
 					return callback(error) if error?
 					@id = user?._id?.toString()
+					@_id = user?._id?.toString()
 					callback()
-	
+
+	logout: (callback = (error) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.get {
+				url: "/logout"
+				json:
+					email: @email
+					password: @password
+			}, (error, response, body) =>
+				return callback(error) if error?
+				db.users.findOne {email: @email}, (error, user) =>
+					return callback(error) if error?
+					@id = user?._id?.toString()
+					@_id = user?._id?.toString()
+					callback()
+
 	ensure_admin: (callback = (error) ->) ->
 		db.users.update {_id: ObjectId(@id)}, { $set: { isAdmin: true }}, callback
-	
+
 	createProject: (name, callback = (error, project_id) ->) ->
 		@request.post {
 			url: "/project/new",
@@ -74,4 +91,30 @@ class User
 			})
 			callback()
 
-module.exports = User
+	changePassword: (callback = (error) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: "/user/password/update"
+				json:
+					currentPassword: @password
+					newPassword1: @password
+					newPassword2: @password
+			}, (error, response, body) =>
+				return callback(error) if error?
+				db.users.findOne {email: @email}, (error, user) =>
+					return callback(error) if error?
+					callback()
+
+	getUserSettingsPage: (callback = (error, statusCode) ->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.get {
+				url: "/user/settings"
+			}, (error, response, body) =>
+				return callback(error) if error?
+				callback(null, response.statusCode)
+
+
+
+module.exports = User

+ 27 - 0
services/web/test/acceptance/coffee/helpers/redis.coffee

@@ -0,0 +1,27 @@
+Settings = require('settings-sharelatex')
+redis = require('redis-sharelatex')
+logger = require("logger-sharelatex")
+Async = require('async')
+
+rclient = redis.createClient(Settings.redis.web)
+
+module.exports =
+
+	getUserSessions: (user, callback=(err, sessionsSet)->) ->
+		rclient.smembers "UserSessions:#{user._id}", (err, result) ->
+			return callback(err, result)
+
+	clearUserSessions: (user, callback=(err)->) ->
+		sessionSetKey = "UserSessions:#{user._id}"
+		rclient.smembers sessionSetKey, (err, sessionKeys) ->
+			if err
+				return callback(err)
+			if sessionKeys.length == 0
+				return callback(null)
+			rclient.multi()
+				.del(sessionKeys)
+				.srem(sessionSetKey, sessionKeys)
+				.exec (err, result) ->
+					if err
+						return callback(err)
+					callback(null)