diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee
index 1fae80dd90..78ce4190d0 100644
--- a/services/web/Gruntfile.coffee
+++ b/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:
diff --git a/services/web/README.md b/services/web/README.md
index 00d94343ab..f777e7e5f5 100644
--- a/services/web/README.md
+++ b/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`
diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
index e7db5d9f65..d75bef5207 100644
--- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
+++ b/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()
diff --git a/services/web/app/coffee/Features/Blog/BlogController.coffee b/services/web/app/coffee/Features/Blog/BlogController.coffee
index 8f726602c3..11af432624 100644
--- a/services/web/app/coffee/Features/Blog/BlogController.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
index 71737eecff..81557fea42 100644
--- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
+++ b/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()
diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee
index 5f3edd4de5..a790948db5 100755
--- a/services/web/app/coffee/Features/Compile/CompileController.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Compile/CompileManager.coffee b/services/web/app/coffee/Features/Compile/CompileManager.coffee
index 8350ca1196..c561576525 100755
--- a/services/web/app/coffee/Features/Compile/CompileManager.coffee
+++ b/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"
diff --git a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee b/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
index fb0f75beee..ec5371f0f2 100644
--- a/services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee
index 8ba8a65845..f398ea75b5 100644
--- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee
+++ b/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
diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee
index 5dd32d1978..546cea035e 100644
--- a/services/web/app/coffee/Features/User/UserController.coffee
+++ b/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'
-
-
-
diff --git a/services/web/app/coffee/Features/User/UserSessionsManager.coffee b/services/web/app/coffee/Features/User/UserSessionsManager.coffee
new file mode 100644
index 0000000000..95974ec59a
--- /dev/null
+++ b/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)
+ )
diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee
index 11a3b5237e..3b40ff6885 100644
--- a/services/web/app/coffee/infrastructure/Server.coffee
+++ b/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")
diff --git a/services/web/app/views/layout.jade b/services/web/app/views/layout.jade
index 82b230c976..14e4aa6107 100644
--- a/services/web/app/views/layout.jade
+++ b/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
- 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
diff --git a/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee b/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
index 4d0adef4fa..36eaa5d555 100644
--- a/services/web/public/coffee/ide/hotkeys/controllers/HotkeysController.coffee
+++ b/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"
diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
index 31fc11b6a3..747ca1ad08 100644
--- a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee
+++ b/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?
diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
index e7735e89dc..f123515deb 100644
--- a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee
+++ b/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 float package.
"""
,
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 here.
+ 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 here.
"""
,
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 uploaded the file and specified the file location correctly.
"""
,
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 supported image format 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 here.
"""
,
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.
"""
diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
index 610915ec3a..635db982c9 100644
--- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
+++ b/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?
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
index 5639d670f0..e634afea3a 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
+++ b/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
diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
index 5f87ff25e9..669cfeced1 100644
--- a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
+++ b/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'
diff --git a/services/web/public/coffee/ide/settings/services/settings.coffee b/services/web/public/coffee/ide/settings/services/settings.coffee
index 4e6bbcea3d..14dfe92f56 100644
--- a/services/web/public/coffee/ide/settings/services/settings.coffee
+++ b/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
+
}
]
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/share/controllers/ShareController.coffee b/services/web/public/coffee/ide/share/controllers/ShareController.coffee
index d76bd07965..fc74862528 100644
--- a/services/web/public/coffee/ide/share/controllers/ShareController.coffee
+++ b/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"
diff --git a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee b/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
index 5166a32ae1..2aa4efb505 100644
--- a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
+++ b/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')
\ No newline at end of file
+ $modalInstance.dismiss('cancel')
diff --git a/services/web/public/coffee/main/event.coffee b/services/web/public/coffee/main/event.coffee
index a0f7a4cedb..68decfd5ee 100644
--- a/services/web/public/coffee/main/event.coffee
+++ b/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)->
diff --git a/services/web/public/img/sprite.png b/services/web/public/img/sprite.png
new file mode 100644
index 0000000000..366d994ce8
Binary files /dev/null and b/services/web/public/img/sprite.png differ
diff --git a/services/web/public/stylesheets/app/editor/pdf.less b/services/web/public/stylesheets/app/editor/pdf.less
index 2d147c2f69..a4d2eeb214 100644
--- a/services/web/public/stylesheets/app/editor/pdf.less
+++ b/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;
+ }
diff --git a/services/web/public/stylesheets/app/sprites.less b/services/web/public/stylesheets/app/sprites.less
new file mode 100644
index 0000000000..b64e84ad68
--- /dev/null
+++ b/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;
+}
diff --git a/services/web/public/stylesheets/components/footer.less b/services/web/public/stylesheets/components/footer.less
index 7a3bc66723..e4e4f82da1 100644
--- a/services/web/public/stylesheets/components/footer.less
+++ b/services/web/public/stylesheets/components/footer.less
@@ -23,3 +23,8 @@ footer.site-footer {
}
}
}
+
+.sprite-icon-lang {
+ display: inline-block;
+ vertical-align: middle;
+}
\ No newline at end of file
diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less
index b516adff3e..0377e4e828 100755
--- a/services/web/public/stylesheets/style.less
+++ b/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";
diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
index f83b38617b..4e9b248f25 100644
--- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
+++ b/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
-
diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
index 632b43e7f2..37e39aaacb 100644
--- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee
+++ b/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
\ No newline at end of file
diff --git a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
index 97b41fb33b..d7a13578e2 100644
--- a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee
+++ b/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
diff --git a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
index 82a4ab734b..849e9e8ccc 100644
--- a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee
+++ b/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
diff --git a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee b/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
index 27852e3e07..89c6479734 100644
--- a/services/web/test/UnitTests/coffee/PasswordReset/PasswordResetControllerTests.coffee
+++ b/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")
diff --git a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
index 601f7867b0..6a99ed8150 100644
--- a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee
+++ b/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"
diff --git a/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee b/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
index d92b8f39d7..19d17e7ac3 100644
--- a/services/web/test/UnitTests/coffee/User/UserControllerTests.coffee
+++ b/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
-
-
diff --git a/services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee b/services/web/test/UnitTests/coffee/User/UserSessionsManagerTests.coffee
new file mode 100644
index 0000000000..9a09aa88f0
--- /dev/null
+++ b/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()
diff --git a/services/web/test/acceptance/coffee/SessionTests.coffee b/services/web/test/acceptance/coffee/SessionTests.coffee
new file mode 100644
index 0000000000..cff5b66406
--- /dev/null
+++ b/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()
+ )
diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee
index c13a45499d..888473578e 100644
--- a/services/web/test/acceptance/coffee/helpers/User.coffee
+++ b/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
\ No newline at end of file
+ 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
diff --git a/services/web/test/acceptance/coffee/helpers/redis.coffee b/services/web/test/acceptance/coffee/helpers/redis.coffee
new file mode 100644
index 0000000000..2611e4fc57
--- /dev/null
+++ b/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)