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