diff --git a/services/web/.gitignore b/services/web/.gitignore index f8a1fc0842..a3f982ef53 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -40,6 +40,7 @@ app.js app/js/* test/UnitTests/js/* test/smoke/js/* +test/acceptance/js/* cookies.txt requestQueueWorker.js TpdsWorker.js diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index 7d49df07af..428e4d506d 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -71,6 +71,14 @@ module.exports = (grunt) -> dest: 'test/UnitTests/js/', ext: '.js' + acceptance_tests: + expand: true, + flatten: false, + cwd: 'test/acceptance/coffee', + src: ['**/*.coffee'], + dest: 'test/acceptance/js/', + ext: '.js' + less: app: files: @@ -119,6 +127,7 @@ module.exports = (grunt) -> clean: app: ["app/js"] unit_tests: ["test/UnitTests/js"] + acceptance_tests: ["test/acceptance/js"] mochaTest: unit: @@ -131,6 +140,12 @@ module.exports = (grunt) -> options: reporter: grunt.option('reporter') or 'spec' grep: grunt.option("grep") + acceptance: + src: ["test/acceptance/js/#{grunt.option('feature') or '**'}/*.js"] + options: + timeout: 10000 + reporter: grunt.option('reporter') or 'spec' + grep: grunt.option("grep") "git-rev-parse": version: @@ -184,6 +199,7 @@ module.exports = (grunt) -> ] "Test tasks": [ "test:unit" + "test:acceptance" ] "Run tasks": [ "run" @@ -290,6 +306,7 @@ module.exports = (grunt) -> grunt.registerTask 'compile:css', 'Compile the less files to css', ['less'] grunt.registerTask 'compile:minify', 'Concat and minify the client side js', ['requirejs', "file_append"] grunt.registerTask 'compile:unit_tests', 'Compile the unit tests', ['clean:unit_tests', 'coffee:unit_tests'] + grunt.registerTask 'compile:acceptance_tests', 'Compile the acceptance tests', ['clean:acceptance_tests', 'coffee:acceptance_tests'] grunt.registerTask 'compile:smoke_tests', 'Compile the smoke tests', ['coffee:smoke_tests'] grunt.registerTask 'compile:tests', 'Compile all the tests', ['compile:smoke_tests', 'compile:unit_tests'] grunt.registerTask 'compile', 'Compiles everything need to run web-sharelatex', ['compile:server', 'compile:client', 'compile:css'] @@ -297,6 +314,7 @@ module.exports = (grunt) -> grunt.registerTask 'install', "Compile everything when installing as an npm module", ['compile'] grunt.registerTask 'test:unit', 'Run the unit tests (use --grep= or --feature= for individual tests)', ['compile:server', 'compile:modules:server', 'compile:unit_tests', 'compile:modules:unit_tests', 'mochaTest:unit'].concat(moduleUnitTestTasks) + grunt.registerTask 'test:acceptance', 'Run the acceptance tests (use --grep= or --feature= for individual tests)', ['compile:acceptance_tests', 'mochaTest:acceptance'] grunt.registerTask 'test:smoke', 'Run the smoke tests', ['compile:smoke_tests', 'mochaTest:smoke'] grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks) diff --git a/services/web/app.coffee b/services/web/app.coffee index 3d45307e5d..d8590689b3 100644 --- a/services/web/app.coffee +++ b/services/web/app.coffee @@ -10,7 +10,6 @@ metrics = require("metrics-sharelatex") metrics.initialize("web") metrics.memory.monitor(logger) Server = require("./app/js/infrastructure/Server") -Errors = require "./app/js/errors" argv = require("optimist") .options("user", {alias : "u", description : "Run the server with permissions of the specified user"}) @@ -18,18 +17,6 @@ argv = require("optimist") .usage("Usage: $0") .argv -Server.app.use (error, req, res, next) -> - if error?.code is 'EBADCSRFTOKEN' - logger.log err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf" - res.sendStatus(403) - return - logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear" - res.statusCode = error.status or 500 - if res.statusCode == 500 - res.end("Oops, something went wrong with your request, sorry. If this continues, please contact us at #{Settings.adminEmail}") - else - res.end() - if Settings.catchErrors process.removeAllListeners "uncaughtException" process.on "uncaughtException", (error) -> diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee index 312b441158..e7db5d9f65 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -44,50 +44,27 @@ module.exports = AuthenticationController = text: req.i18n.translate("email_or_password_wrong_try_again"), type: 'error' - getAuthToken: (req, res, next = (error) ->) -> - AuthenticationController.getLoggedInUserId req, (error, user_id) -> - return next(error) if error? - AuthenticationManager.getAuthToken user_id, (error, auth_token) -> - return next(error) if error? - res.send(auth_token) - getLoggedInUserId: (req, callback = (error, user_id) ->) -> if req?.session?.user?._id? callback null, req.session.user._id.toString() else callback null, null - getLoggedInUser: (req, options = {allow_auth_token: false}, callback = (error, user) ->) -> - if typeof(options) == "function" - callback = options - options = {allow_auth_token: false} - + getLoggedInUser: (req, callback = (error, user) ->) -> if req.session?.user?._id? query = req.session.user._id - else if req.query?.auth_token? and options.allow_auth_token - query = { auth_token: req.query.auth_token } else return callback null, null UserGetter.getUser query, callback - requireLogin: (options = {allow_auth_token: false, load_from_db: false}) -> + requireLogin: () -> doRequest = (req, res, next = (error) ->) -> - load_from_db = options.load_from_db - if req.query?.auth_token? and options.allow_auth_token - load_from_db = true - if load_from_db - AuthenticationController.getLoggedInUser req, { allow_auth_token: options.allow_auth_token }, (error, user) -> - return next(error) if error? - return AuthenticationController._redirectToLoginOrRegisterPage(req, res) if !user? - req.user = user - return next() + if !req.session.user? + AuthenticationController._redirectToLoginOrRegisterPage(req, res) else - if !req.session.user? - AuthenticationController._redirectToLoginOrRegisterPage(req, res) - else - req.user = req.session.user - return next() + req.user = req.session.user + return next() return doRequest diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee index d815b426fe..201643e88e 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee @@ -35,19 +35,3 @@ module.exports = AuthenticationManager = $unset: password: true }, callback) - getAuthToken: (user_id, callback = (error, auth_token) ->) -> - db.users.findOne { _id: ObjectId(user_id.toString()) }, { auth_token : true }, (error, user) => - return callback(error) if error? - return callback(new Error("user could not be found: #{user_id}")) if !user? - if user.auth_token? - callback null, user.auth_token - else - @_createSecureToken (error, auth_token) -> - db.users.update { _id: ObjectId(user_id.toString()) }, { $set : auth_token: auth_token }, (error) -> - return callback(error) if error? - callback null, auth_token - - _createSecureToken: (callback = (error, token) ->) -> - crypto.randomBytes 48, (error, buffer) -> - return callback(error) if error? - callback null, buffer.toString("hex") diff --git a/services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee b/services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee new file mode 100644 index 0000000000..ded0b6f979 --- /dev/null +++ b/services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee @@ -0,0 +1,73 @@ +CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") +Project = require("../../models/Project").Project +User = require("../../models/User").User +PrivilegeLevels = require("./PrivilegeLevels") +PublicAccessLevels = require("./PublicAccessLevels") +Errors = require("../Errors/Errors") + +module.exports = AuthorizationManager = + # Get the privilege level that the user has for the project + # Returns: + # * privilegeLevel: "owner", "readAndWrite", of "readOnly" if the user has + # access. false if the user does not have access + # * becausePublic: true if the access level is only because the project is public. + getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) -> + getPublicAccessLevel = () -> + Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) -> + return callback(error) if error? + if !project? + return callback new Errors.NotFoundError("no project found with id #{project_id}") + if project.publicAccesLevel == PublicAccessLevels.READ_ONLY + return callback null, PrivilegeLevels.READ_ONLY, true + else if project.publicAccesLevel == PublicAccessLevels.READ_AND_WRITE + return callback null, PrivilegeLevels.READ_AND_WRITE, true + else + return callback null, PrivilegeLevels.NONE, false + + if !user_id? + getPublicAccessLevel() + else + CollaboratorsHandler.getMemberIdPrivilegeLevel user_id, project_id, (error, privilegeLevel) -> + return callback(error) if error? + if privilegeLevel? and privilegeLevel != PrivilegeLevels.NONE + # The user has direct access + callback null, privilegeLevel, false + else + AuthorizationManager.isUserSiteAdmin user_id, (error, isAdmin) -> + return callback(error) if error? + if isAdmin + callback null, PrivilegeLevels.OWNER, false + else + getPublicAccessLevel() + + canUserReadProject: (user_id, project_id, callback = (error, canRead) ->) -> + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) -> + return callback(error) if error? + return callback null, (privilegeLevel in [PrivilegeLevels.OWNER, PrivilegeLevels.READ_AND_WRITE, PrivilegeLevels.READ_ONLY]) + + canUserWriteProjectContent: (user_id, project_id, callback = (error, canWriteContent) ->) -> + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) -> + return callback(error) if error? + return callback null, (privilegeLevel in [PrivilegeLevels.OWNER, PrivilegeLevels.READ_AND_WRITE]) + + canUserWriteProjectSettings: (user_id, project_id, callback = (error, canWriteSettings) ->) -> + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel, becausePublic) -> + return callback(error) if error? + if privilegeLevel == PrivilegeLevels.OWNER + return callback null, true + else if privilegeLevel == PrivilegeLevels.READ_AND_WRITE and !becausePublic + return callback null, true + else + return callback null, false + + canUserAdminProject: (user_id, project_id, callback = (error, canAdmin) ->) -> + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) -> + return callback(error) if error? + return callback null, (privilegeLevel == PrivilegeLevels.OWNER) + + isUserSiteAdmin: (user_id, callback = (error, isAdmin) ->) -> + if !user_id? + return callback null, false + User.findOne { _id: user_id }, { isAdmin: 1 }, (error, user) -> + return callback(error) if error? + return callback null, (user?.isAdmin == true) \ No newline at end of file diff --git a/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee b/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee new file mode 100644 index 0000000000..4888db0c8a --- /dev/null +++ b/services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee @@ -0,0 +1,111 @@ +AuthorizationManager = require("./AuthorizationManager") +async = require "async" +logger = require "logger-sharelatex" +ObjectId = require("mongojs").ObjectId +Errors = require "../Errors/Errors" + +module.exports = AuthorizationMiddlewear = + ensureUserCanReadMultipleProjects: (req, res, next) -> + project_ids = (req.query.project_ids or "").split(",") + AuthorizationMiddlewear._getUserId req, (error, user_id) -> + return next(error) if error? + # Remove the projects we have access to. Note rejectSeries doesn't use + # errors in callbacks + async.rejectSeries project_ids, (project_id, cb) -> + AuthorizationManager.canUserReadProject user_id, project_id, (error, canRead) -> + return next(error) if error? + cb(canRead) + , (unauthorized_project_ids) -> + if unauthorized_project_ids.length > 0 + AuthorizationMiddlewear.redirectToRestricted req, res, next + else + next() + + ensureUserCanReadProject: (req, res, next) -> + AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) -> + return next(error) if error? + AuthorizationManager.canUserReadProject user_id, project_id, (error, canRead) -> + return next(error) if error? + if canRead + logger.log {user_id, project_id}, "allowing user read access to project" + next() + else + logger.log {user_id, project_id}, "denying user read access to project" + AuthorizationMiddlewear.redirectToRestricted req, res, next + + ensureUserCanWriteProjectSettings: (req, res, next) -> + AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) -> + return next(error) if error? + AuthorizationManager.canUserWriteProjectSettings user_id, project_id, (error, canWrite) -> + return next(error) if error? + if canWrite + logger.log {user_id, project_id}, "allowing user write access to project settings" + next() + else + logger.log {user_id, project_id}, "denying user write access to project settings" + AuthorizationMiddlewear.redirectToRestricted req, res, next + + ensureUserCanWriteProjectContent: (req, res, next) -> + AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) -> + return next(error) if error? + AuthorizationManager.canUserWriteProjectContent user_id, project_id, (error, canWrite) -> + return next(error) if error? + if canWrite + logger.log {user_id, project_id}, "allowing user write access to project content" + next() + else + logger.log {user_id, project_id}, "denying user write access to project settings" + AuthorizationMiddlewear.redirectToRestricted req, res, next + + ensureUserCanAdminProject: (req, res, next) -> + AuthorizationMiddlewear._getUserAndProjectId req, (error, user_id, project_id) -> + return next(error) if error? + AuthorizationManager.canUserAdminProject user_id, project_id, (error, canAdmin) -> + return next(error) if error? + if canAdmin + logger.log {user_id, project_id}, "allowing user admin access to project" + next() + else + logger.log {user_id, project_id}, "denying user admin access to project" + AuthorizationMiddlewear.redirectToRestricted req, res, next + + ensureUserIsSiteAdmin: (req, res, next) -> + AuthorizationMiddlewear._getUserId req, (error, user_id) -> + return next(error) if error? + AuthorizationManager.isUserSiteAdmin user_id, (error, isAdmin) -> + return next(error) if error? + if isAdmin + logger.log {user_id}, "allowing user admin access to site" + next() + else + logger.log {user_id}, "denying user admin access to site" + AuthorizationMiddlewear.redirectToRestricted req, res, next + + _getUserAndProjectId: (req, callback = (error, user_id, project_id) ->) -> + project_id = req.params?.project_id or req.params?.Project_id + if !project_id? + return callback(new Error("Expected project_id in request parameters")) + if !ObjectId.isValid(project_id) + return callback(new Errors.NotFoundError("invalid project_id: #{project_id}")) + AuthorizationMiddlewear._getUserId req, (error, user_id) -> + return callback(error) if error? + callback(null, user_id, project_id) + + _getUserId: (req, callback = (error, user_id) ->) -> + if req.session?.user?._id? + user_id = req.session.user._id + else + user_id = null + callback null, user_id + + redirectToRestricted: (req, res, next) -> + res.redirect "/restricted" + + restricted : (req, res, next)-> + if req.session.user? + res.render 'user/restricted', + title:'restricted' + else + logger.log "user not logged in and trying to access #{req.url}, being redirected to login" + res.redirect '/register' + \ No newline at end of file diff --git a/services/web/app/coffee/Features/Authorization/PrivilegeLevels.coffee b/services/web/app/coffee/Features/Authorization/PrivilegeLevels.coffee new file mode 100644 index 0000000000..682ae08a02 --- /dev/null +++ b/services/web/app/coffee/Features/Authorization/PrivilegeLevels.coffee @@ -0,0 +1,5 @@ +module.exports = + NONE: false + READ_ONLY: "readOnly" + READ_AND_WRITE: "readAndWrite" + OWNER: "owner" \ No newline at end of file diff --git a/services/web/app/coffee/Features/Authorization/PublicAccessLevels.coffee b/services/web/app/coffee/Features/Authorization/PublicAccessLevels.coffee new file mode 100644 index 0000000000..8e63a64a33 --- /dev/null +++ b/services/web/app/coffee/Features/Authorization/PublicAccessLevels.coffee @@ -0,0 +1,4 @@ +module.exports = + READ_ONLY: "readOnly" + READ_AND_WRITE: "readAndWrite" + PRIVATE: "private" \ No newline at end of file diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee index 788b782eb2..1905d0d0a6 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee @@ -7,15 +7,6 @@ UserGetter = require "../User/UserGetter" mimelib = require("mimelib") module.exports = CollaboratorsController = - getCollaborators: (req, res, next = (error) ->) -> - ProjectGetter.getProject req.params.Project_id, { owner_ref: true, collaberator_refs: true, readOnly_refs: true}, (error, project) -> - return next(error) if error? - ProjectGetter.populateProjectWithUsers project, (error, project) -> - return next(error) if error? - CollaboratorsController._formatCollaborators project, (error, collaborators) -> - return next(error) if error? - res.send(JSON.stringify(collaborators)) - addUserToProject: (req, res, next) -> project_id = req.params.Project_id LimitationsManager.canAddXCollaborators project_id, 1, (error, allowed) => @@ -59,29 +50,3 @@ module.exports = CollaboratorsController = EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id) callback() - _formatCollaborators: (project, callback = (error, collaborators) ->) -> - collaborators = [] - - pushCollaborator = (user, permissions, owner) -> - collaborators.push { - id: user._id.toString() - first_name: user.first_name - last_name: user.last_name - email: user.email - permissions: permissions - owner: owner - } - - if project.owner_ref? - pushCollaborator(project.owner_ref, ["read", "write", "admin"], true) - - if project.collaberator_refs? and project.collaberator_refs.length > 0 - for user in project.collaberator_refs - pushCollaborator(user, ["read", "write"], false) - - if project.readOnly_refs? and project.readOnly_refs.length > 0 - for user in project.readOnly_refs - pushCollaborator(user, ["read"], false) - - callback null, collaborators - diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee index 4ab855af80..71737eecff 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee @@ -1,13 +1,77 @@ UserCreator = require('../User/UserCreator') Project = require("../../models/Project").Project -ProjectEntityHandler = require("../Project/ProjectEntityHandler") mimelib = require("mimelib") logger = require('logger-sharelatex') UserGetter = require "../User/UserGetter" ContactManager = require "../Contacts/ContactManager" CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" +async = require "async" +PrivilegeLevels = require "../Authorization/PrivilegeLevels" +Errors = require "../Errors/Errors" module.exports = CollaboratorsHandler = + getMemberIdsWithPrivilegeLevels: (project_id, callback = (error, members) ->) -> + Project.findOne { _id: project_id }, { owner_ref: 1, collaberator_refs: 1, readOnly_refs: 1 }, (error, project) -> + return callback(error) if error? + return callback new Errors.NotFoundError("no project found with id #{project_id}") if !project? + members = [] + members.push { id: project.owner_ref.toString(), privilegeLevel: PrivilegeLevels.OWNER } + for member_id in project.readOnly_refs or [] + members.push { id: member_id.toString(), privilegeLevel: PrivilegeLevels.READ_ONLY } + for member_id in project.collaberator_refs or [] + members.push { id: member_id.toString(), privilegeLevel: PrivilegeLevels.READ_AND_WRITE } + return callback null, members + + getMemberIds: (project_id, callback = (error, member_ids) ->) -> + CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) -> + return callback(error) if error? + return callback null, members.map (m) -> m.id + + getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) -> + CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) -> + return callback(error) if error? + 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 + + getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) -> + # In future if the schema changes and getting all member ids is more expensive (multiple documents) + # then optimise this. + CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) -> + return callback(error) if error? + for member in members + if member.id == user_id?.toString() + return callback null, member.privilegeLevel + return callback null, PrivilegeLevels.NONE + + getMemberCount: (project_id, callback = (error, count) ->) -> + CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) -> + return callback(error) if error? + return callback null, (members or []).length + + getCollaboratorCount: (project_id, callback = (error, count) ->) -> + CollaboratorsHandler.getMemberCount project_id, (error, count) -> + return callback(error) if error? + return callback null, count - 1 # Don't count project owner + + isUserMemberOfProject: (user_id, project_id, callback = (error, isMember, privilegeLevel) ->) -> + CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) -> + return callback(error) if error? + for member in members + if member.id.toString() == user_id.toString() + return callback null, true, member.privilegeLevel + return callback null, false, null + + getProjectsUserIsCollaboratorOf: (user_id, fields, callback = (error, readAndWriteProjects, readOnlyProjects) ->) -> + Project.find {collaberator_refs:user_id}, fields, (err, readAndWriteProjects)=> + return callback(err) if err? + Project.find {readOnly_refs:user_id}, fields, (err, readOnlyProjects)=> + return callback(err) if err? + callback(null, readAndWriteProjects, readOnlyProjects) + removeUserFromProject: (project_id, user_id, callback = (error) ->)-> logger.log user_id: user_id, project_id: project_id, "removing user" conditions = _id:project_id @@ -38,10 +102,10 @@ module.exports = CollaboratorsHandler = if existing_users.indexOf(user_id.toString()) > -1 return callback null # User already in Project - if privilegeLevel == 'readAndWrite' + if privilegeLevel == PrivilegeLevels.READ_AND_WRITE level = {"collaberator_refs":user_id} logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user" - else if privilegeLevel == 'readOnly' + else if privilegeLevel == PrivilegeLevels.READ_ONLY level = {"readOnly_refs":user_id} logger.log {privileges: "readOnly", user_id, project_id}, "adding user" else @@ -57,6 +121,7 @@ module.exports = CollaboratorsHandler = Project.update { _id: project_id }, { $addToSet: level }, (error) -> return callback(error) if error? # Flush to TPDS in background to add files to collaborator's Dropbox + ProjectEntityHandler = require("../Project/ProjectEntityHandler") ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, (error) -> if error? logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator" diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee index d5abf23350..34a6da9a02 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee @@ -1,11 +1,10 @@ CollaboratorsController = require('./CollaboratorsController') -SecurityManager = require('../../managers/SecurityManager') AuthenticationController = require('../Authentication/AuthenticationController') +AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear') module.exports = apply: (webRouter, apiRouter) -> webRouter.post '/project/:Project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject - apiRouter.get '/project/:Project_id/collaborators', SecurityManager.requestCanAccessProject(allow_auth_token: true), CollaboratorsController.getCollaborators - webRouter.post '/project/:Project_id/users', SecurityManager.requestIsOwner, CollaboratorsController.addUserToProject - webRouter.delete '/project/:Project_id/users/:user_id', SecurityManager.requestIsOwner, CollaboratorsController.removeUserFromProject + webRouter.post '/project/:Project_id/users', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.addUserToProject + webRouter.delete '/project/:Project_id/users/:user_id', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.removeUserFromProject diff --git a/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee b/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee new file mode 100644 index 0000000000..b6270cfce9 --- /dev/null +++ b/services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee @@ -0,0 +1,64 @@ +Settings = require "settings-sharelatex" +request = require('request') +redis = require("redis-sharelatex") +rclient = redis.createClient(Settings.redis.web) +Cookie = require('cookie') +logger = require "logger-sharelatex" + +buildKey = (project_id)-> + return "clsiserver:#{project_id}" + +clsiCookiesEnabled = Settings.clsiCookie?.key? and Settings.clsiCookie.key.length != 0 + + +module.exports = ClsiCookieManager = + + _getServerId : (project_id, callback = (err, serverId)->)-> + rclient.get buildKey(project_id), (err, serverId)-> + if err? + return callback(err) + if serverId? + return callback(null, serverId) + else + return ClsiCookieManager._populateServerIdViaRequest project_id, callback + + + _populateServerIdViaRequest :(project_id, callback = (err, serverId)->)-> + url = "#{Settings.apis.clsi.url}/project/#{project_id}/status" + request.get url, (err, res, body)-> + if err? + logger.err err:err, project_id:project_id, "error getting initial server id for project" + return callback(err) + ClsiCookieManager.setServerId project_id, res, (err, serverId)-> + if err? + logger.err err:err, project_id:project_id, "error setting server id via populate request" + callback(err, serverId) + + _parseServerIdFromResponse : (response)-> + cookies = Cookie.parse(response.headers["set-cookie"]?[0] or "") + return cookies?[Settings.clsiCookie.key] + + setServerId: (project_id, response, callback = (err, serverId)->)-> + if !clsiCookiesEnabled + return callback() + serverId = ClsiCookieManager._parseServerIdFromResponse(response) + multi = rclient.multi() + multi.set buildKey(project_id), serverId + multi.expire buildKey(project_id), Settings.clsiCookie.ttl + multi.exec (err)-> + callback(err, serverId) + + + getCookieJar: (project_id, callback = (err, jar)->)-> + if !clsiCookiesEnabled + return callback(null, request.jar()) + ClsiCookieManager._getServerId project_id, (err, serverId)=> + if err? + logger.err err:err, project_id:project_id, "error getting server id" + return callback(err) + serverCookie = request.cookie("#{Settings.clsiCookie.key}=#{serverId}") + jar = request.jar() + jar.setCookie serverCookie, Settings.apis.clsi.url + callback(null, jar) + + diff --git a/services/web/app/coffee/Features/Compile/ClsiManager.coffee b/services/web/app/coffee/Features/Compile/ClsiManager.coffee index 8b7ef023bd..9af1602d2c 100755 --- a/services/web/app/coffee/Features/Compile/ClsiManager.coffee +++ b/services/web/app/coffee/Features/Compile/ClsiManager.coffee @@ -5,39 +5,63 @@ request = require('request') Project = require("../../models/Project").Project ProjectEntityHandler = require("../Project/ProjectEntityHandler") logger = require "logger-sharelatex" -url = require("url") +Url = require("url") +ClsiCookieManager = require("./ClsiCookieManager") + module.exports = ClsiManager = + sendRequest: (project_id, options = {}, callback = (error, success) ->) -> ClsiManager._buildRequest project_id, options, (error, req) -> return callback(error) if error? logger.log project_id: project_id, "sending compile to CLSI" ClsiManager._postToClsi project_id, req, options.compileGroup, (error, response) -> - return callback(error) if error? - logger.log project_id: project_id, response: response, "received compile response from CLSI" - callback( - null - response?.compile?.status - ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles) - ) + if error? + logger.err err:error, project_id:project_id, "error sending request to clsi" + return callback(error) + logger.log project_id: project_id, outputFilesLength: response?.outputFiles?.length, status: response?.status, "received compile response from CLSI" + ClsiCookieManager._getServerId project_id, (err, clsiServerId)-> + if err? + logger.err err:err, project_id:project_id, "error getting server id" + return callback(err) + outputFiles = ClsiManager._parseOutputFiles(project_id, response?.compile?.outputFiles) + callback(null, response?.compile?.status, outputFiles, clsiServerId) deleteAuxFiles: (project_id, options, callback = (error) ->) -> compilerUrl = @_getCompilerUrl(options?.compileGroup) - request.del "#{compilerUrl}/project/#{project_id}", callback + opts = + url:"#{compilerUrl}/project/#{project_id}" + method:"DELETE" + ClsiManager._makeRequest project_id, opts, callback + + + _makeRequest: (project_id, opts, callback)-> + ClsiCookieManager.getCookieJar project_id, (err, jar)-> + if err? + logger.err err:err, "error getting cookie jar for clsi request" + return callback(err) + opts.jar = jar + request opts, (err, response, body)-> + if err? + logger.err err:err, project_id:project_id, url:opts?.url, "error making request to clsi" + return callback(err) + ClsiCookieManager.setServerId project_id, response, (err)-> + if err? + logger.warn err:err, project_id:project_id, "error setting server id" + + return callback err, response, body + _getCompilerUrl: (compileGroup) -> - if compileGroup == "priority" - return Settings.apis.clsi_priority.url - else - return Settings.apis.clsi.url + return Settings.apis.clsi.url _postToClsi: (project_id, req, compileGroup, callback = (error, response) ->) -> - compilerUrl = @_getCompilerUrl(compileGroup) - request.post { + compilerUrl = Settings.apis.clsi.url + opts = url: "#{compilerUrl}/project/#{project_id}/compile" json: req - jar: false - }, (error, response, body) -> + method: "POST" + ClsiManager._makeRequest project_id, opts, (error, response, body) -> return callback(error) if error? if 200 <= response.statusCode < 300 callback null, body @@ -51,8 +75,10 @@ module.exports = ClsiManager = _parseOutputFiles: (project_id, rawOutputFiles = []) -> outputFiles = [] for file in rawOutputFiles + path = Url.parse(file.url).path + path = path.replace("/project/#{project_id}/output/", "") outputFiles.push - path: url.parse(file.url).path.replace("/project/#{project_id}/output/", "") + path: path type: file.type build: file.build return outputFiles @@ -86,6 +112,9 @@ module.exports = ClsiManager = rootResourcePathOverride = path rootResourcePath = rootResourcePathOverride if rootResourcePathOverride? + if !rootResourcePath? + logger.warn {project_id}, "no root document found, setting to main.tex" + rootResourcePath = "main.tex" for path, file of files path = path.replace(/^\//, "") # Remove leading / @@ -94,19 +123,16 @@ module.exports = ClsiManager = url: "#{Settings.apis.filestore.url}/project/#{project._id}/file/#{file._id}" modified: file.created?.getTime() - if !rootResourcePath? - callback new Error("no root document exists") - else - callback null, { - compile: - options: - compiler: project.compiler - timeout: options.timeout - imageName: project.imageName - draft: !!options.draft - rootResourcePath: rootResourcePath - resources: resources - } + callback null, { + compile: + options: + compiler: project.compiler + timeout: options.timeout + imageName: project.imageName + draft: !!options.draft + rootResourcePath: rootResourcePath + resources: resources + } wordCount: (project_id, file, options, callback = (error, response) ->) -> ClsiManager._buildRequest project_id, options, (error, req) -> @@ -115,9 +141,10 @@ module.exports = ClsiManager = wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}" if req.compile.options.imageName? wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}" - request.get { + opts = url: wordcount_url - }, (error, response, body) -> + method: "GET" + ClsiManager._makeRequest project_id, opts, (error, response, body) -> return callback(error) if error? if 200 <= response.statusCode < 300 callback null, body diff --git a/services/web/app/coffee/Features/Compile/CompileController.coffee b/services/web/app/coffee/Features/Compile/CompileController.coffee index 89fbc87e2e..b50c6ffd1a 100755 --- a/services/web/app/coffee/Features/Compile/CompileController.coffee +++ b/services/web/app/coffee/Features/Compile/CompileController.coffee @@ -8,6 +8,8 @@ Settings = require "settings-sharelatex" AuthenticationController = require "../Authentication/AuthenticationController" UserGetter = require "../User/UserGetter" RateLimiter = require("../../infrastructure/RateLimiter") +ClsiCookieManager = require("./ClsiCookieManager") +Path = require("path") module.exports = CompileController = compile: (req, res, next = (error) ->) -> @@ -28,27 +30,23 @@ module.exports = CompileController = if req.body?.draft options.draft = req.body.draft logger.log {options, project_id}, "got compile request" - CompileManager.compile project_id, user_id, options, (error, status, outputFiles, output, limits) -> + CompileManager.compile project_id, user_id, options, (error, status, outputFiles, clsiServerId, limits) -> return next(error) if error? res.contentType("application/json") - res.send 200, JSON.stringify { + res.status(200).send JSON.stringify { status: status outputFiles: outputFiles compileGroup: limits?.compileGroup + clsiServerId:clsiServerId } downloadPdf: (req, res, next = (error) ->)-> - Metrics.inc "pdf-downloads" project_id = req.params.Project_id isPdfjsPartialDownload = req.query?.pdfng - - - rateLimit = (callback)-> if isPdfjsPartialDownload callback null, true - else rateLimitOpts = endpointName: "full-pdf-download" @@ -93,10 +91,36 @@ module.exports = CompileController = getFileFromClsi: (req, res, next = (error) ->) -> project_id = req.params.Project_id - CompileController.proxyToClsi(project_id, "/project/#{project_id}/output/#{req.params.file}", req, res, next) + build = req.params.build + if build? + url = "/project/#{project_id}/build/#{build}/output/#{req.params.file}" + else + url = "/project/#{project_id}/output/#{req.params.file}" + CompileController.proxyToClsi(project_id, url, req, res, next) - proxySync: (req, res, next = (error) ->) -> - CompileController.proxyToClsi(req.params.Project_id, req.url, req, res, next) + proxySyncPdf: (req, res, next = (error) ->) -> + project_id = req.params.Project_id + {page, h, v} = req.query + if not page?.match(/^\d+$/) + return next(new Error("invalid page parameter")) + if not h?.match(/^\d+\.\d+$/) + return next(new Error("invalid h parameter")) + if not v?.match(/^\d+\.\d+$/) + return next(new Error("invalid v parameter")) + destination = {url: "/project/#{project_id}/sync/pdf", qs: {page, h, v}} + CompileController.proxyToClsi(project_id, destination, req, res, next) + + proxySyncCode: (req, res, next = (error) ->) -> + project_id = req.params.Project_id + {file, line, column} = req.query + if not file? or Path.resolve("/", file) isnt "/#{file}" + return next(new Error("invalid file parameter")) + if not line?.match(/^\d+$/) + return next(new Error("invalid line parameter")) + if not column?.match(/^\d+$/) + return next(new Error("invalid column parameter")) + destination = {url:"/project/#{project_id}/sync/code", qs: {file, line, column}} + CompileController.proxyToClsi(project_id, destination, req, res, next) proxyToClsi: (project_id, url, req, res, next = (error) ->) -> if req.query?.compileGroup @@ -107,30 +131,39 @@ module.exports = CompileController = CompileController.proxyToClsiWithLimits(project_id, url, limits, req, res, next) proxyToClsiWithLimits: (project_id, url, limits, req, res, next = (error) ->) -> - if limits.compileGroup == "priority" - compilerUrl = Settings.apis.clsi_priority.url - else - compilerUrl = Settings.apis.clsi.url - url = "#{compilerUrl}#{url}" - logger.log url: url, "proxying to CLSI" - oneMinute = 60 * 1000 - # the base request - options = { url: url, method: req.method, timeout: oneMinute } - # if we have a build parameter, pass it through to the clsi - if req.query?.pdfng && req.query?.build? # only for new pdf viewer - options.qs = {} - options.qs.build = req.query.build - # if we are byte serving pdfs, pass through If-* and Range headers - # do not send any others, there's a proxying loop if Host: is passed! - if req.query?.pdfng - newHeaders = {} - for h, v of req.headers - newHeaders[h] = req.headers[h] if h.match /^(If-|Range)/i - options.headers = newHeaders - proxy = request(options) - proxy.pipe(res) - proxy.on "error", (error) -> - logger.warn err: error, url: url, "CLSI proxy error" + ClsiCookieManager.getCookieJar project_id, (err, jar)-> + if err? + logger.err err:err, "error getting cookie jar for clsi request" + return callback(err) + # expand any url parameter passed in as {url:..., qs:...} + if typeof url is "object" + {url, qs} = url + if limits.compileGroup == "priority" + compilerUrl = Settings.apis.clsi_priority.url + else + compilerUrl = Settings.apis.clsi.url + url = "#{compilerUrl}#{url}" + logger.log url: url, "proxying to CLSI" + oneMinute = 60 * 1000 + # the base request + options = { url: url, method: req.method, timeout: oneMinute, jar : jar } + # add any provided query string + options.qs = qs if qs? + # if we have a build parameter, pass it through to the clsi + if req.query?.pdfng && req.query?.build? # only for new pdf viewer + options.qs ?= {} + options.qs.build = req.query.build + # if we are byte serving pdfs, pass through If-* and Range headers + # do not send any others, there's a proxying loop if Host: is passed! + if req.query?.pdfng + newHeaders = {} + for h, v of req.headers + newHeaders[h] = req.headers[h] if h.match /^(If-|Range)/i + options.headers = newHeaders + proxy = request(options) + proxy.pipe(res) + proxy.on "error", (error) -> + logger.warn err: error, url: url, "CLSI proxy error" wordCount: (req, res, next) -> project_id = req.params.Project_id diff --git a/services/web/app/coffee/Features/Compile/CompileManager.coffee b/services/web/app/coffee/Features/Compile/CompileManager.coffee index 7cac827789..0d8d480b7b 100755 --- a/services/web/app/coffee/Features/Compile/CompileManager.coffee +++ b/services/web/app/coffee/Features/Compile/CompileManager.coffee @@ -1,8 +1,6 @@ Settings = require('settings-sharelatex') - redis = require("redis-sharelatex") rclient = redis.createClient(Settings.redis.web) - DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler" Project = require("../../models/Project").Project ProjectRootDocManager = require "../Project/ProjectRootDocManager" @@ -13,6 +11,8 @@ logger = require("logger-sharelatex") rateLimiter = require("../../infrastructure/RateLimiter") module.exports = CompileManager = + + compile: (project_id, user_id, options = {}, _callback = (error) ->) -> timer = new Metrics.Timer("editor.compile") callback = (args...) -> @@ -26,7 +26,8 @@ module.exports = CompileManager = CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) -> return callback(error) if error? if recentlyCompiled - return callback new Error("project was recently compiled so not continuing") + logger.warn {project_id, user_id}, "project was recently compiled so not continuing" + return callback null, "too-recently-compiled", [] CompileManager._ensureRootDocumentIsSet project_id, (error) -> return callback(error) if error? @@ -36,10 +37,10 @@ module.exports = CompileManager = return callback(error) if error? for key, value of limits options[key] = value - ClsiManager.sendRequest project_id, options, (error, status, outputFiles, output) -> + ClsiManager.sendRequest project_id, options, (error, status, outputFiles, clsiServerId) -> return callback(error) if error? logger.log files: outputFiles, "output files" - callback(null, status, outputFiles, output, limits) + callback(null, status, outputFiles, clsiServerId, limits) deleteAuxFiles: (project_id, callback = (error) ->) -> CompileManager.getProjectCompileLimits project_id, (error, limits) -> diff --git a/services/web/app/coffee/Features/Documents/DocumentController.coffee b/services/web/app/coffee/Features/Documents/DocumentController.coffee index 8cae627ab6..ba74fc47da 100644 --- a/services/web/app/coffee/Features/Documents/DocumentController.coffee +++ b/services/web/app/coffee/Features/Documents/DocumentController.coffee @@ -1,5 +1,4 @@ ProjectEntityHandler = require "../Project/ProjectEntityHandler" -Errors = require "../../errors" logger = require("logger-sharelatex") module.exports = diff --git a/services/web/app/coffee/Features/Editor/EditorController.coffee b/services/web/app/coffee/Features/Editor/EditorController.coffee index 38f788dd41..476ba96174 100644 --- a/services/web/app/coffee/Features/Editor/EditorController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorController.coffee @@ -164,12 +164,17 @@ module.exports = EditorController = moveEntity: (project_id, entity_id, folder_id, entityType, callback)-> Metrics.inc "editor.move-entity" - ProjectEntityHandler.moveEntity project_id, entity_id, folder_id, entityType, => + LockManager.getLock project_id, (err)-> if err? - logger.err err:err, project_id:project_id, entity_id:entity_id, folder_id:folder_id, "error moving entity" + logger.err err:err, project_id:project_id, "could not get lock for move entity" return callback(err) - EditorRealTimeController.emitToRoom project_id, 'reciveEntityMove', entity_id, folder_id - callback?() + ProjectEntityHandler.moveEntity project_id, entity_id, folder_id, entityType, => + if err? + logger.err err:err, project_id:project_id, entity_id:entity_id, folder_id:folder_id, "error moving entity" + return callback(err) + LockManager.releaseLock project_id, -> + EditorRealTimeController.emitToRoom project_id, 'reciveEntityMove', entity_id, folder_id + callback?() renameProject: (project_id, newName, callback = (err) ->) -> ProjectDetailsHandler.renameProject project_id, newName, -> diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index c52d0f5c67..379f20fe5b 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -5,14 +5,18 @@ EditorRealTimeController = require "./EditorRealTimeController" EditorController = require "./EditorController" ProjectGetter = require('../Project/ProjectGetter') UserGetter = require('../User/UserGetter') -AuthorizationManager = require("../Security/AuthorizationManager") +AuthorizationManager = require("../Authorization/AuthorizationManager") ProjectEditorHandler = require('../Project/ProjectEditorHandler') Metrics = require('../../infrastructure/Metrics') +CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") +PrivilegeLevels = require "../Authorization/PrivilegeLevels" module.exports = EditorHttpController = joinProject: (req, res, next) -> project_id = req.params.Project_id user_id = req.query.user_id + if user_id == "anonymous-user" + user_id = null logger.log {user_id, project_id}, "join project request" Metrics.inc "editor.join-project" EditorHttpController._buildJoinProjectView project_id, user_id, (error, project, privilegeLevel) -> @@ -29,17 +33,17 @@ module.exports = EditorHttpController = ProjectGetter.getProjectWithoutDocLines project_id, (error, project) -> return callback(error) if error? return callback(new Error("not found")) if !project? - ProjectGetter.populateProjectWithUsers project, (error, project) -> + CollaboratorsHandler.getMembersWithPrivilegeLevels project, (error, members) -> return callback(error) if error? UserGetter.getUser user_id, { isAdmin: true }, (error, user) -> return callback(error) if error? - AuthorizationManager.getPrivilegeLevelForProject project, user, (error, canAccess, privilegeLevel) -> + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) -> return callback(error) if error? - if !canAccess + if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE callback null, null, false else callback(null, - ProjectEditorHandler.buildProjectModelView(project), + ProjectEditorHandler.buildProjectModelView(project, members), privilegeLevel ) diff --git a/services/web/app/coffee/Features/Editor/EditorRouter.coffee b/services/web/app/coffee/Features/Editor/EditorRouter.coffee index 0576a4adce..9de1544875 100644 --- a/services/web/app/coffee/Features/Editor/EditorRouter.coffee +++ b/services/web/app/coffee/Features/Editor/EditorRouter.coffee @@ -1,20 +1,20 @@ EditorHttpController = require('./EditorHttpController') -SecurityManager = require('../../managers/SecurityManager') AuthenticationController = require "../Authentication/AuthenticationController" +AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear') module.exports = apply: (webRouter, apiRouter) -> - webRouter.post '/project/:Project_id/doc', SecurityManager.requestCanModifyProject, EditorHttpController.addDoc - webRouter.post '/project/:Project_id/folder', SecurityManager.requestCanModifyProject, EditorHttpController.addFolder + webRouter.post '/project/:Project_id/doc', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.addDoc + webRouter.post '/project/:Project_id/folder', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.addFolder - webRouter.post '/project/:Project_id/:entity_type/:entity_id/rename', SecurityManager.requestCanModifyProject, EditorHttpController.renameEntity - webRouter.post '/project/:Project_id/:entity_type/:entity_id/move', SecurityManager.requestCanModifyProject, EditorHttpController.moveEntity + webRouter.post '/project/:Project_id/:entity_type/:entity_id/rename', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.renameEntity + webRouter.post '/project/:Project_id/:entity_type/:entity_id/move', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.moveEntity - webRouter.delete '/project/:Project_id/file/:entity_id', SecurityManager.requestCanModifyProject, EditorHttpController.deleteFile - webRouter.delete '/project/:Project_id/doc/:entity_id', SecurityManager.requestCanModifyProject, EditorHttpController.deleteDoc - webRouter.delete '/project/:Project_id/folder/:entity_id', SecurityManager.requestCanModifyProject, EditorHttpController.deleteFolder + webRouter.delete '/project/:Project_id/file/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteFile + webRouter.delete '/project/:Project_id/doc/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteDoc + webRouter.delete '/project/:Project_id/folder/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteFolder - webRouter.post '/project/:Project_id/doc/:doc_id/restore', SecurityManager.requestCanModifyProject, EditorHttpController.restoreDoc + webRouter.post '/project/:Project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.restoreDoc # Called by the real-time API to load up the current project state. # This is a post request because it's more than just a getting of data. We take actions diff --git a/services/web/app/coffee/Features/Email/EmailSender.coffee b/services/web/app/coffee/Features/Email/EmailSender.coffee index c5ff09b1b0..cb74502827 100644 --- a/services/web/app/coffee/Features/Email/EmailSender.coffee +++ b/services/web/app/coffee/Features/Email/EmailSender.coffee @@ -3,6 +3,8 @@ metrics = require('../../infrastructure/Metrics') Settings = require('settings-sharelatex') nodemailer = require("nodemailer") sesTransport = require('nodemailer-ses-transport') +sgTransport = require('nodemailer-sendgrid-transport') + _ = require("underscore") if Settings.email? and Settings.email.fromAddress? @@ -19,6 +21,9 @@ client = if Settings?.email?.parameters?.AWSAccessKeyID? logger.log "using aws ses for email" nm_client = nodemailer.createTransport(sesTransport(Settings.email.parameters)) +else if Settings?.email?.parameters?.sendgridApiKey? + logger.log "using sendgrid for email" + nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}})) else if Settings?.email?.parameters? smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth") diff --git a/services/web/app/coffee/Features/Errors/ErrorController.coffee b/services/web/app/coffee/Features/Errors/ErrorController.coffee index d0589ba5ed..16b160642a 100644 --- a/services/web/app/coffee/Features/Errors/ErrorController.coffee +++ b/services/web/app/coffee/Features/Errors/ErrorController.coffee @@ -1,5 +1,25 @@ +Errors = require "./Errors" +logger = require "logger-sharelatex" + module.exports = ErrorController = notFound: (req, res)-> - res.statusCode = 404 + res.status(404) res.render 'general/404', - title: "page_not_found" \ No newline at end of file + title: "page_not_found" + + serverError: (req, res)-> + res.status(500) + res.render 'general/500', + title: "Server Error" + + handleError: (error, req, res, next) -> + if error?.code is 'EBADCSRFTOKEN' + logger.warn err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf" + res.sendStatus(403) + return + if error instanceof Errors.NotFoundError + logger.warn {err: error, url: req.url}, "not found error" + ErrorController.notFound req, res + else + logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear" + ErrorController.serverError req, res \ No newline at end of file diff --git a/services/web/app/coffee/errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee similarity index 87% rename from services/web/app/coffee/errors.coffee rename to services/web/app/coffee/Features/Errors/Errors.coffee index 4a29822efc..0bbff1f19b 100644 --- a/services/web/app/coffee/errors.coffee +++ b/services/web/app/coffee/Features/Errors/Errors.coffee @@ -6,5 +6,4 @@ NotFoundError = (message) -> NotFoundError.prototype.__proto__ = Error.prototype module.exports = Errors = - NotFoundError: NotFoundError - + NotFoundError: NotFoundError \ No newline at end of file diff --git a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee index 09790cf99f..1512b53e7e 100644 --- a/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee +++ b/services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee @@ -42,7 +42,14 @@ module.exports = FileStoreHandler = method : "get" uri: "#{@_buildUrl(project_id, file_id)}#{queryString}" timeout:fiveMinsInMs + headers: {} + if query? and query['range']? + rangeText = query['range'] + if rangeText && rangeText.match? && rangeText.match(/\d+-\d+/) + opts.headers['range'] = "bytes=#{query['range']}" readStream = request(opts) + readStream.on "error", (err) -> + logger.err {err, project_id, file_id, query}, "error in file stream" callback(null, readStream) deleteFile: (project_id, file_id, callback)-> diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 9a04fafd15..f810067991 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -5,7 +5,6 @@ projectDuplicator = require("./ProjectDuplicator") projectCreationHandler = require("./ProjectCreationHandler") editorController = require("../Editor/EditorController") metrics = require('../../infrastructure/Metrics') -Project = require('../../models/Project').Project User = require('../../models/User').User TagsHandler = require("../Tags/TagsHandler") SubscriptionLocator = require("../Subscription/SubscriptionLocator") @@ -13,10 +12,12 @@ NotificationsHandler = require("../Notifications/NotificationsHandler") LimitationsManager = require("../Subscription/LimitationsManager") _ = require("underscore") Settings = require("settings-sharelatex") -SecurityManager = require("../../managers/SecurityManager") +AuthorizationManager = require("../Authorization/AuthorizationManager") fs = require "fs" InactiveProjectManager = require("../InactiveData/InactiveProjectManager") ProjectUpdateHandler = require("./ProjectUpdateHandler") +ProjectGetter = require("./ProjectGetter") +PrivilegeLevels = require("../Authorization/PrivilegeLevels") module.exports = ProjectController = @@ -41,6 +42,14 @@ module.exports = ProjectController = jobs.push (callback) -> editorController.setRootDoc project_id, req.body.rootDocId, callback + async.series jobs, (error) -> + return next(error) if error? + res.sendStatus(204) + + updateProjectAdminSettings: (req, res, next) -> + project_id = req.params.Project_id + + jobs = [] if req.body.publicAccessLevel? jobs.push (callback) -> editorController.setPublicAccessLevel project_id, req.body.publicAccessLevel, callback @@ -129,7 +138,7 @@ module.exports = ProjectController = notifications: (cb)-> NotificationsHandler.getUserNotifications user_id, cb projects: (cb)-> - Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb + ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb hasSubscription: (cb)-> LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb user: (cb) -> @@ -179,23 +188,23 @@ module.exports = ProjectController = anonymous = false else anonymous = true - user_id = 'openUser' + user_id = null project_id = req.params.Project_id logger.log project_id:project_id, "loading editor" async.parallel { project: (cb)-> - Project.findPopulatedById project_id, cb + ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb user: (cb)-> - if user_id == 'openUser' + if !user_id? cb null, defaultSettingsForAnonymousUser(user_id) else User.findById user_id, (err, user)-> logger.log project_id:project_id, user_id:user_id, "got user" cb err, user subscription: (cb)-> - if user_id == 'openUser' + if !user_id? return cb() SubscriptionLocator.getUsersSubscription user_id, cb activate: (cb)-> @@ -216,8 +225,9 @@ module.exports = ProjectController = daysSinceLastUpdated = (new Date() - project.lastUpdated) /86400000 logger.log project_id:project_id, daysSinceLastUpdated:daysSinceLastUpdated, "got db results for loading editor" - SecurityManager.userCanAccessProject user, project, (canAccess, privilegeLevel)-> - if !canAccess + AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel)-> + return next(error) if error? + if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE return res.sendStatus 401 if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt? @@ -228,10 +238,9 @@ module.exports = ProjectController = title: project.name priority_title: true bodyClasses: ["editor"] - project : project project_id : project._id user : { - id : user.id + id : user_id email : user.email first_name : user.first_name last_name : user.last_name @@ -239,6 +248,8 @@ module.exports = ProjectController = subscription : freeTrial: {allowed: allowedFreeTrial} featureSwitches: user.featureSwitches + features: user.features + refProviders: user.refProviders } userSettings: { mode : user.ace.mode diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee index cdbc933135..8ba8a65845 100644 --- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee @@ -5,6 +5,7 @@ documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') tagsHandler = require("../Tags/TagsHandler") async = require("async") FileStoreHandler = require("../FileStore/FileStoreHandler") +CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") module.exports = ProjectDeleter = @@ -44,16 +45,10 @@ module.exports = ProjectDeleter = (cb)-> documentUpdaterHandler.flushProjectToMongoAndDelete project_id, cb (cb)-> - tagsHandler.removeProjectFromAllTags project.owner_ref, project_id, (err)-> + CollaboratorsHandler.getMemberIds project_id, (error, member_ids = []) -> + for member_id in member_ids + tagsHandler.removeProjectFromAllTags member_id, project_id, (err)-> cb() #doesn't matter if this fails or the order it happens in - (cb)-> - project.collaberator_refs.forEach (collaberator_ref)-> - tagsHandler.removeProjectFromAllTags collaberator_ref, project_id, -> - cb() - (cb)-> - project.readOnly_refs.forEach (readOnly_ref)-> - tagsHandler.removeProjectFromAllTags readOnly_ref, project_id, -> - cb() (cb)-> Project.update {_id:project_id}, { $set: { archived: true }}, cb ], (err)-> diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index 3b3233157d..7bd1d33561 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -4,6 +4,7 @@ Project = require('../../models/Project').Project logger = require("logger-sharelatex") tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender' _ = require("underscore") +PublicAccessLevels = require("../Authorization/PublicAccessLevels") module.exports = @@ -49,6 +50,6 @@ module.exports = setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)-> logger.log project_id: project_id, level: newAccessLevel, "set public access level" - if project_id? && newAccessLevel? and _.include ['readOnly', 'readAndWrite', 'private'], newAccessLevel + if project_id? && newAccessLevel? and _.include [PublicAccessLevels.READ_ONLY, PublicAccessLevels.READ_AND_WRITE, PublicAccessLevels.PRIVATE], newAccessLevel Project.update {_id:project_id},{publicAccesLevel:newAccessLevel}, (err)-> callback() \ No newline at end of file diff --git a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee index bf775caeeb..6cae962b04 100644 --- a/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDuplicator.coffee @@ -75,7 +75,7 @@ module.exports = ProjectDuplicator = return callback(err) {originalProject, newProject, originalRootDoc, docContentsArray} = results - originalRootDoc = originalRootDoc[0] + originalRootDoc = originalRootDoc?[0] docContents = {} for docContent in docContentsArray diff --git a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee index f12a7d548e..4e4991a855 100644 --- a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee @@ -1,11 +1,7 @@ _ = require("underscore") module.exports = ProjectEditorHandler = - buildProjectModelView: (project, options) -> - options ||= {} - if !options.includeUsers? - options.includeUsers = true - + buildProjectModelView: (project, members) -> result = _id : project._id name : project.name @@ -18,40 +14,27 @@ module.exports = ProjectEditorHandler = spellCheckLanguage: project.spellCheckLanguage deletedByExternalDataSource : project.deletedByExternalDataSource || false deletedDocs: project.deletedDocs + members: [] + + owner = null + for member in members + if member.privilegeLevel == "owner" + owner = member.user + else + result.members.push @buildUserModelView member.user, member.privilegeLevel + if owner? + result.owner = @buildUserModelView owner, "owner" - if options.includeUsers - result.features = - collaborators: -1 # Infinite - versioning: false - dropbox:false - compileTimeout: 60 - compileGroup:"standard" - templates: false - references: false + result.features = _.defaults(owner?.features or {}, { + collaborators: -1 # Infinite + versioning: false + dropbox:false + compileTimeout: 60 + compileGroup:"standard" + templates: false + references: false + }) - if project.owner_ref.features? - if project.owner_ref.features.collaborators? - result.features.collaborators = project.owner_ref.features.collaborators - if project.owner_ref.features.versioning? - result.features.versioning = project.owner_ref.features.versioning - if project.owner_ref.features.dropbox? - result.features.dropbox = project.owner_ref.features.dropbox - if project.owner_ref.features.compileTimeout? - result.features.compileTimeout = project.owner_ref.features.compileTimeout - if project.owner_ref.features.compileGroup? - result.features.compileGroup = project.owner_ref.features.compileGroup - if project.owner_ref.features.templates? - result.features.templates = project.owner_ref.features.templates - if project.owner_ref.features.references? - result.features.references = project.owner_ref.features.references - - - result.owner = @buildUserModelView project.owner_ref, "owner" - result.members = [] - for ref in project.readOnly_refs - result.members.push @buildUserModelView ref, "readOnly" - for ref in project.collaberator_refs - result.members.push @buildUserModelView ref, "readAndWrite" return result buildUserModelView: (user, privileges) -> @@ -63,12 +46,12 @@ module.exports = ProjectEditorHandler = signUpDate : user.signUpDate buildFolderModelView: (folder) -> - fileRefs = _.filter folder.fileRefs, (file)-> file? + fileRefs = _.filter (folder.fileRefs or []), (file)-> file? _id : folder._id name : folder.name - folders : @buildFolderModelView childFolder for childFolder in folder.folders + folders : @buildFolderModelView childFolder for childFolder in (folder.folders or []) fileRefs : @buildFileModelView file for file in fileRefs - docs : @buildDocModelView doc for doc in folder.docs + docs : @buildDocModelView doc for doc in (folder.docs or []) buildFileModelView: (file) -> _id : file._id diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 9b771625b3..946a57d527 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -4,7 +4,7 @@ Doc = require('../../models/Doc').Doc Folder = require('../../models/Folder').Folder File = require('../../models/File').File FileStoreHandler = require("../FileStore/FileStoreHandler") -Errors = require "../../errors" +Errors = require "../Errors/Errors" tpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender') projectLocator = require('./ProjectLocator') path = require "path" @@ -22,7 +22,9 @@ module.exports = ProjectEntityHandler = folders = {} processFolder = (basePath, folder) -> folders[basePath] = folder - processFolder path.join(basePath, childFolder.name), childFolder for childFolder in folder.folders + for childFolder in (folder.folders or []) + if childFolder.name? + processFolder path.join(basePath, childFolder.name), childFolder ProjectGetter.getProjectWithoutDocLines project_id, (err, project) -> return callback(err) if err? @@ -43,11 +45,11 @@ module.exports = ProjectEntityHandler = for docContent in docContentsArray docContents[docContent._id] = docContent - ProjectEntityHandler.getAllFolders project_id, (error, folders) -> + ProjectEntityHandler.getAllFolders project_id, (error, folders = {}) -> return callback(error) if error? docs = {} for folderPath, folder of folders - for doc in folder.docs + for doc in (folder.docs or []) content = docContents[doc._id.toString()] if content? docs[path.join(folderPath, doc.name)] = { @@ -61,11 +63,11 @@ module.exports = ProjectEntityHandler = getAllFiles: (project_id, callback) -> logger.log project_id:project_id, "getting all files for project" - @getAllFolders project_id, (err, folders) -> + @getAllFolders project_id, (err, folders = {}) -> return callback(err) if err? files = {} for folderPath, folder of folders - for file in folder.fileRefs + for file in (folder.fileRefs or []) if file? files[path.join(folderPath, file.name)] = file callback null, files @@ -344,15 +346,18 @@ module.exports = ProjectEntityHandler = return callback(error) if error? self._removeElementFromMongoArray Project, project_id, path.mongo, (err)-> return callback(err) if err? - ProjectEntityHandler._putElement project, destinationFolder_id, entity, entityType, (err, result)-> + # We've updated the project structure by removing the element, so must refresh it. + ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=> return callback(err) if err? - opts = - project_id:project_id - project_name:project.name - startPath:path.fileSystem - endPath:result.path.fileSystem, - rev:entity.rev - tpdsUpdateSender.moveEntity opts, callback + ProjectEntityHandler._putElement project, destinationFolder_id, entity, entityType, (err, result)-> + return callback(err) if err? + opts = + project_id:project_id + project_name:project.name + startPath:path.fileSystem + endPath:result.path.fileSystem, + rev:entity.rev + tpdsUpdateSender.moveEntity opts, callback deleteEntity: (project_id, entity_id, entityType, callback = (error) ->)-> self = @ @@ -501,7 +506,7 @@ module.exports = ProjectEntityHandler = elementType = "fileRefs" return elementType - if !element? + if !element? or !element._id? e = new Error("no element passed to be inserted") logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as it was null" return callback(e) @@ -520,13 +525,13 @@ module.exports = ProjectEntityHandler = newPath = fileSystem: "#{path.fileSystem}/#{element.name}" mongo: path.mongo - logger.log project_id: project._id, element_id: element._id, fileType: type, folder_id: folder_id, "adding element to project" id = element._id+'' element._id = require('mongoose').Types.ObjectId(id) conditions = _id:project._id mongopath = "#{path.mongo}.#{type}" update = "$push":{} update["$push"][mongopath] = element + logger.log project_id: project._id, element_id: element._id, fileType: type, folder_id: folder_id, mongopath:mongopath, "adding element to project" Project.update conditions, update, {}, (err)-> if err? logger.err err: err, project_id: project._id, 'error saving in putElement project' diff --git a/services/web/app/coffee/Features/Project/ProjectGetter.coffee b/services/web/app/coffee/Features/Project/ProjectGetter.coffee index d8bb1e457a..af6178d06b 100644 --- a/services/web/app/coffee/Features/Project/ProjectGetter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectGetter.coffee @@ -2,11 +2,9 @@ mongojs = require("../../infrastructure/mongojs") db = mongojs.db ObjectId = mongojs.ObjectId async = require "async" -Errors = require("../../errors") +Project = require("../../models/Project").Project logger = require("logger-sharelatex") - - module.exports = ProjectGetter = EXCLUDE_DEPTH: 8 @@ -51,42 +49,11 @@ module.exports = ProjectGetter = return callback(err) callback(null, project?[0]) - populateProjectWithUsers: (project, callback=(error, project) ->) -> - # eventually this should be in a UserGetter.getUser module - getUser = (user_id, callback=(error, user) ->) -> - unless user_id instanceof ObjectId - user_id = ObjectId(user_id) - db.users.find _id: user_id, (error, users = []) -> - callback error, users[0] - - jobs = [] - jobs.push (callback) -> - getUser project.owner_ref, (error, user) -> + + findAllUsersProjects: (user_id, fields, callback = (error, ownedProjects, readAndWriteProjects, readOnlyProjects) ->) -> + CollaboratorsHandler = require "../Collaborators/CollaboratorsHandler" + Project.find {owner_ref: user_id}, fields, (error, projects) -> + return callback(error) if error? + CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, fields, (error, readAndWriteProjects, readOnlyProjects) -> return callback(error) if error? - if user? - project.owner_ref = user - callback null, project - - readOnly_refs = project.readOnly_refs - project.readOnly_refs = [] - for readOnly_ref in readOnly_refs - do (readOnly_ref) -> - jobs.push (callback) -> - getUser readOnly_ref, (error, user) -> - return callback(error) if error? - if user? - project.readOnly_refs.push user - callback null, project - - collaberator_refs = project.collaberator_refs - project.collaberator_refs = [] - for collaberator_ref in collaberator_refs - do (collaberator_ref) -> - jobs.push (callback) -> - getUser collaberator_ref, (error, user) -> - return callback(error) if error? - if user? - project.collaberator_refs.push user - callback null, project - - async.parallelLimit jobs, 3, (error) -> callback error, project + callback null, projects, readAndWriteProjects, readOnlyProjects diff --git a/services/web/app/coffee/Features/Project/ProjectLocator.coffee b/services/web/app/coffee/Features/Project/ProjectLocator.coffee index 7d1eb8f749..9cd80d7c72 100644 --- a/services/web/app/coffee/Features/Project/ProjectLocator.coffee +++ b/services/web/app/coffee/Features/Project/ProjectLocator.coffee @@ -1,9 +1,10 @@ Project = require('../../models/Project').Project ProjectGetter = require("./ProjectGetter") -Errors = require "../../errors" +Errors = require "../Errors/Errors" _ = require('underscore') logger = require('logger-sharelatex') async = require('async') +ProjectGetter = require "./ProjectGetter" module.exports = ProjectLocator = findElement: (options, _callback = (err, element, path, parentFolder)->)-> @@ -60,7 +61,13 @@ module.exports = ProjectLocator = findRootDoc : (opts, callback)-> getRootDoc = (project)=> if project.rootDoc_id? - @findElement {project:project, element_id:project.rootDoc_id, type:"docs"}, callback + @findElement {project:project, element_id:project.rootDoc_id, type:"docs"}, (error, args...) -> + if error? + if error instanceof Errors.NotFoundError + return callback null, null + else + return callback error + return callback null, args... else callback null, null {project, project_id} = opts @@ -131,7 +138,8 @@ module.exports = ProjectLocator = async.waterfall jobs, callback findUsersProjectByName: (user_id, projectName, callback)-> - Project.findAllUsersProjects user_id, 'name archived', (err, projects, collabertions=[])-> + ProjectGetter.findAllUsersProjects user_id, 'name archived', (err, projects, collabertions=[])-> + return callback(error) if error? projects = projects.concat(collabertions) projectName = projectName.toLowerCase() project = _.find projects, (project)-> diff --git a/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee b/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee index 0127e5681e..b28979aaf0 100644 --- a/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee +++ b/services/web/app/coffee/Features/Referal/ReferalAllocator.coffee @@ -35,7 +35,7 @@ module.exports = ReferalAllocator = query = _id: user_id User.findOne query, (error, user) -> return callback(error) if error - return callback(new Error("user not found")) if !user? + return callback(new Error("user not found #{user_id} for assignBonus")) if !user? logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus" if user.refered_user_count? and user.refered_user_count > 0 newFeatures = ReferalAllocator._calculateFeatures(user) diff --git a/services/web/app/coffee/Features/References/ReferencesHandler.coffee b/services/web/app/coffee/Features/References/ReferencesHandler.coffee index 0b2ddb1e26..7894ab6915 100644 --- a/services/web/app/coffee/Features/References/ReferencesHandler.coffee +++ b/services/web/app/coffee/Features/References/ReferencesHandler.coffee @@ -1,7 +1,8 @@ logger = require("logger-sharelatex") request = require("request") settings = require("settings-sharelatex") -Project = require("../../models/Project").Project +ProjectGetter = require "../Project/ProjectGetter" +UserGetter = require "../User/UserGetter" DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') U = require('underscore') Async = require('async') @@ -15,42 +16,56 @@ module.exports = ReferencesHandler = _buildDocUrl: (projectId, docId) -> "#{settings.apis.docstore.url}/project/#{projectId}/doc/#{docId}/raw" + _buildFileUrl: (projectId, fileId) -> + "#{settings.apis.filestore.url}/project/#{projectId}/file/#{fileId}" + + _findBibFileIds: (project) -> + ids = [] + _process = (folder) -> + (folder.fileRefs or []).forEach (file) -> + if file?.name?.match(/^.*\.bib$/) + ids.push(file._id) + (folder.folders or []).forEach (folder) -> + _process(folder) + (project.rootFolder or []).forEach (rootFolder) -> + _process(rootFolder) + return ids + _findBibDocIds: (project) -> ids = [] - _process = (folder) -> - folder.docs.forEach (doc) -> + (folder.docs or []).forEach (doc) -> if doc?.name?.match(/^.*\.bib$/) ids.push(doc._id) - folder.folders.forEach (folder) -> + (folder.folders or []).forEach (folder) -> _process(folder) - - project.rootFolder.forEach (rootFolder) -> + (project.rootFolder or []).forEach (rootFolder) -> _process(rootFolder) - return ids _isFullIndex: (project, callback = (err, result) ->) -> - owner = project.owner_ref - callback(null, owner.features.references == true) + UserGetter.getUser project.owner_ref, { features: true }, (err, owner) -> + return callback(err) if err? + callback(null, owner?.features?.references == true) indexAll: (projectId, callback=(err, data)->) -> - Project.findPopulatedById projectId, (err, project) -> + ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) -> if err logger.err {err, projectId}, "error finding project" return callback(err) logger.log {projectId}, "indexing all bib files in project" docIds = ReferencesHandler._findBibDocIds(project) - ReferencesHandler._doIndexOperation(projectId, project, docIds, callback) + fileIds = ReferencesHandler._findBibFileIds(project) + ReferencesHandler._doIndexOperation(projectId, project, docIds, fileIds, callback) index: (projectId, docIds, callback=(err, data)->) -> - Project.findPopulatedById projectId, (err, project) -> + ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) -> if err logger.err {err, projectId}, "error finding project" return callback(err) - ReferencesHandler._doIndexOperation(projectId, project, docIds, callback) + ReferencesHandler._doIndexOperation(projectId, project, docIds, [], callback) - _doIndexOperation: (projectId, project, docIds, callback) -> + _doIndexOperation: (projectId, project, docIds, fileIds, callback) -> ReferencesHandler._isFullIndex project, (err, isFullIndex) -> if err logger.err {err, projectId}, "error checking whether to do full index" @@ -65,11 +80,14 @@ module.exports = ReferencesHandler = return callback(err) bibDocUrls = docIds.map (docId) -> ReferencesHandler._buildDocUrl projectId, docId + bibFileUrls = fileIds.map (fileId) -> + ReferencesHandler._buildFileUrl projectId, fileId + allUrls = bibDocUrls.concat(bibFileUrls) logger.log {projectId, isFullIndex, docIds, bibDocUrls}, "sending request to references service" request.post { url: "#{settings.apis.references.url}/project/#{projectId}/index" json: - docUrls: bibDocUrls + docUrls: allUrls fullIndex: isFullIndex }, (err, res, data) -> if err diff --git a/services/web/app/coffee/Features/Security/AuthorizationManager.coffee b/services/web/app/coffee/Features/Security/AuthorizationManager.coffee deleted file mode 100644 index 0ec4985f35..0000000000 --- a/services/web/app/coffee/Features/Security/AuthorizationManager.coffee +++ /dev/null @@ -1,38 +0,0 @@ -SecurityManager = require '../../managers/SecurityManager' - -module.exports = AuthorizationManager = - getPrivilegeLevelForProject: ( - project, user, - callback = (error, canAccess, privilegeLevel)-> - ) -> - # This is not tested because eventually this function should be brought into - # this module. - SecurityManager.userCanAccessProject user, project, (canAccess, privilegeLevel) -> - if canAccess - callback null, true, privilegeLevel - else - callback null, false - - setPrivilegeLevelOnClient: (client, privilegeLevel) -> - client.set("privilege_level", privilegeLevel) - - ensureClientCanViewProject: (client, callback = (error, project_id)->) -> - @ensureClientHasPrivilegeLevelForProject client, ["owner", "readAndWrite", "readOnly"], callback - - ensureClientCanEditProject: (client, callback = (error, project_id)->) -> - @ensureClientHasPrivilegeLevelForProject client, ["owner", "readAndWrite"], callback - - ensureClientCanAdminProject: (client, callback = (error, project_id)->) -> - @ensureClientHasPrivilegeLevelForProject client, ["owner"], callback - - ensureClientHasPrivilegeLevelForProject: (client, levels, callback = (error, project_id)->) -> - client.get "privilege_level", (error, level) -> - return callback(error) if error? - if level? - client.get "project_id", (error, project_id) -> - return callback(error) if error? - if project_id? - if levels.indexOf(level) > -1 - callback null, project_id - - diff --git a/services/web/app/coffee/Features/Spelling/SpellingController.coffee b/services/web/app/coffee/Features/Spelling/SpellingController.coffee index 86683e2341..973655701b 100644 --- a/services/web/app/coffee/Features/Spelling/SpellingController.coffee +++ b/services/web/app/coffee/Features/Spelling/SpellingController.coffee @@ -12,4 +12,5 @@ module.exports = SpellingController = request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS) .on "error", (error) -> logger.error err: error, "Spelling API error" + res.status(500).end() .pipe(res) diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee index d323c19a9d..e2f633e185 100644 --- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee +++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee @@ -3,6 +3,7 @@ Project = require("../../models/Project").Project User = require("../../models/User").User SubscriptionLocator = require("./SubscriptionLocator") Settings = require("settings-sharelatex") +CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") module.exports = @@ -13,16 +14,11 @@ module.exports = callback null, owner.features.collaborators else callback null, Settings.defaultPlanCode.collaborators - - currentNumberOfCollaboratorsInProject: (project_id, callback) -> - Project.findById project_id, 'collaberator_refs readOnly_refs', (error, project) -> - return callback(error) if error? - callback null, (project.collaberator_refs.length + project.readOnly_refs.length) canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) -> @allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) => return callback(error) if error? - @currentNumberOfCollaboratorsInProject project_id, (error, current_number) => + CollaboratorsHandler.getCollaboratorCount project_id, (error, current_number) => return callback(error) if error? if current_number + x_collaborators <= allowed_number or allowed_number < 0 callback null, true diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index e2e021bd97..2a1e576231 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -1,4 +1,4 @@ -SecurityManager = require '../../managers/SecurityManager' +AuthenticationController = require '../Authentication/AuthenticationController' SubscriptionHandler = require './SubscriptionHandler' PlansLocator = require("./PlansLocator") SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') @@ -31,7 +31,7 @@ module.exports = SubscriptionController = #get to show the recurly.js page paymentPage: (req, res, next) -> - SecurityManager.getCurrentUser req, (error, user) => + AuthenticationController.getLoggedInUser req, (error, user) => return next(error) if error? plan = PlansLocator.findLocalPlanInSettings(req.query.planCode) LimitationsManager.userHasSubscription user, (err, hasSubscription)-> @@ -48,7 +48,7 @@ module.exports = SubscriptionController = subscription: plan_code : req.query.planCode currency: currency - account_code: user.id + account_code: user._id }, (error, signature) -> return next(error) if error? res.render "subscriptions/new", @@ -64,28 +64,16 @@ module.exports = SubscriptionController = showCouponField: req.query.scf showVatField: req.query.svf couponCode: req.query.cc or "" - subscriptionFormOptions: JSON.stringify - acceptedCards: ['discover', 'mastercard', 'visa'] - target : "#subscribeForm" - signature : signature - planCode : req.query.planCode - successURL : "#{Settings.siteUrl}/user/subscription/create?_csrf=#{req.session._csrf}" - accountCode : user.id - enableCoupons: true - acceptPaypal: true - account : - firstName : user.first_name - lastName : user.last_name - email : user.email + userSubscriptionPage: (req, res, next) -> - SecurityManager.getCurrentUser req, (error, user) => + AuthenticationController.getLoggedInUser req, (error, user) => return next(error) if error? LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)-> groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user) if subscription?.customAccount - logger.log user: user, "redirecting to plans" + logger.log user: user, "redirecting to custom account page" res.redirect "/user/subscription/custom_account" else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember logger.log user:user, "redirecting to group subscription invite page" @@ -109,22 +97,26 @@ module.exports = SubscriptionController = userCustomSubscriptionPage: (req, res, next)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)-> + if !subscription? + err = new Error("subscription null for custom account, user:#{user?._id}") + logger.warn err:err, "subscription is null for custom accounts page" + return next(err) res.render "subscriptions/custom_account", title: "your_subscription" subscription: subscription editBillingDetailsPage: (req, res, next) -> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> return next(error) if error? LimitationsManager.userHasSubscription user, (err, hasSubscription)-> if !hasSubscription res.redirect "/user/subscription" else RecurlyWrapper.sign { - account_code: user.id + account_code: user._id }, (error, signature) -> return next(error) if error? res.render "subscriptions/edit-billing-details", @@ -135,10 +127,10 @@ module.exports = SubscriptionController = signature : signature successURL : "#{Settings.siteUrl}/user/subscription/update" user : - id : user.id + id : user._id createSubscription: (req, res, next)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> return callback(error) if error? recurly_token_id = req.body.recurly_token_id subscriptionDetails = req.body.subscriptionDetails @@ -150,14 +142,14 @@ module.exports = SubscriptionController = res.sendStatus 201 successful_subscription: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) => + AuthenticationController.getLoggedInUser req, (error, user) => SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) -> res.render "subscriptions/successful_subscription", title: "thank_you" subscription:subscription cancelSubscription: (req, res, next) -> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> logger.log user_id:user._id, "canceling subscription" return next(error) if error? SubscriptionHandler.cancelSubscription user, (err)-> @@ -166,7 +158,7 @@ module.exports = SubscriptionController = res.redirect "/user/subscription" updateSubscription: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> return next(error) if error? planCode = req.body.plan_code logger.log planCode: planCode, user_id:user._id, "updating subscription" @@ -176,7 +168,7 @@ module.exports = SubscriptionController = res.redirect "/user/subscription" reactivateSubscription: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> logger.log user_id:user._id, "reactivating subscription" return next(error) if error? SubscriptionHandler.reactivateSubscription user, (err)-> @@ -195,7 +187,7 @@ module.exports = SubscriptionController = res.sendStatus 200 renderUpgradeToAnnualPlanPage: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)-> planCode = subscription?.planCode.toLowerCase() if planCode?.indexOf("annual") != -1 @@ -212,7 +204,7 @@ module.exports = SubscriptionController = planName: planName processUpgradeToAnnualPlan: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> {planName} = req.body coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName] annualPlanName = "#{planName}-annual" @@ -225,7 +217,7 @@ module.exports = SubscriptionController = res.sendStatus 200 extendTrial: (req, res)-> - SecurityManager.getCurrentUser req, (error, user) -> + AuthenticationController.getLoggedInUser req, (error, user) -> LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)-> SubscriptionHandler.extendTrial subscription, 14, (err)-> if err? diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee index af8b2414f3..5b20608eea 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee @@ -4,6 +4,7 @@ SubscriptionLocator = require("./SubscriptionLocator") ErrorsController = require("../Errors/ErrorController") SubscriptionDomainHandler = require("./SubscriptionDomainHandler") _ = require("underscore") +async = require("async") module.exports = @@ -53,19 +54,26 @@ module.exports = subscription: subscription renderGroupInvitePage: (req, res)-> - subscription_id = req.params.subscription_id + group_subscription_id = req.params.subscription_id user_id = req.session.user._id - licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id) + licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id) if !licence? return ErrorsController.notFound(req, res) - SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.subscription_id, (err, partOfGroup)-> + jobs = + partOfGroup: (cb)-> + SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb + subscription: (cb)-> + SubscriptionLocator.getUsersSubscription user_id, cb + async.series jobs, (err, results)-> + {partOfGroup, subscription} = results if partOfGroup return res.redirect("/user/subscription/custom_account") else res.render "subscriptions/group/invite", title: "Group Invitation" - subscription_id:subscription_id + group_subscription_id:group_subscription_id licenceName:licence.name + has_personal_subscription: subscription? beginJoinGroup: (req, res)-> subscription_id = req.params.subscription_id diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee index 9285f8e575..d73a35a69d 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee @@ -22,4 +22,7 @@ module.exports = Subscription.findOne _id:subscription_id, callback getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)-> - Subscription.findOne member_ids: user_id, _id:subscription_id, {_id:1}, callback + Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback + + getGroupSubscriptionMemberOf: (user_id, callback)-> + Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback \ No newline at end of file diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index 18f9058b67..a4a0864d4f 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -11,19 +11,18 @@ ReferalAllocator = require("../Referal/ReferalAllocator") oneMonthInSeconds = 60 * 60 * 24 * 30 -module.exports = +module.exports = SubscriptionUpdater = syncSubscription: (recurlySubscription, adminUser_id, callback) -> - self = @ logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist" SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)-> if subscription? logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist" - self._updateSubscription recurlySubscription, subscription, callback + SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback else logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does not exist, creating a new one" - self._createNewSubscription adminUser_id, (err, subscription)-> - self._updateSubscription recurlySubscription, subscription, callback + SubscriptionUpdater._createNewSubscription adminUser_id, (err, subscription)-> + SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback addUserToGroup: (adminUser_id, user_id, callback)-> logger.log adminUser_id:adminUser_id, user_id:user_id, "adding user into mongo subscription" @@ -46,7 +45,8 @@ module.exports = if err? logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group" return callback(err) - UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, callback + SubscriptionUpdater._setUsersMinimumFeatures user_id, callback + _createNewSubscription: (adminUser_id, callback)-> logger.log adminUser_id:adminUser_id, "creating new subscription" @@ -55,7 +55,7 @@ module.exports = subscription.save (err)-> callback err, subscription - _updateSubscription: (recurlySubscription, subscription, callback)-> + _updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)-> logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription" plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code) if recurlySubscription.state == "expired" @@ -71,11 +71,34 @@ module.exports = subscription.groupPlan = true subscription.membersLimit = plan.membersLimit subscription.save -> - allIds = _.union subscription.members_id, [subscription.admin_id] + allIds = _.union subscription.member_ids, [subscription.admin_id] jobs = allIds.map (user_id)-> return (cb)-> - UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, cb - jobs.push (cb)-> ReferalAllocator.assignBonus subscription.admin_id, cb + SubscriptionUpdater._setUsersMinimumFeatures user_id, cb async.series jobs, callback + _setUsersMinimumFeatures: (user_id, callback)-> + jobs = + subscription: (cb)-> + SubscriptionLocator.getUsersSubscription user_id, cb + groupSubscription: (cb)-> + SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb + async.series jobs, (err, results)-> + if err? + logger.err err:err, user_id:user, "error getting subscription or group for _setUsersMinimumFeatures" + return callback(err) + {subscription, groupSubscription} = results + if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode + logger.log user_id:user_id, "using users subscription plan code for features" + UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback + else if groupSubscription? and groupSubscription.planCode? + logger.log user_id:user_id, "using group which user is memor of for features" + UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback + else + logger.log user_id:user_id, "using default features for user with no subscription or group" + UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)-> + if err? + logger.err err:err, user_id:user_id, "Error setting minimum user feature" + return callback(err) + ReferalAllocator.assignBonus user_id, callback diff --git a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee index 521aabeb0c..2f3b7fb9a8 100644 --- a/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee +++ b/services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee @@ -5,6 +5,7 @@ Project = require('../../models/Project').Project keys = require('../../infrastructure/Keys') metrics = require("../../infrastructure/Metrics") request = require("request") +CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') buildPath = (user_id, project_name, filePath)-> projectPath = path.join(project_name, "/", filePath) @@ -122,9 +123,11 @@ module.exports = TpdsUpdateSender = TpdsUpdateSender._enqueue "poll-dropbox:#{user_id}", "standardHttpRequest", options, callback getProjectsUsersIds = (project_id, callback = (err, owner_id, allUserIds)->)-> - Project.findById project_id, "_id owner_ref readOnly_refs collaberator_refs", (err, project)-> - allUserIds = [].concat(project.collaberator_refs).concat(project.readOnly_refs).concat(project.owner_ref) - callback err, project.owner_ref, allUserIds + Project.findById project_id, "_id owner_ref", (err, project) -> + return callback(err) if err? + CollaboratorsHandler.getMemberIds project_id, (err, member_ids) -> + return callback(err) if err? + callback err, project?.owner_ref, member_ids mergeProjectNameAndPath = (project_name, path)-> if(path.indexOf('/') == 0) diff --git a/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee b/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee index a615810fe6..645828ca6d 100644 --- a/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee +++ b/services/web/app/coffee/Features/Uploads/ArchiveManager.coffee @@ -25,33 +25,31 @@ module.exports = ArchiveManager = error += chunk unzip.on "error", (err) -> - logger.error {err, source, destination}, "unzip failed" + logger.error {err, source}, "unzip failed" if err.code == "ENOENT" logger.error "unzip command not found. Please check the unzip command is installed" callback(err) - unzip.on "exit", () -> + unzip.on "close", (exitCode) -> if error? error = new Error(error) - logger.error err:error, source: source, destination: destination, "error checking zip size" + logger.error err:error, source: source, "error checking zip size" lines = output.split("\n") lastLine = lines[lines.length - 2]?.trim() totalSizeInBytes = lastLine?.split(" ")?[0] - totalSizeInBytes = parseInt(totalSizeInBytes) + totalSizeInBytesAsInt = parseInt(totalSizeInBytes) - if !totalSizeInBytes? or isNaN(totalSizeInBytes) - logger.err source:source, "error getting bytes of zip" - return callback(new Error("something went wrong")) + if !totalSizeInBytesAsInt? or isNaN(totalSizeInBytesAsInt) + logger.err source:source, totalSizeInBytes:totalSizeInBytes, totalSizeInBytesAsInt:totalSizeInBytesAsInt, lastLine:lastLine, exitCode:exitCode, "error getting bytes of zip" + return callback(new Error("error getting bytes of zip")) isTooLarge = totalSizeInBytes > (ONE_MEG * 300) callback(error, isTooLarge) - - extractZipArchive: (source, destination, _callback = (err) ->) -> callback = (args...) -> @@ -87,7 +85,7 @@ module.exports = ArchiveManager = logger.error "unzip command not found. Please check the unzip command is installed" callback(err) - unzip.on "exit", () -> + unzip.on "close", () -> timer.done() if error? error = new Error(error) diff --git a/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee b/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee index c82144acc0..d87d271a7f 100644 --- a/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee +++ b/services/web/app/coffee/Features/Uploads/UploadsRouter.coffee @@ -1,4 +1,4 @@ -SecurityManager = require('../../managers/SecurityManager') +AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear') AuthenticationController = require('../Authentication/AuthenticationController') ProjectUploadController = require "./ProjectUploadController" RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear') @@ -16,6 +16,7 @@ module.exports = maxRequests: 200 timeInterval: 60 * 30 }), - SecurityManager.requestCanModifyProject, + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanWriteProjectContent, ProjectUploadController.uploadFile diff --git a/services/web/app/coffee/Features/User/UserInfoController.coffee b/services/web/app/coffee/Features/User/UserInfoController.coffee index ac7556bc90..edaf836c80 100644 --- a/services/web/app/coffee/Features/User/UserInfoController.coffee +++ b/services/web/app/coffee/Features/User/UserInfoController.coffee @@ -6,10 +6,6 @@ sanitize = require('sanitizer') module.exports = UserController = getLoggedInUsersPersonalInfo: (req, res, next = (error) ->) -> - # this is funcky as hell, we don't use the current session to get the user - # we use the auth token, actually destroying session from the chat api request - if req.query?.auth_token? - req.session?.destroy() logger.log user: req.user, "reciving request for getting logged in users personal info" return next(new Error("User is not logged in")) if !req.user? UserGetter.getUser req.user._id, { diff --git a/services/web/app/coffee/Features/Wiki/WikiController.coffee b/services/web/app/coffee/Features/Wiki/WikiController.coffee index 9016a4dd41..c75ee06f86 100644 --- a/services/web/app/coffee/Features/Wiki/WikiController.coffee +++ b/services/web/app/coffee/Features/Wiki/WikiController.coffee @@ -4,7 +4,7 @@ logger = require("logger-sharelatex") ErrorController = require "../Errors/ErrorController" _ = require("underscore") AuthenticationController = require("../Authentication/AuthenticationController") - +async = require("async") other_lngs = ["es"] module.exports = WikiController = @@ -28,19 +28,22 @@ module.exports = WikiController = lngPage = "#{page}_#{req.lng}" else lngPage = page - - WikiController._getPageContent "Contents", (error, contents) -> + jobs = + contents: (cb)-> + WikiController._getPageContent "Contents", cb + pageData: (cb)-> + WikiController._getPageContent lngPage, cb + async.parallel jobs, (error, results)-> return next(error) if error? - WikiController._getPageContent lngPage, (error, pageData) -> - return next(error) if error? - if pageData.content?.length > 280 - if _.include(other_lngs, req.lng) - pageData.title = pageData.title.slice(0, pageData.title.length - (req.lng.length+1) ) + {pageData, contents} = results + if pageData.content?.length > 280 + if _.include(other_lngs, req.lng) + pageData.title = pageData.title.slice(0, pageData.title.length - (req.lng.length+1) ) + WikiController._renderPage(pageData, contents, res) + else + WikiController._getPageContent page, (error, pageData) -> + return next(error) if error? WikiController._renderPage(pageData, contents, res) - else - WikiController._getPageContent page, (error, pageData) -> - return next(error) if error? - WikiController._renderPage(pageData, contents, res) @@ -62,7 +65,6 @@ module.exports = WikiController = result = content: data?.parse?.text?['*'] title: data?.parse?.title - callback null, result diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 1fd1aa10a2..faba1e4623 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -64,6 +64,12 @@ module.exports = (app, webRouter, apiRouter)-> Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2) next() + webRouter.use (req, res, next)-> + res.locals.getUserEmail = -> + email = req?.session?.user?.email or "" + return email + next() + webRouter.use (req, res, next)-> res.locals.formatProjectPublicAccessLevel = (privilegeLevel)-> formatedPrivileges = private:"Private", readOnly:"Public: Read Only", readAndWrite:"Public: Read and Write" diff --git a/services/web/app/coffee/infrastructure/Server.coffee b/services/web/app/coffee/infrastructure/Server.coffee index 4a035ba007..11a3b5237e 100644 --- a/services/web/app/coffee/infrastructure/Server.coffee +++ b/services/web/app/coffee/infrastructure/Server.coffee @@ -30,6 +30,8 @@ OldAssetProxy = require("./OldAssetProxy") translations = require("translations-sharelatex").setup(Settings.i18n) Modules = require "./Modules" +ErrorController = require "../Features/Errors/ErrorController" + 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) @@ -123,7 +125,7 @@ apiRouter.get "/profile", (req, res) -> , time app.get "/heapdump", (req, res)-> - require('heapdump').writeSnapshot '/tmp/' + Date.now() + '.clsi.heapsnapshot', (err, filename)-> + require('heapdump').writeSnapshot '/tmp/' + Date.now() + '.web.heapsnapshot', (err, filename)-> res.send filename logger.info ("creating HTTP server").yellow @@ -136,6 +138,8 @@ app.use(webRouter) router = new Router(webRouter, apiRouter) +app.use ErrorController.handleError + module.exports = app: app server: server diff --git a/services/web/app/coffee/managers/SecurityManager.coffee b/services/web/app/coffee/managers/SecurityManager.coffee deleted file mode 100644 index 699c307679..0000000000 --- a/services/web/app/coffee/managers/SecurityManager.coffee +++ /dev/null @@ -1,194 +0,0 @@ -logger = require('logger-sharelatex') -crypto = require 'crypto' -Assert = require 'assert' -Settings = require 'settings-sharelatex' -User = require('../models/User').User -Project = require('../models/Project').Project -ErrorController = require("../Features/Errors/ErrorController") -AuthenticationController = require("../Features/Authentication/AuthenticationController") -_ = require('underscore') -metrics = require('../infrastructure/Metrics') -querystring = require('querystring') -async = require "async" - -module.exports = SecurityManager = - restricted : (req, res, next)-> - if req.session.user? - res.render 'user/restricted', - title:'restricted' - else - logger.log "user not logged in and trying to access #{req.url}, being redirected to login" - res.redirect '/register' - - getCurrentUser: (req, callback) -> - if req.session.user? - User.findById req.session.user._id, callback - else - callback null, null - - requestCanAccessMultipleProjects: (req, res, next) -> - project_ids = req.query.project_ids?.split(",") - jobs = [] - for project_id in project_ids or [] - do (project_id) -> - jobs.push (callback) -> - # This is a bit hacky - better to have an abstracted method - # that we can pass project_id to, but this whole file needs - # a serious refactor ATM. - req.params.Project_id = project_id - SecurityManager.requestCanAccessProject req, res, (error) -> - delete req.params.Project_id - callback(error) - async.series jobs, next - - requestCanAccessProject : (req, res, next)-> - doRequest = (req, res, next) -> - getRequestUserAndProject req, res, {allow_auth_token: options?.allow_auth_token}, (err, user, project)-> - if !project? or project.archived - return ErrorController.notFound(req, res, next) - userCanAccessProject user, project, (canAccess, permissionLevel)-> - if canAccess - next() - else if user? - logger.log "user_id: #{user._id} email: #{user.email} trying to access restricted page #{req.path}" - res.redirect('/restricted') - else - logger.log "user not logged in and trying to access #{req.url}, being redirected to login" - AuthenticationController._redirectToLoginOrRegisterPage(req, res) - if arguments.length > 1 - options = - allow_auth_token: false - doRequest.apply(this, arguments) - else - options = req - return doRequest - - requestCanModifyProject : (req, res, next)-> - getRequestUserAndProject req, res, {}, (err, user, project)=> - userCanModifyProject user, project, (canModify)-> - if canModify - next() - else - logger.log "user_id: #{user?._id} email: #{user?.email} can not modify project redirecting to restricted page" - res.redirect('/restricted') - - userCanModifyProject : userCanModifyProject = (user, project, callback)-> - if !user? or !project? - callback false - else if userIsOwner user, project - callback true - else if userIsCollaberator user, project - callback true - else if project.publicAccesLevel == "readAndWrite" - callback true - else if user.isAdmin - callback true - else - callback false - - - requestIsOwner : (req, res, next)-> - getRequestUserAndProject req, res, {}, (err, user, project)-> - if !user? - return res.redirect('/restricted') - else if userIsOwner user, project || user.isAdmin - next() - else - logger.log user_id: user?._id, email: user?.email, "user is not owner of project redirecting to restricted page" - res.redirect('/restricted') - - requestIsAdmin : isAdmin = (req, res, next)-> - logger.log "checking if user is admin" - user = req.session.user - if(user? && user.isAdmin) - logger.log user: user, "User is admin" - next() - else - res.redirect('/restricted') - logger.log user:user, "is not admin redirecting to restricted page" - - userCanAccessProject : userCanAccessProject = (user, project, callback)=> - if !user? - user = {_id:'anonymous-user'} - if !project? - callback false - logger.log user:user, project:project, "Checking if can access" - if userIsOwner user, project - callback true, "owner" - else if userIsCollaberator user, project - callback true, "readAndWrite" - else if userIsReadOnly user, project - callback true, "readOnly" - else if user.isAdmin - logger.log user:user, project:project, "user is admin and can access project" - callback true, "owner" - else if project.publicAccesLevel == "readAndWrite" - logger.log user:user, project:project, "project is a public read and write project" - callback true, "readAndWrite" - else if project.publicAccesLevel == "readOnly" - logger.log user:user, project:project, "project is a public read only project" - callback true, "readOnly" - else - metrics.inc "security.denied" - logger.log user:user, project:project, "Security denied - user can not enter project" - callback false - - userIsOwner : userIsOwner = (user, project)-> - if !user? - return false - else - userId = user._id+'' - ownerRef = getProjectIdFromRef(project.owner_ref) - if userId == ownerRef - true - else - false - - userIsCollaberator : userIsCollaberator = (user, project)-> - if !user? - return false - else - userId = user._id+'' - result = false - _.each project.collaberator_refs, (colabRef)-> - colabRef = getProjectIdFromRef(colabRef) - if colabRef == userId - result = true - return result - - userIsReadOnly : userIsReadOnly = (user, project)-> - if !user? - return false - else - userId = user._id+'' - result = false - _.each project.readOnly_refs, (readOnlyRef)-> - readOnlyRef = getProjectIdFromRef(readOnlyRef) - - if readOnlyRef == userId - result = true - return result - -getRequestUserAndProject = (req, res, options, callback)-> - project_id = req.params.Project_id - if !project_id? - logger.log project_id:project_id, options:options, url:req?.url, "no project_id trying to getRequestUserAndProject" - return res.send 422 - Project.findById project_id, 'name owner_ref readOnly_refs collaberator_refs publicAccesLevel archived', (err, project)=> - if err? - logger.err err:err, "error getting project for security check" - return callback err - AuthenticationController.getLoggedInUser req, options, (err, user)=> - if err? - logger.err err:err, "error getting last logged in user for security check" - callback err, user, project - -getProjectIdFromRef = (ref)-> - if !ref? - return null - else if ref._id? - return ref._id+'' - else - return ref+'' - - diff --git a/services/web/app/coffee/models/Project.coffee b/services/web/app/coffee/models/Project.coffee index dc9f0927fd..1d53999bd9 100644 --- a/services/web/app/coffee/models/Project.coffee +++ b/services/web/app/coffee/models/Project.coffee @@ -5,7 +5,7 @@ FolderSchema = require('./Folder.js').FolderSchema logger = require('logger-sharelatex') sanitize = require('sanitizer') concreteObjectId = require('mongoose').Types.ObjectId -Errors = require "../errors" +Errors = require "../Features/Errors/Errors" Schema = mongoose.Schema @@ -43,33 +43,6 @@ ProjectSchema.statics.getProject = (project_or_id, fields, callback)-> return callback(new Errors.NotFoundError(e.message)) this.findById project_or_id, fields, callback -ProjectSchema.statics.findPopulatedById = (project_id, callback)-> - logger.log project_id:project_id, "findPopulatedById" - this.find(_id: project_id ) - .populate('collaberator_refs') - .populate('readOnly_refs') - .populate('owner_ref') - .exec (err, projects)-> - if err? - logger.err err:err, project_id:project_id, "something went wrong looking for project findPopulatedById" - callback(err) - else if !projects? || projects.length == 0 - logger.err project_id:project_id, "something went wrong looking for project findPopulatedById, no project could be found" - callback "not found" - else - logger.log project_id:project_id, "finished findPopulatedById" - callback(null, projects[0]) - -ProjectSchema.statics.findAllUsersProjects = (user_id, requiredFields, callback)-> - this.find {owner_ref:user_id}, requiredFields, (err, projects)=> - this.find {collaberator_refs:user_id}, requiredFields, (err, collabertions)=> - this.find {readOnly_refs:user_id}, requiredFields, (err, readOnlyProjects)=> - callback(err, projects, collabertions, readOnlyProjects) - - - - - applyToAllFilesRecursivly = ProjectSchema.statics.applyToAllFilesRecursivly = (folder, fun)-> _.each folder.fileRefs, (file)-> fun(file) diff --git a/services/web/app/coffee/models/ProjectInvite.coffee b/services/web/app/coffee/models/ProjectInvite.coffee new file mode 100644 index 0000000000..3349dafa9b --- /dev/null +++ b/services/web/app/coffee/models/ProjectInvite.coffee @@ -0,0 +1,23 @@ +mongoose = require 'mongoose' +Settings = require 'settings-sharelatex' + +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +ProjectInviteSchema = new Schema + project_id: ObjectId + from_user_id: ObjectId + privilegeLevel: String + # For existing users + to_user_id: ObjectId + # For non-existant users + hashed_token: String + email: String + +conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: Settings.mongo.poolSize || 10) + +ProjectInvite = conn.model('ProjectInvite', ProjectInviteSchema) + +mongoose.model 'ProjectInvite', ProjectInviteSchema +exports.ProjectInvite = ProjectInvite +exports.ProjectInviteSchema = ProjectInviteSchema \ No newline at end of file diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 8fca181d0e..b0a7628f17 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -54,32 +54,10 @@ UserSchema = new Schema # For example, a user signing up directly for a paid plan # has this set to true, despite never having had a free trial hadFreeTrial: {type: Boolean, default: false} - -UserSchema.statics.getAllIds = (callback)-> - this.find {}, ["first_name"], callback - - -UserSchema.statics.findReadOnlyProjects = (user_id, callback)-> - @find({'projects.readOnly_refs':user_id}).populate('projects.readOnly_refs').run (err, users)-> - projects = [] - _.each users, (user)-> - _.each user.projects, (project)-> - _.each project.readOnly_refs, (subUser)-> - if(subUser._id == user_id) - projects.push(project) - callback(projects) - -UserSchema.statics.findCollaborationProjects = (user_id, callback)-> - @find({'projects.collaberator_refs':user_id}).populate('projects.collaberator_refs').run (err, users)-> - projects = [] - _.each users, (user)-> - _.each user.projects, (project)-> - _.each project.collaberator_refs, (subUser)-> - if(subUser._id == user_id) - projects.push(project) - callback(projects) - - + refProviders: { + mendeley: Boolean # coerce the refProviders values to Booleans + zotero: Boolean + } conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10) diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 46b1db2157..80c52c5a24 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -3,8 +3,6 @@ ErrorController = require('./Features/Errors/ErrorController') ProjectController = require("./Features/Project/ProjectController") ProjectApiController = require("./Features/Project/ProjectApiController") SpellingController = require('./Features/Spelling/SpellingController') -SecurityManager = require('./managers/SecurityManager') -AuthorizationManager = require('./Features/Security/AuthorizationManager') EditorController = require("./Features/Editor/EditorController") EditorRouter = require("./Features/Editor/EditorRouter") Settings = require('settings-sharelatex') @@ -39,6 +37,7 @@ RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter') InactiveProjectController = require("./Features/InactiveData/InactiveProjectController") ContactRouter = require("./Features/Contacts/ContactRouter") ReferencesController = require('./Features/References/ReferencesController') +AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear') logger = require("logger-sharelatex") _ = require("underscore") @@ -54,7 +53,7 @@ module.exports = class Router webRouter.post '/login', AuthenticationController.login webRouter.get '/logout', UserController.logout - webRouter.get '/restricted', SecurityManager.restricted + webRouter.get '/restricted', AuthorizationMiddlewear.restricted # Left as a placeholder for implementing a public register page webRouter.get '/register', UserPagesController.registerPage @@ -88,8 +87,7 @@ module.exports = class Router webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser - webRouter.get '/user/auth_token', AuthenticationController.requireLogin(), AuthenticationController.getAuthToken - webRouter.get '/user/personal_info', AuthenticationController.requireLogin(allow_auth_token: true), UserInfoController.getLoggedInUsersPersonalInfo + webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo apiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo webRouter.get '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage @@ -100,12 +98,13 @@ module.exports = class Router params: ["Project_id"] maxRequests: 10 timeInterval: 60 - }), SecurityManager.requestCanAccessProject, ProjectController.loadEditor - webRouter.get '/Project/:Project_id/file/:File_id', SecurityManager.requestCanAccessProject, FileStoreController.getFile - webRouter.post '/project/:Project_id/settings', SecurityManager.requestCanModifyProject, ProjectController.updateProjectSettings + }), AuthorizationMiddlewear.ensureUserCanReadProject, ProjectController.loadEditor + webRouter.get '/Project/:Project_id/file/:File_id', AuthorizationMiddlewear.ensureUserCanReadProject, FileStoreController.getFile + webRouter.post '/project/:Project_id/settings', AuthorizationMiddlewear.ensureUserCanWriteProjectSettings, ProjectController.updateProjectSettings + webRouter.post '/project/:Project_id/settings/admin', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.updateProjectAdminSettings - webRouter.post '/project/:Project_id/compile', SecurityManager.requestCanAccessProject, CompileController.compile - webRouter.get '/Project/:Project_id/output/output.pdf', SecurityManager.requestCanAccessProject, CompileController.downloadPdf + webRouter.post '/project/:Project_id/compile', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.compile + webRouter.get '/Project/:Project_id/output/output.pdf', AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.downloadPdf webRouter.get /^\/project\/([^\/]*)\/output\/(.*)$/, ((req, res, next) -> params = @@ -113,24 +112,35 @@ module.exports = class Router "file": req.params[1] req.params = params next() - ), SecurityManager.requestCanAccessProject, CompileController.getFileFromClsi - webRouter.delete "/project/:Project_id/output", SecurityManager.requestCanAccessProject, CompileController.deleteAuxFiles - webRouter.get "/project/:Project_id/sync/code", SecurityManager.requestCanAccessProject, CompileController.proxySync - webRouter.get "/project/:Project_id/sync/pdf", SecurityManager.requestCanAccessProject, CompileController.proxySync - webRouter.get "/project/:Project_id/wordcount", SecurityManager.requestCanAccessProject, CompileController.wordCount + ), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi + # direct url access to output files for a specific build (query string not required) + webRouter.get /^\/project\/([^\/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + ((req, res, next) -> + params = + "Project_id": req.params[0] + "build": req.params[1] + "file": req.params[2] + req.params = params + next() + ), AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.getFileFromClsi - webRouter.delete '/Project/:Project_id', SecurityManager.requestIsOwner, ProjectController.deleteProject - webRouter.post '/Project/:Project_id/restore', SecurityManager.requestIsOwner, ProjectController.restoreProject - webRouter.post '/Project/:Project_id/clone', SecurityManager.requestCanAccessProject, ProjectController.cloneProject + webRouter.delete "/project/:Project_id/output", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.deleteAuxFiles + webRouter.get "/project/:Project_id/sync/code", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncCode + webRouter.get "/project/:Project_id/sync/pdf", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.proxySyncPdf + webRouter.get "/project/:Project_id/wordcount", AuthorizationMiddlewear.ensureUserCanReadProject, CompileController.wordCount - webRouter.post '/project/:Project_id/rename', SecurityManager.requestIsOwner, ProjectController.renameProject + webRouter.delete '/Project/:Project_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.deleteProject + webRouter.post '/Project/:Project_id/restore', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.restoreProject + webRouter.post '/Project/:Project_id/clone', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectController.cloneProject - webRouter.get "/project/:Project_id/updates", SecurityManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi - webRouter.get "/project/:Project_id/doc/:doc_id/diff", SecurityManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi - webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", SecurityManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi + webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject - webRouter.get '/Project/:Project_id/download/zip', SecurityManager.requestCanAccessProject, ProjectDownloadsController.downloadProject - webRouter.get '/project/download/zip', SecurityManager.requestCanAccessMultipleProjects, ProjectDownloadsController.downloadMultipleProjects + webRouter.get "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi + webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi + webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, TrackChangesController.proxyToTrackChangesApi + + webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject + webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects webRouter.get '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags webRouter.post '/tag', AuthenticationController.requireLogin(), TagsController.createTag @@ -174,26 +184,31 @@ module.exports = class Router webRouter.post "/spelling/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi webRouter.post "/spelling/learn", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi - webRouter.get "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.getMessages - webRouter.post "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.sendMessage + webRouter.get "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.getMessages + webRouter.post "/project/:Project_id/messages", AuthorizationMiddlewear.ensureUserCanReadProject, ChatController.sendMessage - webRouter.get /learn(\/.*)?/, WikiController.getPage + webRouter.get /learn(\/.*)?/, RateLimiterMiddlewear.rateLimit({ + endpointName: "wiki" + params: [] + maxRequests: 60 + timeInterval: 60 + }), WikiController.getPage - webRouter.post "/project/:Project_id/references/index", SecurityManager.requestCanAccessProject, ReferencesController.index - webRouter.post "/project/:Project_id/references/indexAll", SecurityManager.requestCanAccessProject, ReferencesController.indexAll + webRouter.post "/project/:Project_id/references/index", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.index + webRouter.post "/project/:Project_id/references/indexAll", AuthorizationMiddlewear.ensureUserCanReadProject, ReferencesController.indexAll #Admin Stuff - webRouter.get '/admin', SecurityManager.requestIsAdmin, AdminController.index - webRouter.get '/admin/user', SecurityManager.requestIsAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon - webRouter.get '/admin/register', SecurityManager.requestIsAdmin, AdminController.registerNewUser - webRouter.post '/admin/register', SecurityManager.requestIsAdmin, UserController.register - webRouter.post '/admin/closeEditor', SecurityManager.requestIsAdmin, AdminController.closeEditor - webRouter.post '/admin/dissconectAllUsers', SecurityManager.requestIsAdmin, AdminController.dissconectAllUsers - webRouter.post '/admin/syncUserToSubscription', SecurityManager.requestIsAdmin, AdminController.syncUserToSubscription - webRouter.post '/admin/flushProjectToTpds', SecurityManager.requestIsAdmin, AdminController.flushProjectToTpds - webRouter.post '/admin/pollDropboxForUser', SecurityManager.requestIsAdmin, AdminController.pollDropboxForUser - webRouter.post '/admin/messages', SecurityManager.requestIsAdmin, AdminController.createMessage - webRouter.post '/admin/messages/clear', SecurityManager.requestIsAdmin, AdminController.clearMessages + webRouter.get '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index + webRouter.get '/admin/user', AuthorizationMiddlewear.ensureUserIsSiteAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon + webRouter.get '/admin/register', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.registerNewUser + webRouter.post '/admin/register', AuthorizationMiddlewear.ensureUserIsSiteAdmin, UserController.register + webRouter.post '/admin/closeEditor', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.closeEditor + webRouter.post '/admin/dissconectAllUsers', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.dissconectAllUsers + webRouter.post '/admin/syncUserToSubscription', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.syncUserToSubscription + webRouter.post '/admin/flushProjectToTpds', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.flushProjectToTpds + webRouter.post '/admin/pollDropboxForUser', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.pollDropboxForUser + webRouter.post '/admin/messages', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.createMessage + webRouter.post '/admin/messages/clear', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.clearMessages apiRouter.get '/perfTest', (req,res)-> res.send("hello") @@ -205,7 +220,7 @@ module.exports = class Router webRouter.get '/health_check', HealthCheckController.check webRouter.get '/health_check/redis', HealthCheckController.checkRedis - apiRouter.get "/status/compiler/:Project_id", SecurityManager.requestCanAccessProject, (req, res) -> + apiRouter.get "/status/compiler/:Project_id", AuthorizationMiddlewear.ensureUserCanReadProject, (req, res) -> sendRes = _.once (statusCode, message)-> res.writeHead statusCode res.end message @@ -222,9 +237,9 @@ module.exports = class Router headers: req.headers }) - apiRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error")) - apiRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error") - apiRouter.get '/oops-mongo', (req, res, next) -> + webRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error")) + webRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error") + webRouter.get '/oops-mongo', (req, res, next) -> require("./models/Project").Project.findOne {}, () -> throw new Error("Test error") @@ -233,7 +248,8 @@ module.exports = class Router res.send() webRouter.post '/error/client', (req, res, next) -> - logger.error err: req.body.error, meta: req.body.meta, "client side error" + logger.warn err: req.body.error, meta: req.body.meta, "client side error" + metrics.inc("client-side-error") res.sendStatus(204) webRouter.get '*', ErrorController.notFound \ No newline at end of file diff --git a/services/web/app/views/contact-us-modal.jade b/services/web/app/views/contact-us-modal.jade new file mode 100644 index 0000000000..46090eec1b --- /dev/null +++ b/services/web/app/views/contact-us-modal.jade @@ -0,0 +1,30 @@ +script(type='text/ng-template', id='supportModalTemplate') + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="close()" + ) × + h3 #{translate("contact_us")} + .modal-body.contact-us-modal + span(ng-show="sent == false") + label + | #{translate("subject")} + .form-group + input.field.text.medium.span8.form-control(ng-model="form.subject", maxlength='255', tabindex='1', onkeyup='') + label.desc(ng-show="'#{getUserEmail()}'.length < 1") + | #{translate("email")} + .form-group(ng-show="'#{getUserEmail()}'.length < 1") + input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + label#title12.desc + | #{translate("project_url")} (#{translate("optional")}) + .form-group + input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='') + label.desc + | #{translate("suggestion")} + .form-group + textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', tabindex='4', onkeyup='') + .form-group.text-center + input.btn-success.btn.btn-lg(type='submit', ng-disabled="sending", ng-click="contactUs()" value='#{translate("contact_us")}') + span(ng-show="sent") + p #{translate("request_sent_thank_you")} \ No newline at end of file diff --git a/services/web/app/views/general/500.jade b/services/web/app/views/general/500.jade new file mode 100644 index 0000000000..e045a7d457 --- /dev/null +++ b/services/web/app/views/general/500.jade @@ -0,0 +1,21 @@ +doctype html +html(itemscope, itemtype='http://schema.org/Product') + head + title Something went wrong + link(rel="icon", href="/favicon.ico") + link(rel='stylesheet', href='/stylesheets/style.css') + link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet") + body + .content + .container + .row + .col-md-8.col-md-offset-2.text-center + .page-header + h2 Oh dear, something went wrong. + p: img(src="/img/lion-sad-128.png", alt="Sad Lion") + p + | Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail} + p + a(href="/") + i.fa.fa-arrow-circle-o-left + | Take me home diff --git a/services/web/app/views/layout.jade b/services/web/app/views/layout.jade index 013c8e5a60..ae0e7550be 100644 --- a/services/web/app/views/layout.jade +++ b/services/web/app/views/layout.jade @@ -23,7 +23,9 @@ html(itemscope, itemtype='http://schema.org/Product') if settings.i18n.subdomainLang each subdomainDetails in settings.i18n.subdomainLang - link(rel="alternate", href=subdomainDetails.url+currentUrl, hreflang=subdomainDetails.lngCode) + if !subdomainDetails.hide + link(rel="alternate", href=subdomainDetails.url+currentUrl, hreflang=subdomainDetails.lngCode) + meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor") @@ -60,8 +62,8 @@ html(itemscope, itemtype='http://schema.org/Product') sixpackDomain: '#{settings.sixpack.domain}' }; window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')}; - window.ab = {} - window.user_id = '#{getLoggedInUserId()}' + window.ab = {}; + window.user_id = '#{getLoggedInUserId()}'; - if (typeof(settings.algolia) != "undefined") script. @@ -80,6 +82,7 @@ html(itemscope, itemtype='http://schema.org/Product') } body + - if(typeof(suppressSystemMessages) == "undefined") .system-messages( ng-cloak @@ -118,21 +121,14 @@ html(itemscope, itemtype='http://schema.org/Product') "paths" : { "moment": "libs/moment-2.7.0" } - }; + }; script( data-main=jsPath+'main.js', baseurl=jsPath, src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js') ) - - if (typeof(tenderUrl) != "undefined") - script(src="https://#{tenderUrl}/tender_widget.js" ) - script(type="text/javascript"). - Tender = { - hideToggle: true, - widgetToggles: $(".js-tender-widget"), - category: "questions" - }; + include contact-us-modal diff --git a/services/web/app/views/project/editor/binary-file.jade b/services/web/app/views/project/editor/binary-file.jade index ca25a6d806..d0effce668 100644 --- a/services/web/app/views/project/editor/binary-file.jade +++ b/services/web/app/views/project/editor/binary-file.jade @@ -4,16 +4,41 @@ div.binary-file.full-size( ng-if="openFile" ) img( + ng-show="!failedLoad" ng-src="/project/{{ project_id }}/file/{{ openFile.id }}" ng-if="['png', 'jpg', 'jpeg', 'gif'].indexOf(extension(openFile)) > -1" + ng-class="{'img-preview': !imgLoaded}" + onerror="sl_binaryFilePreviewError()" + onabort="sl_binaryFilePreviewError()" + onload="sl_binaryFilePreviewLoaded()" ) + img( + ng-show="!failedLoad" ng-src="/project/{{ project_id }}/file/{{ openFile.id }}?format=png" ng-if="['pdf', 'eps'].indexOf(extension(openFile)) > -1" + ng-class="{'img-preview': !imgLoaded}" + onerror="sl_binaryFilePreviewError()" + onabort="sl_binaryFilePreviewError()" + onload="sl_binaryFilePreviewLoaded()" ) + + div(ng-if="(['bib'].indexOf(extension(openFile)) > -1) && !bibtexPreview.error") + + div.bib-loading(ng-show="bibtexPreview.loading && !bibtexPreview.error") + | #{translate('loading')}... + + div.bib-preview(ng-show="bibtexPreview.data && !bibtexPreview.loading && !bibtexPreview.error") + div.scroll-container + p + | {{ bibtexPreview.data }} + p(ng-show="bibtexPreview.shouldShowDots") + | ... + p.no-preview( - ng-if="['png', 'jpg', 'jpeg', 'gif', 'pdf', 'eps'].indexOf(extension(openFile)) == -1" + ng-if="failedLoad || bibtexPreview.error || ['bib', 'png', 'jpg', 'jpeg', 'gif', 'pdf', 'eps'].indexOf(extension(openFile)) == -1" ) #{translate("no_preview_available")} + a.btn.btn-info( ng-href="/project/{{ project_id }}/file/{{ openFile.id }}" ) #{translate("download")} {{ openFile.name }} diff --git a/services/web/app/views/project/editor/editor.jade b/services/web/app/views/project/editor/editor.jade index f79f6e3c19..d6c0876f96 100644 --- a/services/web/app/views/project/editor/editor.jade +++ b/services/web/app/views/project/editor/editor.jade @@ -10,8 +10,12 @@ div.full-size( ) .ui-layout-center .loading-panel(ng-show="!editor.sharejs_doc || editor.opening") - i.fa.fa-spin.fa-refresh - |   #{translate("loading")}... + span(ng-show="editor.open_doc_id") + i.fa.fa-spin.fa-refresh + |   #{translate("loading")}... + span(ng-show="!editor.open_doc_id") + i.fa.fa-arrow-left + |   #{translate("open_a_file_on_the_left")} #editor( ace-editor="editor", diff --git a/services/web/app/views/project/editor/file-tree.jade b/services/web/app/views/project/editor/file-tree.jade index bb07ee9e88..77cbd10da9 100644 --- a/services/web/app/views/project/editor/file-tree.jade +++ b/services/web/app/views/project/editor/file-tree.jade @@ -70,23 +70,23 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected' ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']" ) - div(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'") - h3 #{translate("deleted_files")} - ul.list-unstyled.file-tree-list.deleted-docs - li( - ng-class="{ 'selected': entity.selected }", - ng-repeat="entity in deletedDocs | orderBy:'name'", - ng-controller="FileTreeEntityController" - ) - .entity - .entity-name( - ng-click="select($event)" - ) - //- Just a spacer to align with folders - i.fa.fa-fw.toggle - i.fa.fa-fw.fa-file + li(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'") + h3 #{translate("deleted_files")} + li( + ng-class="{ 'selected': entity.selected }", + ng-repeat="entity in deletedDocs | orderBy:'name'", + ng-controller="FileTreeEntityController", + ng-show="ui.view == 'track-changes'" + ) + .entity + .entity-name( + ng-click="select($event)" + ) + //- Just a spacer to align with folders + i.fa.fa-fw.toggle + i.fa.fa-fw.fa-file - span {{ entity.name }} + span {{ entity.name }} script(type='text/ng-template', id='entityListItemTemplate') li( @@ -107,8 +107,7 @@ script(type='text/ng-template', id='entityListItemTemplate') //- Just a spacer to align with folders i.fa.fa-fw.toggle(ng-if="entity.type != 'folder'") - i.fa.fa-fw.fa-file(ng-if="entity.type == 'doc'") - i.fa.fa-fw.fa-image(ng-if="entity.type == 'file'") + i.fa.fa-fw(ng-if="entity.type != 'folder'", ng-class="'fa-' + iconTypeFromName(entity.name)") span( ng-hide="entity.renaming" ) {{ entity.name }} diff --git a/services/web/app/views/project/editor/left-menu.jade b/services/web/app/views/project/editor/left-menu.jade index 0c6cf8250e..52ea62d505 100644 --- a/services/web/app/views/project/editor/left-menu.jade +++ b/services/web/app/views/project/editor/left-menu.jade @@ -198,21 +198,21 @@ script(type='text/ng-template', id='wordCountModalTemplate') div(ng-if="!status.loading") .container-fluid .row - .col-md-4 + .col-xs-4 .pull-right #{translate("total_words")} : - .col-md-6 {{data.textWords}} + .col-xs-6 {{data.textWords}} .row - .col-md-4 + .col-xs-4 .pull-right #{translate("headers")} : - .col-md-6 {{data.headers}} + .col-xs-6 {{data.headers}} .row - .col-md-4 + .col-xs-4 .pull-right #{translate("math_inline")} : - .col-md-6 {{data.mathInline}} + .col-xs-6 {{data.mathInline}} .row - .col-md-4 + .col-xs-4 .pull-right #{translate("math_display")} : - .col-md-6 {{data.mathDisplay}} + .col-xs-6 {{data.mathDisplay}} .modal-footer button.btn.btn-default( ng-disabled="state.inflight" diff --git a/services/web/app/views/project/editor/pdf.jade b/services/web/app/views/project/editor/pdf.jade index 5d58f64e13..df97ff889e 100644 --- a/services/web/app/views/project/editor/pdf.jade +++ b/services/web/app/views/project/editor/pdf.jade @@ -32,7 +32,7 @@ div.full-size.pdf(ng-controller="PdfController") a.log-btn( href ng-click="toggleLogs()" - ng-class="{ 'active': (pdf.view == 'logs' || pdf.failure) && !pdf.error && !pdf.timeout && !pdf.uncompiled }" + ng-class="{ 'active': shouldShowLogs == true }" tooltip="#{translate('logs_and_output_files')}" tooltip-placement="bottom" ) @@ -74,93 +74,10 @@ div.full-size.pdf(ng-controller="PdfController") ) i.split-screen i.split-screen + // end of toolbar - .pdf-viewer(ng-show="pdf.url && pdf.view == 'pdf' && !pdf.failure && !pdf.timeout && !pdf.error") - div( - pdfng - ng-if="settings.pdfViewer == 'pdfjs'" - pdf-src="pdf.url" - key="{{ project_id }}" - resize-on="layout:main:resize,layout:pdf:resize" - highlights="pdf.highlights" - position="pdf.position" - dbl-click-callback="syncToCode" - ) - - iframe( - ng-src="{{ pdf.url }}" - ng-if="settings.pdfViewer == 'native'" - ) - - .pdf-uncompiled(ng-show="pdf.uncompiled && !pdf.compiling") - |   - i.fa.fa-level-up.fa-flip-horizontal.fa-2x - |   #{translate('click_here_to_preview_pdf')} - - .pdf-errors(ng-show="pdf.timedout || pdf.error") - .alert.alert-danger(ng-show="pdf.error") - strong #{translate("server_error")} - span #{translate("somthing_went_wrong_compiling")} - - - .alert.alert-danger(ng-show="pdf.timedout") - p - strong #{translate("timedout")}. - span #{translate("proj_timed_out_reason")} - p - a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank") - | #{translate("learn_how_to_make_documents_compile_quickly")} - - .alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile") - p - strong #{translate("upgrade_for_faster_compiles")} - p #{translate("free_accounts_have_timeout_upgrade_to_increase")} - p Plus: - p - ul.list-unstyled - li - i.fa.fa-check   - | #{translate("unlimited_projects")} - - li - i.fa.fa-check   - | #{translate("collabs_per_proj", {collabcount:'Multiple'})} - - li - i.fa.fa-check   - | #{translate("full_doc_history")} - - li - i.fa.fa-check   - | #{translate("sync_to_dropbox")} - - li - i.fa.fa-check   - | #{translate("sync_to_github")} - - li - i.fa.fa-check   - |#{translate("compile_larger_projects")} - - p(ng-controller="FreeTrialModalController") - a.btn.btn-success.row-spaced-small( - href - ng-class="buttonClass" - sixpack-convert="track_changes_feature_info" - ng-click="startFreeTrial('compile-timeout')" - ) #{translate("start_free_trial")} - - .pdf-errors(ng-show="pdf.projectTooLarge") - .alert.alert-danger - strong #{translate("project_too_large")} - span #{translate("project_too_large_please_reduce")} - - - - - - - .pdf-logs(ng-show="(pdf.view == 'logs' || pdf.failure) && !pdf.error && !pdf.timeout && !pdf.uncompiled") + // logs view + .pdf-logs(ng-show="shouldShowLogs") .alert.alert-success(ng-show="pdf.logEntries.all.length == 0") | #{translate("no_errors_good_job")} @@ -204,7 +121,7 @@ div.full-size.pdf(ng-controller="PdfController") ul.dropdown-menu.dropdown-menu-right li(ng-repeat="file in pdf.outputFiles") a( - href="/project/{{project_id}}/output/{{file.path}}" + href="{{file.url}}" target="_blank" ) {{ file.name }} a.btn.btn-info.btn-sm(href, ng-click="toggleRawLog()") @@ -212,6 +129,96 @@ div.full-size.pdf(ng-controller="PdfController") span(ng-show="pdf.showRawLog") #{translate("hide_raw_logs")} pre(ng-bind="pdf.rawLog", ng-show="pdf.showRawLog") + + + // non-log views (pdf and errors) + div(ng-show="!shouldShowLogs", ng-switch on="pdf.view") + .pdf-uncompiled(ng-switch-when="uncompiled" ng-show="!pdf.compiling") + |   + i.fa.fa-level-up.fa-flip-horizontal.fa-2x + |   #{translate('click_here_to_preview_pdf')} + + .pdf-viewer(ng-switch-when="pdf") + div( + pdfng + ng-if="settings.pdfViewer == 'pdfjs'" + pdf-src="pdf.url" + key="{{ project_id }}" + resize-on="layout:main:resize,layout:pdf:resize" + highlights="pdf.highlights" + position="pdf.position" + dbl-click-callback="syncToCode" + ) + iframe( + ng-src="{{ pdf.url }}" + ng-if="settings.pdfViewer == 'native'" + ) + + .pdf-errors(ng-switch-when="errors") + + .alert.alert-danger(ng-show="pdf.error") + strong #{translate("server_error")} + span #{translate("somthing_went_wrong_compiling")} + + .alert.alert-danger(ng-show="pdf.renderingError") + strong #{translate("pdf_rendering_error")} + span #{translate("something_went_wrong_rendering_pdf")} + + .alert.alert-danger(ng-show="pdf.clsiMaintenance") + strong #{translate("server_error")} + span #{translate("clsi_maintenance")} + + .alert.alert-danger(ng-show="pdf.tooRecentlyCompiled") + strong #{translate("server_error")} + span #{translate("too_recently_compiled")} + + .alert.alert-danger(ng-show="pdf.timedout") + p + strong #{translate("timedout")}. + span #{translate("proj_timed_out_reason")} + p + a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank") + | #{translate("learn_how_to_make_documents_compile_quickly")} + + .alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile") + p(ng-if="project.owner._id == user.id") + strong #{translate("upgrade_for_faster_compiles")} + p(ng-if="project.owner._id != user.id") + strong #{translate("ask_proj_owner_to_upgrade_for_faster_compiles")} + p #{translate("free_accounts_have_timeout_upgrade_to_increase")} + p Plus: + p + ul.list-unstyled + li + i.fa.fa-check   + | #{translate("unlimited_projects")} + li + i.fa.fa-check   + | #{translate("collabs_per_proj", {collabcount:'Multiple'})} + li + i.fa.fa-check   + | #{translate("full_doc_history")} + li + i.fa.fa-check   + | #{translate("sync_to_dropbox")} + li + i.fa.fa-check   + | #{translate("sync_to_github")} + li + i.fa.fa-check   + |#{translate("compile_larger_projects")} + p(ng-controller="FreeTrialModalController", ng-if="project.owner._id == user.id") + a.btn.btn-success.row-spaced-small( + href + ng-class="buttonClass" + sixpack-convert="track_changes_feature_info" + ng-click="startFreeTrial('compile-timeout')" + ) #{translate("start_free_trial")} + + .alert.alert-danger(ng-show="pdf.projectTooLarge") + strong #{translate("project_too_large")} + span #{translate("project_too_large_please_reduce")} + script(type='text/ng-template', id='clearCacheModalTemplate') .modal-header diff --git a/services/web/app/views/project/list/modals.jade b/services/web/app/views/project/list/modals.jade index e0fe24ad14..9111b2a347 100644 --- a/services/web/app/views/project/list/modals.jade +++ b/services/web/app/views/project/list/modals.jade @@ -183,7 +183,9 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate') data-dismiss="modal" ng-click="cancel()" ) × - h3 {{action}} #{translate("projects")} + h3(ng-if="action == 'delete'") #{translate("delete_projects")} + h3(ng-if="action == 'leave'") #{translate("leave_projects")} + h3(ng-if="action == 'delete-and-leave'") #{translate("delete_and_leave_projects")} .modal-body div(ng-show="projectsToDelete.length > 0") p #{translate("about_to_delete_projects")} @@ -201,7 +203,7 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate') ) #{translate("cancel")} button.btn.btn-danger( ng-click="delete()" - ) {{action}} + ) #{translate("confirm")} script(type="text/ng-template", id="uploadProjectModalTemplate") .modal-header diff --git a/services/web/app/views/project/list/project-list.jade b/services/web/app/views/project/list/project-list.jade index 9e8c22a1e8..649c860b26 100644 --- a/services/web/app/views/project/list/project-list.jade +++ b/services/web/app/views/project/list/project-list.jade @@ -24,7 +24,7 @@ .btn-toolbar(ng-show="filter != 'archived'") .btn-group(ng-hide="selectedProjects.length < 1") a.btn.btn-default( - href='#', + href, tooltip="#{translate('download')}", tooltip-placement="bottom", tooltip-append-to-body="true", @@ -32,7 +32,7 @@ ) i.fa.fa-cloud-download a.btn.btn-default( - href='#', + href, tooltip="#{translate('delete')}", tooltip-placement="bottom", tooltip-append-to-body="true", @@ -42,7 +42,7 @@ .btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown) a.btn.btn-default.dropdown-toggle( - href="#", + href, data-toggle="dropdown", dropdown-toggle, tooltip="#{translate('add_to_folders')}", @@ -72,11 +72,11 @@ | {{tag.name}} li.divider li - a(href="#", ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")} + a(href, ng-click="openNewTagModal()", stop-propagation="click") #{translate("create_new_folder")} .btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown a.btn.btn-default.dropdown-toggle( - href='#', + href, data-toggle="dropdown", dropdown-toggle ) #{translate("more")} @@ -84,19 +84,19 @@ ul.dropdown-menu.dropdown-menu-right(role="menu") li(ng-show="getFirstSelectedProject().accessLevel == 'owner'") a( - href='#', + href, ng-click="openRenameProjectModal()" ) #{translate("rename")} li a( - href='#', + href, ng-click="openCloneProjectModal()" ) #{translate("make_copy")} .btn-toolbar(ng-show="filter == 'archived'") .btn-group(ng-hide="selectedProjects.length < 1") a.btn.btn-default( - href='#', + href, data-original-title="Restore", data-toggle="tooltip", data-placement="bottom", @@ -105,7 +105,7 @@ .btn-group(ng-hide="selectedProjects.length < 1") a.btn.btn-danger( - href='#', + href, data-original-title="Delete Forever", data-toggle="tooltip", data-placement="bottom", diff --git a/services/web/app/views/subscriptions/group/invite.jade b/services/web/app/views/subscriptions/group/invite.jade index 1885ea20b8..3ea26aa0a1 100644 --- a/services/web/app/views/subscriptions/group/invite.jade +++ b/services/web/app/views/subscriptions/group/invite.jade @@ -2,7 +2,8 @@ extends ../../layout block scripts script(type='text/javascript'). - window.subscription_id = '#{subscription_id}' + window.group_subscription_id = '#{group_subscription_id}' + window.has_personal_subscription = '#{has_personal_subscription}' block content .content.content-alt @@ -11,12 +12,26 @@ block content .col-md-8.col-md-offset-2 -if (query.expired) .alert.alert-warning #{translate("email_link_expired")} + + .row + div   .row .col-md-8.col-md-offset-2(ng-cloak) .card(ng-controller="GroupSubscriptionInviteController") .page-header h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})} - div(ng-show="!requestSent").row.text-centered + + div(ng-show="view =='personalSubscription'").row.text-centered + div #{translate("cancel_personal_subscription_first")} + .row + .col-md-12   + .row + .col-md-12 + a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")} + span   + a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")} + + div(ng-show="view =='groupSubscriptionInvite'").row.text-centered .row .col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})} .row @@ -26,10 +41,10 @@ block content .text-center a.btn.btn-default(href="/project") #{translate("not_now")} span   - a.btn.btn.btn-primary(ng-click="joinGroup()") #{translate("verify_email_address")} + a.btn.btn.btn-primary(ng-click="joinGroup()", ng-disabled="inflight") #{translate("verify_email_address")} - span(ng-show="requestSent").row.text-centered.text-center + span(ng-show="view =='requestSent'").row.text-centered.text-center .row .col-md-12 #{translate("check_email_to_complete_the_upgrade")} .row diff --git a/services/web/app/views/subscriptions/new.jade b/services/web/app/views/subscriptions/new.jade index b70aec29fc..e84dc56eec 100644 --- a/services/web/app/views/subscriptions/new.jade +++ b/services/web/app/views/subscriptions/new.jade @@ -108,7 +108,7 @@ block content option(value="10") 10 option(value="11") 11 option(value="12") 12 - .col-md-3 + .col-md-4 .form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''") select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year') option(value="", disabled, selected) Year diff --git a/services/web/app/views/wiki/page.jade b/services/web/app/views/wiki/page.jade index 054697cee9..6a6bea2202 100644 --- a/services/web/app/views/wiki/page.jade +++ b/services/web/app/views/wiki/page.jade @@ -1,14 +1,68 @@ extends ../layout block content - .content.content-alt(ng-non-bindable) + .content.content-alt(ng-cloak) .container.wiki + .row.template-page-header + .col-md-8(ng-cloak) + .row - .col-xs-3.contents + .col-xs-3.contents(ng-non-bindable) | !{contents.content} + .col-xs-9.page - .card + - if(typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined") + span(ng-controller="SearchWikiController") + .row + form.project-search.form-horizontal.col-md-9(role="form") + .form-group.has-feedback.has-feedback-left.col-md-12 + input.form-control.col-md-12(type='text', ng-model='searchQueryText', ng-keyup='search()', placeholder="Search help library....") + i.fa.fa-search.form-control-feedback-left + i.fa.fa-times.form-control-feedback( + ng-click="clearSearchText()", + style="cursor: pointer;", + ng-show="searchQueryText.length > 0" + ) + .col-md-3.text-right + a.btn.btn-primary(ng-click="showMissingTemplateModal()") #{translate("suggest_new_doc")} + + .row + .col-md-12(ng-cloak) + a(ng-href='{{hit.url}}',ng-repeat='hit in hits').search-result.card.card-thin + span(ng-bind-html='hit.name') + div.search-result-content(ng-show="hit.content != ''", ng-bind-html='hit.content') + + .card.row-spaced(ng-non-bindable) .page-header h1 #{title} - | !{page.content} \ No newline at end of file + | !{page.content} + + + + + script(type="text/ng-template", id="missingWikiPageModal") + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="close()" + ) × + h3 #{translate("suggest_new_doc")} + .modal-body.contact-us-modal + span(ng-show="sent == false") + label.desc + | #{translate("email")} (#{translate("optional")}) + .form-group + input.field.text.medium.span8.form-control(ng-model="form.email", ng-init="form.email = '#{getUserEmail()}'", type='email', spellcheck='false', value='', maxlength='255', tabindex='2') + label.desc + | #{translate("suggestion")} + .form-group + textarea.field.text.medium.span8.form-control(ng-model="form.message",type='text', value='', maxlength='255', tabindex='4', onkeyup='') + span(ng-show="sent") + p #{translate("request_sent_thank_you")} + .modal-footer + button.btn.btn-default(ng-click="close()") + span #{translate("dismiss")} + button.btn-success.btn(type='submit', ng-disabled="sending", ng-click="contactUs()") #{translate("contact_us")} + diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 3f73439cd0..beb933e4c9 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -88,8 +88,6 @@ module.exports = url: "http://localhost:3009" clsi: url: "http://localhost:3013" - clsi_priority: - url: "http://localhost:3013" templates: url: "http://localhost:3007" githubSync: @@ -126,6 +124,9 @@ module.exports = # cookieDomain: ".sharelatex.dev" cookieName:"sharelatex.sid" + # this is only used if cookies are used for clsi backend + #clsiCookieKey: "clsiserver" + # Same, but with http auth credentials. httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000' @@ -259,7 +260,7 @@ module.exports = # Should we allow access to any page without logging in? This includes # public projects, /learn, /templates, about pages, etc. - allowPublicAccess: false + allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false # Maximum size of text documents in the real-time editing system. max_doc_length: 2 * 1024 * 1024 # 2mb @@ -388,11 +389,15 @@ module.exports = # filter: "(uid=:userKey)" # failMessage: 'LDAP User Fail' # fieldName: 'LDAP User' - # placeholder: 'LDAP User ID' + # placeholder: 'email@example.com' # emailAtt: 'mail' # anonymous: false # adminDN: 'cn=read-only-admin,dc=example,dc=com' # adminPW: 'password' + # starttls: true + # tlsOptions: + # rejectUnauthorized: false + # ca: ['/etc/ldap/ca_certs.pem'] #templateLinks: [{ # name : "CV projects", diff --git a/services/web/package.json b/services/web/package.json index add5a63be3..2d52210fda 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -18,11 +18,13 @@ "body-parser": "^1.13.1", "bufferedstream": "1.6.0", "connect-redis": "2.3.0", + "cookie": "^0.2.3", "cookie-parser": "1.3.5", "csurf": "^1.8.3", "dateformat": "1.0.4-1.2.3", "express": "4.13.0", "express-session": "1.11.3", + "grunt": "^0.4.5", "heapdump": "^0.3.7", "http-proxy": "^1.8.1", "jade": "~1.3.1", @@ -39,16 +41,19 @@ "multer": "^0.1.8", "node-uuid": "1.4.1", "nodemailer": "2.1.0", + "nodemailer-sendgrid-transport": "^0.2.0", "nodemailer-ses-transport": "^1.3.0", "optimist": "0.6.1", "redback": "0.4.0", "redis": "0.10.1", "redis-sharelatex": "0.0.9", - "request": "2.67.0", + "request": "^2.69.0", + "requests": "^0.1.7", "rimraf": "2.2.6", "sanitizer": "0.1.1", "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0", "sixpack-client": "^1.0.0", + "temp": "^0.8.3", "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master", "underscore": "1.6.0", "underscore.string": "^3.0.2", diff --git a/services/web/public/coffee/analytics/AbTestingManager.coffee b/services/web/public/coffee/analytics/AbTestingManager.coffee index 53524c8d5d..950f4f89f8 100644 --- a/services/web/public/coffee/analytics/AbTestingManager.coffee +++ b/services/web/public/coffee/analytics/AbTestingManager.coffee @@ -22,24 +22,20 @@ define [ _buildCookieKey = (testName, bucket)-> key = "sl_abt_#{testName}_#{bucket}" - #console.log key return key _getTestCookie = (testName, bucket)-> cookieKey = _buildCookieKey(testName, bucket) cookie = ipCookie(cookieKey) - #console.log cookieKey, cookie return cookie _persistCookieStep = (testName, bucket, newStep)-> cookieKey = _buildCookieKey(testName, bucket) ipCookie(cookieKey, {step:newStep}, {expires:100, path:"/"}) - #console.log("persisting", cookieKey, {step:newStep}) ga('send', 'event', 'ab_tests', "#{testName}:#{bucket}", "step-#{newStep}") _checkIfStepIsNext = (cookieStep, newStep)-> - #console.log cookieStep, newStep, "checking if step is next" if !cookieStep? and newStep != 0 return false else if newStep == 0 @@ -68,8 +64,6 @@ define [ bucketIndex = parseInt(hash.toString().slice(0,2), 16) % (buckets?.length or 2) return buckets[bucketIndex] - - App.controller "AbTestController", ($scope, abTestManager)-> testKeys = _.keys(window.ab) diff --git a/services/web/public/coffee/base.coffee b/services/web/public/coffee/base.coffee index 98cdb3a871..b62635170a 100644 --- a/services/web/public/coffee/base.coffee +++ b/services/web/public/coffee/base.coffee @@ -23,5 +23,9 @@ define [ baseUrl: window.sharelatex.sixpackDomain client_id: window.user_id }) + + sl_debugging = window.location?.search?.match(/debug=true/)? + window.sl_console = + log: (args...) -> console.log(args...) if sl_debugging return App diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 161289d055..ceebadac46 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -112,4 +112,20 @@ define [ ide.localStorage = localStorage + ide.browserIsSafari = false + try + userAgent = navigator.userAgent + ide.browserIsSafari = ( + userAgent && + userAgent.match(/.*Safari\/.*/) && + !userAgent.match(/.*Chrome\/.*/) && + !userAgent.match(/.*Chromium\/.*/) + ) + catch err + console.error err + + # User can append ?ft=somefeature to url to activate a feature toggle + ide.featureToggle = location?.search?.match(/^\?ft=(\w+)$/)?[1] + + angular.bootstrap(document.body, ["SharelatexApp"]) diff --git a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee index 9b80c0e0dc..02283bc49e 100644 --- a/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee +++ b/services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee @@ -9,4 +9,12 @@ define [ openFile: (file) -> @$scope.ui.view = "file" - @$scope.openFile = file \ No newline at end of file + @$scope.openFile = null + @$scope.$apply() + window.setTimeout( + () => + @$scope.openFile = file + @$scope.$apply() + , 0 + , this + ) diff --git a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee index 5dcabe8000..b1457532b5 100644 --- a/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee +++ b/services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee @@ -1,7 +1,70 @@ define [ "base" ], (App) -> - App.controller "BinaryFileController", ["$scope", ($scope) -> + App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", ($scope, $rootScope, $http, $timeout) -> + + TWO_MEGABYTES = 2 * 1024 * 1024 + + $scope.bibtexPreview = + loading: false + shouldShowDots: false + error: false + data: null + + # Callback fired when the `img` tag fails to load, + # `failedLoad` used to show the "No Preview" message + $scope.failedLoad = false + window.sl_binaryFilePreviewError = () => + $scope.failedLoad = true + $scope.$apply() + + # Callback fired when the `img` tag is done loading, + # `imgLoaded` is used to show the spinner gif while loading + $scope.imgLoaded = false + window.sl_binaryFilePreviewLoaded = () => + $scope.imgLoaded = true + $scope.$apply() + $scope.extension = (file) -> return file.name.split(".").pop()?.toLowerCase() - ] \ No newline at end of file + + $scope.loadBibtexFilePreview = () -> + url = "/project/#{project_id}/file/#{$scope.openFile.id}?range=0-#{TWO_MEGABYTES}" + $scope.bibtexPreview.loading = true + $scope.bibtexPreview.shouldShowDots = false + $scope.$apply() + $http.get(url) + .success (data) -> + $scope.bibtexPreview.loading = false + $scope.bibtexPreview.error = false + # show dots when payload is closs to cutoff + if data.length >= (TWO_MEGABYTES - 200) + $scope.bibtexPreview.shouldShowDots = true + try + # remove last partial line + data = data.replace(/\n.*$/, '') + finally + $scope.bibtexPreview.data = data + $timeout($scope.setHeight, 0) + .error (err) -> + $scope.bibtexPreview.error = true + $scope.bibtexPreview.loading = false + + $scope.setHeight = () -> + # Behold, a ghastly hack + guide = document.querySelector('.file-tree-inner') + table_wrap = document.querySelector('.bib-preview .scroll-container') + if table_wrap + desired_height = guide.offsetHeight - 44 + if table_wrap.offsetHeight > desired_height + table_wrap.style.height = desired_height + 'px' + table_wrap.style['max-height'] = desired_height + 'px' + + $scope.loadBibtexIfRequired = () -> + if $scope.extension($scope.openFile) == 'bib' + $scope.bibtexPreview.data = null + $scope.loadBibtexFilePreview() + + $scope.loadBibtexIfRequired() + + ] diff --git a/services/web/public/coffee/ide/connection/ConnectionManager.coffee b/services/web/public/coffee/ide/connection/ConnectionManager.coffee index 7a52aa16de..f3d14a3e3d 100644 --- a/services/web/public/coffee/ide/connection/ConnectionManager.coffee +++ b/services/web/public/coffee/ide/connection/ConnectionManager.coffee @@ -52,6 +52,7 @@ define [], () -> "force new connection": true @ide.socket.on "connect", () => + sl_console.log "[socket.io connect] Connected" @connected = true @ide.pushEvent("connected") @@ -73,6 +74,7 @@ define [], () -> @ide.socket.on 'disconnect', () => + sl_console.log "[socket.io disconnect] Disconnected" @connected = false @ide.pushEvent("disconnected") @@ -97,9 +99,20 @@ define [], () -> , 10 * 1000 joinProject: () -> + sl_console.log "[joinProject] joining..." @ide.socket.emit 'joinProject', { project_id: @ide.project_id }, (err, project, permissionsLevel, protocolVersion) => + if err? + if err.message == "not authorized" + window.location = "/login?redir=#{encodeURI(window.location.pathname)}" + else + @ide.socket.disconnect() + @ide.showGenericMessageModal("Something went wrong connecting", """ + Something went wrong connecting to your project. Please refresh is this continues to happen. + """) + return + if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion location.reload(true) diff --git a/services/web/public/coffee/ide/editor/Document.coffee b/services/web/public/coffee/ide/editor/Document.coffee index e00573aef1..78431b8b6b 100644 --- a/services/web/public/coffee/ide/editor/Document.coffee +++ b/services/web/public/coffee/ide/editor/Document.coffee @@ -6,7 +6,10 @@ define [ @getDocument: (ide, doc_id) -> @openDocs ||= {} if !@openDocs[doc_id]? + sl_console.log "[getDocument] Creating new document instance for #{doc_id}" @openDocs[doc_id] = new Document(ide, doc_id) + else + sl_console.log "[getDocument] Returning existing document instance for #{doc_id}" return @openDocs[doc_id] @hasUnsavedChanges: () -> @@ -107,11 +110,14 @@ define [ @wantToBeJoined = false @_cancelJoin() if (@doc? and @doc.hasBufferedOps()) + sl_console.log "[leave] Doc has buffered ops, pushing callback for later" @_leaveCallbacks ||= [] @_leaveCallbacks.push callback else if !@connected + sl_console.log "[leave] Not connected, returning now" callback() else + sl_console.log "[leave] Leaving now" @_leaveDoc(callback) flush: () -> @@ -146,12 +152,15 @@ define [ if !inflightOp? and !pendingOp? # there's nothing going on saved = true - else if inflightOp == @oldInflightOp - saved = false - else if pendingOp? + sl_console.log "[pollSavedStatus] no inflight or pending ops" + else if inflightOp? and inflightOp == @oldInflightOp + # The same inflight op has been sitting unacked since we + # last checked. saved = false + sl_console.log "[pollSavedStatus] inflight op is same as before" else saved = true + sl_console.log "[pollSavedStatus] assuming saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})" @oldInflightOp = inflightOp return saved @@ -172,14 +181,14 @@ define [ update: update if window.disconnectOnAck? and Math.random() < window.disconnectOnAck - console.log "Disconnecting on ack", update + sl_console.log "Disconnecting on ack", update window._ide.socket.socket.disconnect() # Pretend we never received the ack return if window.dropAcks? and Math.random() < window.dropAcks if !update.op? # Only drop our own acks, not collaborator updates - console.log "Simulating a lost ack", update + sl_console.log "Simulating a lost ack", update return if update?.doc == @doc_id and @doc? @@ -189,15 +198,18 @@ define [ @leave() _onDisconnect: () -> + sl_console.log '[onDisconnect] disconnecting' @connected = false @joined = false @doc?.updateConnectionState "disconnected" _onReconnect: () -> + sl_console.log "[onReconnect] reconnected (joined project)" @ide.pushEvent "reconnected:afterJoinProject" @connected = true if @wantToBeJoined or @doc?.hasBufferedOps() + sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@doc?.hasBufferedOps()})" @_joinDoc (error) => return @_onError(error) if error? @doc.updateConnectionState "ok" @@ -225,16 +237,25 @@ define [ callback() _leaveDoc: (callback = (error) ->) -> + sl_console.log '[_leaveDoc] Sending leaveDoc request' @ide.socket.emit 'leaveDoc', @doc_id, (error) => return callback(error) if error? @joined = false for callback in @_leaveCallbacks or [] + sl_console.log '[_leaveDoc] Calling buffered callback', callback callback(error) delete @_leaveCallbacks callback(error) _cleanUp: () -> - delete Document.openDocs[@doc_id] + if Document.openDocs[@doc_id] == @ + sl_console.log "[_cleanUp] Removing self (#{@doc_id}) from in openDocs" + delete Document.openDocs[@doc_id] + else + # It's possible that this instance has error, and the doc has been reloaded. + # This creates a new instance in Document.openDoc with the same id. We shouldn't + # clear it because it's not this instance. + sl_console.log "[_cleanUp] New instance of (#{@doc_id}) created. Not removing" @_unBindFromEditorEvents() @_unBindFromSocketEvents() @@ -276,5 +297,9 @@ define [ console.error "ShareJS error", error, meta ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" ) @doc?.clearInflightAndPendingOps() - @_cleanUp() @trigger "error", error, meta + # The clean up should run after the error is triggered because the error triggers a + # disconnect. If we run the clean up first, we remove our event handlers and miss + # the disconnect event, which means we try to leaveDoc when the connection comes back. + # This could intefere with the new connection of a new instance of this document. + @_cleanUp() diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index e31955ef46..54d56cac19 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -41,6 +41,7 @@ define [ @openDoc(doc) openDoc: (doc, options = {}) -> + sl_console.log "[openDoc] Opening #{doc.id}" @$scope.ui.view = "editor" done = () => @@ -74,8 +75,10 @@ define [ done() _openNewDocument: (doc, callback = (error, sharejs_doc) ->) -> + sl_console.log "[_openNewDocument] Opening..." current_sharejs_doc = @$scope.editor.sharejs_doc if current_sharejs_doc? + sl_console.log "[_openNewDocument] Leaving existing open doc..." current_sharejs_doc.leaveAndCleanUp() @_unbindFromDocumentEvents(current_sharejs_doc) diff --git a/services/web/public/coffee/ide/editor/ShareJsDoc.coffee b/services/web/public/coffee/ide/editor/ShareJsDoc.coffee index 61b9ffb6cf..27d325676c 100644 --- a/services/web/public/coffee/ide/editor/ShareJsDoc.coffee +++ b/services/web/public/coffee/ide/editor/ShareJsDoc.coffee @@ -29,12 +29,13 @@ define [ send: (update) => @_startInflightOpTimeout(update) if window.disconnectOnUpdate? and Math.random() < window.disconnectOnUpdate - console.log "Disconnecting on update", update + sl_console.log "Disconnecting on update", update window._ide.socket.socket.disconnect() if window.dropUpdates? and Math.random() < window.dropUpdates - console.log "Simulating a lost update", update + sl_console.log "Simulating a lost update", update return - @socket.emit "applyOtUpdate", @doc_id, update + @socket.emit "applyOtUpdate", @doc_id, update, (error) => + return @_handleError(error) if error? state: "ok" id: @socket.socket.sessionid } @@ -95,6 +96,7 @@ define [ @_doc.flush() updateConnectionState: (state) -> + sl_console.log "[updateConnectionState] Setting state to #{state}" @connection.state = state @connection.id = @socket.socket.sessionid @_doc.autoOpen = false @@ -110,12 +112,14 @@ define [ detachFromAce: () -> @_doc.detach_ace?() INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack + WAIT_FOR_CONNECTION_TIMEOUT: 500 # If we're waiting for the project to join, try again in 0.5 seconds _startInflightOpTimeout: (update) -> @_startFatalTimeoutTimer(update) - timer = setTimeout () => + retryOp = () => # Only send the update again if inflightOp is still populated # This can be cleared when hard reloading the document in which # case we don't want to keep trying to send it. + sl_console.log "[inflightOpTimeout] Trying op again" if @_doc.inflightOp? # When there is a socket.io disconnect, @_doc.inflightSubmittedIds # is updated with the socket.io client id of the current op in flight @@ -124,8 +128,18 @@ define [ # So we need both depending on whether the op was submitted before # one or more disconnects, or if it was submitted during the current session. update.dupIfSource = [@connection.id, @_doc.inflightSubmittedIds...] - @connection.send(update) - , @INFLIGHT_OP_TIMEOUT + + # We must be joined to a project for applyOtUpdate to work on the real-time + # service, so don't send an op if we're not. Connection state is set to 'ok' + # when we've joined the project + if @connection.state != "ok" + sl_console.log "[inflightOpTimeout] Not connected, retrying in 0.5s" + timer = setTimeout retryOp, @WAIT_FOR_CONNECTION_TIMEOUT + else + sl_console.log "[inflightOpTimeout] Sending" + @connection.send(update) + + timer = setTimeout retryOp, @INFLIGHT_OP_TIMEOUT @_doc.inflightCallbacks.push () => @_clearFatalTimeoutTimer() clearTimeout timer diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee index 7b4889a81e..75e00a37f6 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee @@ -1,13 +1,13 @@ define [ "ide/editor/directives/aceEditor/auto-complete/SuggestionManager" - "ide/editor/directives/aceEditor/auto-complete/Snippets" + "ide/editor/directives/aceEditor/auto-complete/SnippetManager" "ace/ace" "ace/ext-language_tools" -], (SuggestionManager, Snippets) -> +], (SuggestionManager, SnippetManager) -> Range = ace.require("ace/range").Range getLastCommandFragment = (lineUpToCursor) -> - if m = lineUpToCursor.match(/(\\[^\\ ]+)$/) + if m = lineUpToCursor.match(/(\\[^\\]+)$/) return m[1] else return null @@ -38,19 +38,20 @@ define [ enableLiveAutocompletion: false }) - SnippetCompleter = - getCompletions: (editor, session, pos, prefix, callback) -> - callback null, Snippets + SnippetCompleter = new SnippetManager() references = @$scope.$root._references ReferencesCompleter = getCompletions: (editor, session, pos, prefix, callback) -> - range = new Range(pos.row, 0, pos.row, pos.column) - lineUpToCursor = editor.getSession().getTextRange(range) + upToCursorRange = new Range(pos.row, 0, pos.row, pos.column) + lineUpToCursor = editor.getSession().getTextRange(upToCursorRange) commandFragment = getLastCommandFragment(lineUpToCursor) if commandFragment - citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]?){(.*,)?(\w*)/) + citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/) if citeMatch + beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999) + lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange) + needsClosingBrace = !lineBeyondCursor.match(/^[^{]*}/) commandName = citeMatch[1] previousArgs = citeMatch[2] currentArg = citeMatch[3] @@ -59,8 +60,8 @@ define [ previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs result = [] result.push { - caption: "\\#{commandName}{", - snippet: "\\#{commandName}{", + caption: "\\#{commandName}{}", + snippet: "\\#{commandName}{}", meta: "reference", score: 11000 } @@ -68,8 +69,8 @@ define [ references.keys.forEach (key) -> if !(key in [null, undefined]) result.push({ - caption: "\\#{commandName}{#{previousArgsCaption}#{key}", - value: "\\#{commandName}{#{previousArgs}#{key}", + caption: "\\#{commandName}{#{previousArgsCaption}#{key}#{if needsClosingBrace then '}' else ''}", + value: "\\#{commandName}{#{previousArgs}#{key}#{if needsClosingBrace then '}' else ''}", meta: "reference", score: 10000 }) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SnippetManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SnippetManager.coffee new file mode 100644 index 0000000000..7b7593565d --- /dev/null +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SnippetManager.coffee @@ -0,0 +1,159 @@ +define () -> + environments = [ + "abstract", + "align", "align*", + "equation", "equation*", + "gather", "gather*", + "multline", "multline*", + "split", + "verbatim" + ] + + staticSnippets = for env in environments + { + caption: "\\begin{#{env}}..." + snippet: """ + \\begin{#{env}} + \t$1 + \\end{#{env}} + """ + meta: "env" + } + + staticSnippets = staticSnippets.concat [{ + caption: "\\begin{array}..." + snippet: """ + \\begin{array}{${1:cc}} + \t$2 & $3 \\\\\\\\ + \t$4 & $5 + \\end{array} + """ + meta: "env" + }, { + caption: "\\begin{figure}..." + snippet: """ + \\begin{figure} + \t\\centering + \t\\includegraphics{$1} + \t\\caption{${2:Caption}} + \t\\label{${3:fig:my_label}} + \\end{figure} + """ + meta: "env" + }, { + caption: "\\begin{tabular}..." + snippet: """ + \\begin{tabular}{${1:c|c}} + \t$2 & $3 \\\\\\\\ + \t$4 & $5 + \\end{tabular} + """ + meta: "env" + }, { + caption: "\\begin{table}..." + snippet: """ + \\begin{table}[$1] + \t\\centering + \t\\begin{tabular}{${2:c|c}} + \t\t$3 & $4 \\\\\\\\ + \t\t$5 & $6 + \t\\end{tabular} + \t\\caption{${7:Caption}} + \t\\label{${8:tab:my_label}} + \\end{table} + """ + meta: "env" + }, { + caption: "\\begin{list}..." + snippet: """ + \\begin{list} + \t\\item $1 + \\end{list} + """ + meta: "env" + }, { + caption: "\\begin{enumerate}..." + snippet: """ + \\begin{enumerate} + \t\\item $1 + \\end{enumerate} + """ + meta: "env" + }, { + caption: "\\begin{itemize}..." + snippet: """ + \\begin{itemize} + \t\\item $1 + \\end{itemize} + """ + meta: "env" + }, { + caption: "\\begin{frame}..." + snippet: """ + \\begin{frame}{${1:Frame Title}} + \t$2 + \\end{frame} + """ + meta: "env" + }] + + + parseCustomEnvironments = (text) -> + re = /^\\newenvironment{(\w+)}.*$/gm + result = [] + iterations = 0 + while match = re.exec(text) + result.push {name: match[1], whitespace: null} + iterations += 1 + if iterations >= 1000 + return result + return result + + + parseBeginCommands = (text) -> + re = /^\\begin{(\w+)}.*\n([\t ]*).*$/gm + result = [] + iterations = 0 + while match = re.exec(text) + result.push {name: match[1], whitespace: match[2]} + iterations += 1 + if iterations >= 1000 + return result + return result + + class SnippetManager + getCompletions: (editor, session, pos, prefix, callback) -> + docText = session.getValue() + customEnvironments = parseCustomEnvironments(docText) + beginCommands = parseBeginCommands(docText) + parsedItemsMap = {} + for environment in customEnvironments + parsedItemsMap[environment.name] = environment + for command in beginCommands + parsedItemsMap[command.name] = command + parsedItems = _.values(parsedItemsMap) + snippets = staticSnippets.concat( + parsedItems.map (item) -> + { + caption: "\\begin{#{item.name}}..." + snippet: """ + \\begin{#{item.name}} + #{item.whitespace || ''}$0 + \\end{#{item.name}} + """ + meta: "env" + } + ).concat( + # arguably these `end` commands shouldn't be here, as they're not snippets + # but this is where we have access to the `begin` environment names + # *shrug* + parsedItems.map (item) -> + { + caption: "\\end{#{item.name}}" + value: "\\end{#{item.name}}" + meta: "env" + } + ) + callback null, snippets + + return SnippetManager diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee deleted file mode 100644 index 4bc3d16ef4..0000000000 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee +++ /dev/null @@ -1,100 +0,0 @@ -define () -> - environments = [ - "abstract", - "align", "align*", - "equation", "equation*", - "gather", "gather*", - "multline", "multline*", - "split", - "verbatim" - ] - - snippets = for env in environments - { - caption: "\\begin{#{env}}..." - snippet: """ - \\begin{#{env}} - \t$1 - \\end{#{env}} - """ - meta: "env" - } - - snippets = snippets.concat [{ - caption: "\\begin{array}..." - snippet: """ - \\begin{array}{${1:cc}} - \t$2 & $3 \\\\\\\\ - \t$4 & $5 - \\end{array} - """ - meta: "env" - }, { - caption: "\\begin{figure}..." - snippet: """ - \\begin{figure} - \t\\centering - \t\\includegraphics{$1} - \t\\caption{${2:Caption}} - \t\\label{${3:fig:my_label}} - \\end{figure} - """ - meta: "env" - }, { - caption: "\\begin{tabular}..." - snippet: """ - \\begin{tabular}{${1:c|c}} - \t$2 & $3 \\\\\\\\ - \t$4 & $5 - \\end{tabular} - """ - meta: "env" - }, { - caption: "\\begin{table}..." - snippet: """ - \\begin{table}[$1] - \t\\centering - \t\\begin{tabular}{${2:c|c}} - \t\t$3 & $4 \\\\\\\\ - \t\t$5 & $6 - \t\\end{tabular} - \t\\caption{${7:Caption}} - \t\\label{${8:tab:my_label}} - \\end{table} - """ - meta: "env" - }, { - caption: "\\begin{list}..." - snippet: """ - \\begin{list} - \t\\item $1 - \\end{list} - """ - meta: "env" - }, { - caption: "\\begin{enumerate}..." - snippet: """ - \\begin{enumerate} - \t\\item $1 - \\end{enumerate} - """ - meta: "env" - }, { - caption: "\\begin{itemize}..." - snippet: """ - \\begin{itemize} - \t\\item $1 - \\end{itemize} - """ - meta: "env" - }, { - caption: "\\begin{frame}..." - snippet: """ - \\begin{frame}{${1:Frame Title}} - \t$2 - \\end{frame} - """ - meta: "env" - }] - - return snippets \ No newline at end of file diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee index 559a2c5981..97241a90ce 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee @@ -1,11 +1,23 @@ define [], () -> + class Parser constructor: (@doc) -> parse: () -> + # Safari regex is super slow, freezes browser for minutes on end, + # hacky solution: limit iterations + limit = null + if window?._ide?.browserIsSafari + limit = 100 + commands = [] seen = {} + iterations = 0 while command = @nextCommand() + iterations += 1 + if limit && iterations > limit + return commands + docState = @doc optionalArgs = 0 @@ -28,7 +40,7 @@ define [], () -> # Ignore single letter commands since auto complete is moot then. commandRegex: /\\([a-zA-Z][a-zA-Z]+)/ - + nextCommand: () -> i = @doc.search(@commandRegex) if i == -1 @@ -123,4 +135,3 @@ define [], () -> completionBeforeCursor: completionBeforeCursor completionAfterCursor: completionAfterCursor } - diff --git a/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee b/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee index 4ac09ad4da..dd4f26ceb0 100644 --- a/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee +++ b/services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee @@ -192,6 +192,13 @@ class Doc callback error for callback in @inflightCallbacks else # The op applied successfully. + + # We may get multiple acks of the same message if we retried it, + # so its ok if we receive an ack for a version that we've already gone past. + # If so, just ignore it + if msg.v < @version + return + throw new Error('Invalid version from server') unless msg.v == @version @serverOps[@version] = oldInflightOp diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee index cdc261053f..a0dcaa1367 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "FileTreeController", ["$scope", "$modal", "ide", ($scope, $modal, ide) -> + App.controller "FileTreeController", ["$scope", "$modal", "ide", "$rootScope", ($scope, $modal, ide, $rootScope) -> $scope.openNewDocModal = () -> $modal.open( templateUrl: "newDocModalTemplate" diff --git a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee index b5c96408c7..f0813cf03b 100644 --- a/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee +++ b/services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee @@ -51,6 +51,19 @@ define [ $scope.$on "delete:selected", () -> $scope.openDeleteModal() if $scope.entity.selected + + $scope.iconTypeFromName = (name) -> + ext = name.split(".").pop()?.toLowerCase() + if ext in ["png", "pdf", "jpg", "jpeg", "gif"] + return "image" + else if ext in ["csv", "xls", "xlsx"] + return "table" + else if ext in ["py", "r"] + return "file-text" + else if ext in ['bib'] + return 'book' + else + return "file" ] App.controller "DeleteEntityModalController", [ diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 8586d218a7..05d99124c7 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -4,7 +4,13 @@ define [ "libs/bib-log-parser" ], (App, LogParser, BibLogParser) -> App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) -> + autoCompile = true + + # pdf.view = uncompiled | pdf | errors + $scope.pdf.view = if $scope?.pdf?.url then 'pdf' else 'uncompiled' + $scope.shouldShowLogs = false + $scope.$on "project:joined", () -> return if !autoCompile autoCompile = false @@ -12,7 +18,8 @@ define [ $scope.hasPremiumCompile = $scope.project.features.compileGroup == "priority" $scope.$on "pdf:error:display", () -> - $scope.pdf.error = true + $scope.pdf.view = 'errors' + $scope.pdf.renderingError = true $scope.draft = localStorage("draft:#{$scope.project_id}") or false $scope.$watch "draft", (new_value, old_value) -> @@ -29,41 +36,72 @@ define [ _csrf: window.csrfToken } - parseCompileResponse = (response) -> + parseCompileResponse = (response) -> + # Reset everything $scope.pdf.error = false $scope.pdf.timedout = false $scope.pdf.failure = false - $scope.pdf.uncompiled = false $scope.pdf.projectTooLarge = false $scope.pdf.url = null + $scope.pdf.clsiMaintenance = false + $scope.pdf.tooRecentlyCompiled = false + $scope.pdf.renderingError = false + + # make a cache to look up files by name + fileByPath = {} + for file in response.outputFiles + fileByPath[file.path] = file if response.status == "timedout" + $scope.pdf.view = 'errors' $scope.pdf.timedout = true else if response.status == "autocompile-backoff" - $scope.pdf.uncompiled = true + $scope.pdf.view = 'uncompiled' else if response.status == "project-too-large" + $scope.pdf.view = 'errors' $scope.pdf.projectTooLarge = true else if response.status == "failure" + $scope.pdf.view = 'errors' $scope.pdf.failure = true - fetchLogs() + $scope.shouldShowLogs = true + fetchLogs(fileByPath['output.log'], fileByPath['output.blg']) + else if response.status == 'clsi-maintenance' + $scope.pdf.view = 'errors' + $scope.pdf.clsiMaintenance = true + else if response.status == "too-recently-compiled" + $scope.pdf.view = 'errors' + $scope.pdf.tooRecentlyCompiled = true else if response.status == "success" - # define the base url - $scope.pdf.url = "/project/#{$scope.project_id}/output/output.pdf?cache_bust=#{Date.now()}" + $scope.pdf.view = 'pdf' + $scope.shouldShowLogs = false + + # prepare query string + qs = {} + # define the base url. if the pdf file has a build number, pass it to the clsi in the url + if fileByPath['output.pdf']?.url? + $scope.pdf.url = fileByPath['output.pdf'].url + else if fileByPath['output.pdf']?.build? + build = fileByPath['output.pdf'].build + $scope.pdf.url = "/project/#{$scope.project_id}/build/#{build}/output/output.pdf" + else + $scope.pdf.url = "/project/#{$scope.project_id}/output/output.pdf" + # check if we need to bust cache (build id is unique so don't need it in that case) + if not fileByPath['output.pdf']?.build? + qs.cache_bust = "#{Date.now()}" # add a query string parameter for the compile group if response.compileGroup? $scope.pdf.compileGroup = response.compileGroup - $scope.pdf.url = $scope.pdf.url + "&compileGroup=#{$scope.pdf.compileGroup}" - # make a cache to look up files by name - fileByPath = {} - for file in response.outputFiles - fileByPath[file.path] = file - # if the pdf file has a build number, pass it to the clsi - if fileByPath['output.pdf']?.build? - build = fileByPath['output.pdf'].build - $scope.pdf.url = $scope.pdf.url + "&build=#{build}" + qs.compileGroup = "#{$scope.pdf.compileGroup}" + if response.clsiServerId? + qs.clsiserverid = response.clsiServerId + ide.clsiServerId = response.clsiServerId + # convert the qs hash into a query string and append it + qs_args = ("#{k}=#{v}" for k, v of qs) + $scope.pdf.qs = if qs_args.length then "?" + qs_args.join("&") else "" + $scope.pdf.url += $scope.pdf.qs - fetchLogs(fileByPath['output.log']) + fetchLogs(fileByPath['output.log'], fileByPath['output.blg']) IGNORE_FILES = ["output.fls", "output.fdb_latexmk"] $scope.pdf.outputFiles = [] @@ -77,51 +115,84 @@ define [ file.name = "#{file.path.replace(/^output\./, "")} file" else file.name = file.path + file.url = "/project/#{project_id}/output/#{file.path}" + if response.clsiServerId? + file.url = file.url + "?clsiserverid=#{response.clsiServerId}" $scope.pdf.outputFiles.push file - fetchLogs = (outputFile) -> - qs = if outputFile?.build? then "?build=#{outputFile.build}" else "" - $http.get "/project/#{$scope.project_id}/output/output.log" + qs - .success (log) -> - #console.log ">>", log - $scope.pdf.rawLog = log - logEntries = LogParser.parse(log, ignoreDuplicates: true) - #console.log ">>", logEntries - $scope.pdf.logEntries = logEntries - $scope.pdf.logEntries.all = logEntries.errors.concat(logEntries.warnings).concat(logEntries.typesetting) - # # # # - proceed = () -> - $scope.pdf.logEntryAnnotations = {} - for entry in logEntries.all - if entry.file? - entry.file = normalizeFilePath(entry.file) - entity = ide.fileTreeManager.findEntityByPath(entry.file) - if entity? - $scope.pdf.logEntryAnnotations[entity.id] ||= [] - $scope.pdf.logEntryAnnotations[entity.id].push { - row: entry.line - 1 - type: if entry.level == "error" then "error" else "warning" - text: entry.message - } - # Get the biber log and parse it too - $http.get "/project/#{$scope.project_id}/output/output.blg" + qs - .success (log) -> - window._s = $scope - biberLogEntries = BibLogParser.parse(log, {}) - if $scope.pdf.logEntries - entries = $scope.pdf.logEntries - all = biberLogEntries.errors.concat(biberLogEntries.warnings) - entries.all = entries.all.concat(all) - entries.errors = entries.errors.concat(biberLogEntries.errors) - entries.warnings = entries.warnings.concat(biberLogEntries.warnings) - proceed() - .error (e) -> - console.error ">> error", e - proceed() - # # # # - .error () -> - $scope.pdf.logEntries = [] - $scope.pdf.rawLog = "" + + fetchLogs = (logFile, blgFile) -> + + getFile = (name, file) -> + opts = + method:"GET" + params: + build:file.build + clsiserverid:ide.clsiServerId + if file.url? # FIXME clean this up when we have file.urls out consistently + opts.url = file.url + else if file?.build? + opts.url = "/project/#{$scope.project_id}/build/#{file.build}/output/#{name}" + else + opts.url = "/project/#{$scope.project_id}/output/#{name}" + return $http(opts) + + # accumulate the log entries + logEntries = + all: [] + errors: [] + warnings: [] + + accumulateResults = (newEntries) -> + for key in ['all', 'errors', 'warnings'] + logEntries[key] = logEntries[key].concat newEntries[key] + + # use the parsers for each file type + processLog = (log) -> + $scope.pdf.rawLog = log + {errors, warnings, typesetting} = LogParser.parse(log, ignoreDuplicates: true) + all = [].concat errors, warnings, typesetting + accumulateResults {all, errors, warnings} + + processBiber = (log) -> + {errors, warnings} = BibLogParser.parse(log, {}) + all = [].concat errors, warnings + accumulateResults {all, errors, warnings} + + # output the results + handleError = () -> + $scope.pdf.logEntries = [] + $scope.pdf.rawLog = "" + + annotateFiles = () -> + $scope.pdf.logEntries = logEntries + $scope.pdf.logEntryAnnotations = {} + for entry in logEntries.all + if entry.file? + entry.file = normalizeFilePath(entry.file) + entity = ide.fileTreeManager.findEntityByPath(entry.file) + if entity? + $scope.pdf.logEntryAnnotations[entity.id] ||= [] + $scope.pdf.logEntryAnnotations[entity.id].push { + row: entry.line - 1 + type: if entry.level == "error" then "error" else "warning" + text: entry.message + } + + # retrieve the logfile and process it + response = getFile('output.log', logFile) + .success processLog + .error handleError + + if blgFile? # retrieve the blg file if present + response.success () -> + getFile('output.blg', blgFile) + # ignore errors in biber file + .success processBiber + # display the combined result + .then annotateFiles + else # otherwise just display the result + response.success annotateFiles getRootDocOverride_id = () -> doc = ide.editorManager.getCurrentDocValue() @@ -157,7 +228,9 @@ define [ parseCompileResponse(data) .error () -> $scope.pdf.compiling = false + $scope.pdf.renderingError = false $scope.pdf.error = true + $scope.pdf.view = 'errors' # This needs to be public. ide.$scope.recompile = $scope.recompile @@ -166,18 +239,18 @@ define [ $http { url: "/project/#{$scope.project_id}/output" method: "DELETE" + params: + clsiserverid:ide.clsiServerId headers: "X-Csrf-Token": window.csrfToken } $scope.toggleLogs = () -> - if !$scope.pdf.view? or $scope.pdf.view == "pdf" - $scope.pdf.view = "logs" - else - $scope.pdf.view = "pdf" + $scope.shouldShowLogs = !$scope.shouldShowLogs $scope.showPdf = () -> $scope.pdf.view = "pdf" + $scope.shouldShowLogs = false $scope.toggleRawLog = () -> $scope.pdf.showRawLog = !$scope.pdf.showRawLog @@ -250,6 +323,7 @@ define [ file: path line: row + 1 column: column + clsiserverid:ide.clsiServerId } }) .success (data) -> @@ -277,6 +351,7 @@ define [ page: position.page + 1 h: position.offset.left.toFixed(2) v: position.offset.top.toFixed(2) + clsiserverid:ide.clsiServerId } }) .success (data) -> diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee index bd7883c156..5639d670f0 100644 --- a/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee +++ b/services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee @@ -16,7 +16,7 @@ define [ # PDFJS.disableStream # PDFJS.disableRange @scale = @options.scale || 1 - @pdfjs = PDFJS.getDocument @url + @pdfjs = PDFJS.getDocument {url: @url, rangeChunkSize: 2*65536} @pdfjs.onProgress = @options.progressCallback @document = $q.when(@pdfjs) @navigateFn = @options.navigateFn diff --git a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee index 1e6a2791d2..f0ec8d61b1 100644 --- a/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee +++ b/services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee @@ -290,11 +290,18 @@ define [ rescaleTimer = null , 0 + spinnerTimer = null doRescale = (scale) -> # console.log 'doRescale', scale return unless scale? origposition = angular.copy scope.position # console.log 'origposition', origposition + + if not spinnerTimer? + spinnerTimer = setTimeout () -> + spinner.add(element) + spinnerTimer = null + , 100 layoutReady.promise.then (parentSize) -> [h, w] = parentSize # console.log 'in promise', h, w @@ -312,7 +319,6 @@ define [ scope.$emit 'pdf:error', error elementTimer = null - spinnerTimer = null updateLayout = () -> # if element is zero-sized keep checking until it is ready # console.log 'checking element ready', element.height(), element.width() @@ -329,11 +335,6 @@ define [ ] # console.log 'resolving layoutReady with', scope.parentSize $timeout () -> - if not spinnerTimer? - spinnerTimer = setTimeout () -> - spinner.add(element) - spinnerTimer = null - , 100 layoutReady.resolve scope.parentSize scope.$emit 'flash-controls' diff --git a/services/web/public/coffee/ide/references/ReferencesManager.coffee b/services/web/public/coffee/ide/references/ReferencesManager.coffee index b0f49f54f8..2f1e95c5b1 100644 --- a/services/web/public/coffee/ide/references/ReferencesManager.coffee +++ b/services/web/public/coffee/ide/references/ReferencesManager.coffee @@ -11,6 +11,9 @@ define [ if entity?.name?.match /.*\.bib$/ @indexReferences([doc.doc_id], true) + @$scope.$on 'references:should-reindex', (e, data) => + @indexAllReferences(true) + # When we join the project: # index all references files # and don't broadcast to all clients diff --git a/services/web/public/coffee/ide/settings/services/settings.coffee b/services/web/public/coffee/ide/settings/services/settings.coffee index 78dda105ad..4e6bbcea3d 100644 --- a/services/web/public/coffee/ide/settings/services/settings.coffee +++ b/services/web/public/coffee/ide/settings/services/settings.coffee @@ -10,5 +10,10 @@ define [ saveProjectSettings: (data) -> data._csrf = window.csrfToken ide.$http.post "/project/#{ide.project_id}/settings", data + + saveProjectAdminSettings: (data) -> + 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/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index bb945a9ebc..13d5faea9f 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -143,7 +143,7 @@ define [ $scope.makePublic = () -> $scope.project.publicAccesLevel = $scope.inputs.privileges - settings.saveProjectSettings({publicAccessLevel: $scope.inputs.privileges}) + settings.saveProjectAdminSettings({publicAccessLevel: $scope.inputs.privileges}) $modalInstance.close() $scope.cancel = () -> @@ -153,7 +153,7 @@ define [ App.controller "MakePrivateModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) -> $scope.makePrivate = () -> $scope.project.publicAccesLevel = "private" - settings.saveProjectSettings({publicAccessLevel: "private"}) + settings.saveProjectAdminSettings({publicAccessLevel: "private"}) $modalInstance.close() $scope.cancel = () -> diff --git a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee b/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee index 3c69d3e276..e880a25eef 100644 --- a/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee +++ b/services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee @@ -5,11 +5,15 @@ define [ $scope.status = loading:true - $http.get("/project/#{ide.project_id}/wordcount") + opts = + url:"/project/#{ide.project_id}/wordcount" + method:"GET" + params: + clsiserverid:ide.clsiServerId + $http opts .success (data) -> $scope.status.loading = false $scope.data = data.texcount - console.log $scope.data .error () -> $scope.status.error = true diff --git a/services/web/public/coffee/libs.coffee b/services/web/public/coffee/libs.coffee index 3c30dd63f3..fd1b5d0c91 100644 --- a/services/web/public/coffee/libs.coffee +++ b/services/web/public/coffee/libs.coffee @@ -12,6 +12,7 @@ define [ "libs/angular-cookies" "libs/passfield" "libs/sixpack" + "libs/groove" "libs/angular-sixpack" "libs/ng-tags-input-3.0.0" ], () -> diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index ad3e8d8a36..d85d89cfe8 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -15,7 +15,8 @@ define [ "main/annual-upgrade" "main/register-users" "main/subscription/group-subscription-invite-controller" - "main/universties-site" + "main/contact-us" + "main/learn" "analytics/AbTestingManager" "directives/asyncForm" "directives/stopPropagation" @@ -30,3 +31,6 @@ define [ "__MAIN_CLIENTSIDE_INCLUDES__" ], () -> angular.bootstrap(document.body, ["SharelatexApp"]) + + + diff --git a/services/web/public/coffee/main/contact-us.coffee b/services/web/public/coffee/main/contact-us.coffee new file mode 100644 index 0000000000..08bf5bfa45 --- /dev/null +++ b/services/web/public/coffee/main/contact-us.coffee @@ -0,0 +1,66 @@ +define [ + "base" + "libs/platform" +], (App, platform) -> + + + App.controller 'ContactModal', ($scope, $modal) -> + $scope.contactUsModal = () -> + modalInstance = $modal.open( + templateUrl: "supportModalTemplate" + controller: "SupportModalController" + ) + + App.controller 'SupportModalController', ($scope, $modalInstance) -> + $scope.form = {} + $scope.sent = false + $scope.sending = false + $scope.contactUs = -> + if !$scope.form.email? + console.log "email not set" + return + $scope.sending = true + ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) + message = $scope.form.message + if $scope.form.project_url? + message = "#{message}\n\n project_url = #{$scope.form.project_url}" + params = + email: $scope.form.email + message: message or "" + subject: $scope.form.subject + " - [#{ticketNumber}]" + labels: "support" + about: "
browser: #{platform?.name} #{platform?.version}
+
os: #{platform?.os?.family} #{platform?.os?.version}
" + + Groove.createTicket params, (err, json)-> + $scope.sent = true + $scope.$apply() + + $scope.close = () -> + $modalInstance.close() + + + App.controller 'UniverstiesContactController', ($scope, $modal) -> + + $scope.form = {} + $scope.sent = false + $scope.sending = false + $scope.contactUs = -> + if !$scope.form.email? + console.log "email not set" + return + $scope.sending = true + ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) + params = + name: $scope.form.name || $scope.form.email + email: $scope.form.email + labels: "#{$scope.form.source} accounts" + message: "Please contact me with more details" + subject: $scope.form.subject + " - [#{ticketNumber}]" + about : "#{$scope.form.position || ''} #{$scope.form.university || ''}" + + Groove.createTicket params, (err, json)-> + $scope.sent = true + $scope.$apply() + + diff --git a/services/web/public/coffee/main/learn.coffee b/services/web/public/coffee/main/learn.coffee new file mode 100644 index 0000000000..28026be508 --- /dev/null +++ b/services/web/public/coffee/main/learn.coffee @@ -0,0 +1,92 @@ +define [ + "base" +], (App) -> + + App.factory "algoliawiki", -> + if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki? + client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key) + index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki) + return index + + App.controller "SearchWikiController", ($scope, algoliawiki, _, $modal) -> + algolia = algoliawiki + $scope.hits = [] + + $scope.clearSearchText = -> + $scope.searchQueryText = "" + updateHits [] + + $scope.safeApply = (fn)-> + phase = $scope.$root.$$phase + if(phase == '$apply' || phase == '$digest') + $scope.$eval(fn) + else + $scope.$apply(fn) + + buildHitViewModel = (hit)-> + page_underscored = hit.pageName.replace(/\s/g,'_') + section_underscored = hit.sectionName.replace(/\s/g,'_') + content = hit._highlightResult.content.value + # Replace many new lines + content = content.replace(/\n\n+/g, "\n\n") + lines = content.split("\n") + # Only show the lines that have a highlighted match + matching_lines = [] + for line in lines + if !line.match(/^\[edit\]/) + content += line + "\n" + if line.match(//) + matching_lines.push line + content = matching_lines.join("\n...\n") + result = + name : hit._highlightResult.pageName.value + " - " + hit._highlightResult.sectionName.value + url :"/learn/#{page_underscored}##{section_underscored}" + content: content + return result + + updateHits = (hits)-> + $scope.safeApply -> + $scope.hits = hits + + $scope.search = -> + query = $scope.searchQueryText + if !query? or query.length == 0 + updateHits [] + return + + algolia.search query, (err, response)-> + if response.hits.length == 0 + updateHits [] + else + hits = _.map response.hits, buildHitViewModel + updateHits hits + + $scope.showMissingTemplateModal = () -> + modalInstance = $modal.open( + templateUrl: "missingWikiPageModal" + controller: "MissingWikiPageController" + ) + + + App.controller 'MissingWikiPageController', ($scope, $modalInstance) -> + $scope.form = {} + $scope.sent = false + $scope.sending = false + $scope.contactUs = -> + if !$scope.form.message? + console.log "message not set" + return + $scope.sending = true + ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) + params = + email: $scope.form.email or "support@sharelatex.com" + message: $scope.form.message or "" + subject: "new wiki page sujection - [#{ticketNumber}]" + labels: "support wiki" + + Groove.createTicket params, (err, json)-> + $scope.sent = true + $scope.$apply() + + $scope.close = () -> + $modalInstance.close() diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index a841e369a8..dbf3c613ac 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -65,11 +65,11 @@ define [ $scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner" if $scope.projectsToLeave.length > 0 and $scope.projectsToDelete.length > 0 - $scope.action = "Delete & Leave" + $scope.action = "delete-and-leave" else if $scope.projectsToLeave.length == 0 and $scope.projectsToDelete.length > 0 - $scope.action = "Delete" + $scope.action = "delete" else - $scope.action = "Leave" + $scope.action = "leave" $scope.delete = () -> $modalInstance.close() diff --git a/services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee b/services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee index ccfae4a3cf..eadd9e2d83 100644 --- a/services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee +++ b/services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee @@ -3,12 +3,31 @@ define [ ], (App) -> App.controller "GroupSubscriptionInviteController", ($scope, $http) -> - $scope.requestSent = false + $scope.inflight = false + + if has_personal_subscription + $scope.view = "personalSubscription" + else + $scope.view = "groupSubscriptionInvite" + + $scope.keepPersonalSubscription = -> + $scope.view = "groupSubscriptionInvite" + + $scope.cancelSubscription = -> + $scope.inflight = true + request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken} + request.success (data, status)-> + $scope.inflight = false + $scope.view = "groupSubscriptionInvite" + request.error (data, status)-> + console.log "the request failed" $scope.joinGroup = -> - $scope.requestSent = true - request = $http.post "/user/subscription/#{subscription_id}/group/begin-join", {_csrf:window.csrfToken} + $scope.view = "requestSent" + $scope.inflight = true + request = $http.post "/user/subscription/#{group_subscription_id}/group/begin-join", {_csrf:window.csrfToken} request.success (data, status)-> + $scope.inflight = false if status != 200 # assume request worked $scope.requestSent = false request.error (data, status)-> diff --git a/services/web/public/coffee/main/templates.coffee b/services/web/public/coffee/main/templates.coffee new file mode 100644 index 0000000000..3d05d4ea0f --- /dev/null +++ b/services/web/public/coffee/main/templates.coffee @@ -0,0 +1,62 @@ +define [ + "base" +], (App) -> + + App.factory "algoliawiki", -> + if window.sharelatex?.algolia? and window.sharelatex.algolia?.indexes?.wiki? + client = new AlgoliaSearch(window.sharelatex.algolia?.app_id, window.sharelatex.algolia?.api_key) + index = client.initIndex(window.sharelatex.algolia?.indexes?.wiki) + return index + + App.controller "SearchWikiController", ($scope, algoliawiki, _) -> + algolia = algoliawiki + $scope.hits = [] + + $scope.clearSearchText = -> + $scope.searchQueryText = "" + updateHits [] + + $scope.safeApply = (fn)-> + phase = $scope.$root.$$phase + if(phase == '$apply' || phase == '$digest') + $scope.$eval(fn) + else + $scope.$apply(fn) + + buildHitViewModel = (hit)-> + page_underscored = hit.pageName.replace(/\s/g,'_') + section_underscored = hit.sectionName.replace(/\s/g,'_') + content = hit._highlightResult.content.value + # Replace many new lines + content = content.replace(/\n\n+/g, "\n\n") + lines = content.split("\n") + # Only show the lines that have a highlighted match + matching_lines = [] + for line in lines + if !line.match(/^\[edit\]/) + content += line + "\n" + if line.match(//) + matching_lines.push line + content = matching_lines.join("\n...\n") + result = + name : hit._highlightResult.pageName.value + " - " + hit._highlightResult.sectionName.value + url :"/learn/#{page_underscored}##{section_underscored}" + content: content + return result + + updateHits = (hits)-> + $scope.safeApply -> + $scope.hits = hits + + $scope.search = -> + query = $scope.searchQueryText + if !query? or query.length == 0 + updateHits [] + return + + algolia.search query, (err, response)-> + if response.hits.length == 0 + updateHits [] + else + hits = _.map response.hits, buildHitViewModel + updateHits hits \ No newline at end of file diff --git a/services/web/public/coffee/main/universties-site.coffee b/services/web/public/coffee/main/universties-site.coffee deleted file mode 100644 index c62f4adc50..0000000000 --- a/services/web/public/coffee/main/universties-site.coffee +++ /dev/null @@ -1,26 +0,0 @@ -define [ - "base" -], (App) -> - - App.controller 'UniverstiesContactController', ($scope, $modal) -> - - $scope.form = {} - $scope.sent = false - $scope.sending = false - $scope.contactUs = -> - if !$scope.form.email? - console.log "email not set" - return - $scope.sending = true - ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32) - params = - name: $scope.form.name || $scope.form.email - email: $scope.form.email - labels: $scope.form.source - message: "Please contact me with more details" - subject: $scope.form.subject + " - [#{ticketNumber}]" - about : "#{$scope.form.position || ''} #{$scope.form.university || ''}" - - Groove.createTicket params, (err, json)-> - $scope.sent = true - $scope.$apply() diff --git a/services/web/public/coffee/main/user-details.coffee b/services/web/public/coffee/main/user-details.coffee index e79ccfbbe4..f9e159e9d0 100644 --- a/services/web/public/coffee/main/user-details.coffee +++ b/services/web/public/coffee/main/user-details.coffee @@ -16,6 +16,7 @@ define [ _csrf : window.csrfToken $scope.showForm = -> + GrooveWidget.toggle() $scope.formVisable = true $scope.getPercentComplete = -> diff --git a/services/web/public/img/about/michael.jpg b/services/web/public/img/about/michael.jpg new file mode 100644 index 0000000000..afb6022a64 Binary files /dev/null and b/services/web/public/img/about/michael.jpg differ diff --git a/services/web/public/img/lion-sad-128.png b/services/web/public/img/lion-sad-128.png new file mode 100644 index 0000000000..e5e58c42b0 Binary files /dev/null and b/services/web/public/img/lion-sad-128.png differ diff --git a/services/web/public/img/spinner.gif b/services/web/public/img/spinner.gif new file mode 100644 index 0000000000..22220a2214 Binary files /dev/null and b/services/web/public/img/spinner.gif differ diff --git a/services/web/public/js/libs/groove.js b/services/web/public/js/libs/groove.js new file mode 100644 index 0000000000..983b2a73dc --- /dev/null +++ b/services/web/public/js/libs/groove.js @@ -0,0 +1,84 @@ +!function(window) { + + window.Groove = { + + init: function(options) { + this._options = options; + if (typeof grooveOnReady != 'undefined') {grooveOnReady();} + }, + + createTicket: function(params, callback) { + var postData = serialize({ + "ticket[enduser_name]": params["name"], + "ticket[enduser_email]": params["email"], + "ticket[title]": params["subject"], + "ticket[enduser_about]": params["about"], + "ticket[label_string]": params["labels"], + "ticket[comments_attributes][0][body]": params["message"] + }); + + sendRequest(this._options.widget_ticket_url, function(req) { + if (callback) {callback(req);} + }, postData); + } + }; + + // http://www.quirksmode.org/js/xmlhttp.html + function sendRequest(url, callback, postData) { + var req = createXMLHTTPObject(); + if (!req) return; + var method = (postData) ? "POST" : "GET"; + req.open(method, url, true); + if (postData){ + try { + req.setRequestHeader('Content-type','application/x-www-form-urlencoded'); + } + catch(e) { + req.contentType = 'application/x-www-form-urlencoded'; + }; + }; + req.onreadystatechange = function () { + if (req.readyState != 4) return; + callback(req); + } + if (req.readyState == 4) return; + req.send(postData); + } + + var XMLHttpFactories = [ + function () {return new XDomainRequest()}, + function () {return new XMLHttpRequest()}, + function () {return new ActiveXObject("Msxml2.XMLHTTP")}, + function () {return new ActiveXObject("Msxml3.XMLHTTP")}, + function () {return new ActiveXObject("Microsoft.XMLHTTP")} + ]; + + function createXMLHTTPObject() { + var xmlhttp = false; + for (var i = 0; i < XMLHttpFactories.length; i++) { + try { + xmlhttp = XMLHttpFactories[i](); + } + catch (e) { + continue; + } + break; + } + return xmlhttp; + } + + function serialize(obj) { + var str = []; + for(var p in obj) { + if (obj[p]) { + str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p])); + } + } + return str.join("&"); +} + +if (typeof grooveOnLoad != 'undefined') {grooveOnLoad();} +}(window); + +Groove.init({"widget_ticket_url":"https://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket.json"}); + diff --git a/services/web/public/js/libs/platform.js b/services/web/public/js/libs/platform.js new file mode 100644 index 0000000000..d50f7355cf --- /dev/null +++ b/services/web/public/js/libs/platform.js @@ -0,0 +1,1130 @@ +/*! + * Platform.js v1.3.1 + * Copyright 2014-2016 Benjamin Tan + * Copyright 2011-2013 John-David Dalton + * Available under MIT license + */ +;(function() { + 'use strict'; + + /** Used to determine if values are of the language type `Object`. */ + var objectTypes = { + 'function': true, + 'object': true + }; + + /** Used as a reference to the global object. */ + var root = (objectTypes[typeof window] && window) || this; + + /** Backup possible global object. */ + var oldRoot = root; + + /** Detect free variable `exports`. */ + var freeExports = objectTypes[typeof exports] && exports; + + /** Detect free variable `module`. */ + var freeModule = objectTypes[typeof module] && module && !module.nodeType && module; + + /** Detect free variable `global` from Node.js or Browserified code and use it as `root`. */ + var freeGlobal = freeExports && freeModule && typeof global == 'object' && global; + if (freeGlobal && (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal || freeGlobal.self === freeGlobal)) { + root = freeGlobal; + } + + /** + * Used as the maximum length of an array-like object. + * See the [ES6 spec](http://people.mozilla.org/~jorendorff/es6-draft.html#sec-tolength) + * for more details. + */ + var maxSafeInteger = Math.pow(2, 53) - 1; + + /** Regular expression to detect Opera. */ + var reOpera = /\bOpera/; + + /** Possible global object. */ + var thisBinding = this; + + /** Used for native method references. */ + var objectProto = Object.prototype; + + /** Used to check for own properties of an object. */ + var hasOwnProperty = objectProto.hasOwnProperty; + + /** Used to resolve the internal `[[Class]]` of values. */ + var toString = objectProto.toString; + + /*--------------------------------------------------------------------------*/ + + /** + * Capitalizes a string value. + * + * @private + * @param {string} string The string to capitalize. + * @returns {string} The capitalized string. + */ + function capitalize(string) { + string = String(string); + return string.charAt(0).toUpperCase() + string.slice(1); + } + + /** + * A utility function to clean up the OS name. + * + * @private + * @param {string} os The OS name to clean up. + * @param {string} [pattern] A `RegExp` pattern matching the OS name. + * @param {string} [label] A label for the OS. + */ + function cleanupOS(os, pattern, label) { + // Platform tokens are defined at: + // http://msdn.microsoft.com/en-us/library/ms537503(VS.85).aspx + // http://web.archive.org/web/20081122053950/http://msdn.microsoft.com/en-us/library/ms537503(VS.85).aspx + var data = { + '10.0': '10', + '6.4': '10 Technical Preview', + '6.3': '8.1', + '6.2': '8', + '6.1': '7 / Server 2008 R2', + '6.0': 'Vista / Server 2008', + '5.2': 'XP 64-bit / Server 2003', + '5.1': 'XP', + '5.01': '2000 SP1', + '5.0': '2000', + '4.0': 'NT', + '4.90': 'ME' + }; + // Detect Windows version from platform tokens. + if (pattern && label && /^Win/i.test(os) && !/^Windows Phone /i.test(os) && + (data = data[/[\d.]+$/.exec(os)])) { + os = 'Windows ' + data; + } + // Correct character case and cleanup string. + os = String(os); + + if (pattern && label) { + os = os.replace(RegExp(pattern, 'i'), label); + } + + os = format( + os.replace(/ ce$/i, ' CE') + .replace(/\bhpw/i, 'web') + .replace(/\bMacintosh\b/, 'Mac OS') + .replace(/_PowerPC\b/i, ' OS') + .replace(/\b(OS X) [^ \d]+/i, '$1') + .replace(/\bMac (OS X)\b/, '$1') + .replace(/\/(\d)/, ' $1') + .replace(/_/g, '.') + .replace(/(?: BePC|[ .]*fc[ \d.]+)$/i, '') + .replace(/\bx86\.64\b/gi, 'x86_64') + .replace(/\b(Windows Phone) OS\b/, '$1') + .replace(/\b(Chrome OS \w+) [\d.]+\b/, '$1') + .split(' on ')[0] + ); + + return os; + } + + /** + * An iteration utility for arrays and objects. + * + * @private + * @param {Array|Object} object The object to iterate over. + * @param {Function} callback The function called per iteration. + */ + function each(object, callback) { + var index = -1, + length = object ? object.length : 0; + + if (typeof length == 'number' && length > -1 && length <= maxSafeInteger) { + while (++index < length) { + callback(object[index], index, object); + } + } else { + forOwn(object, callback); + } + } + + /** + * Trim and conditionally capitalize string values. + * + * @private + * @param {string} string The string to format. + * @returns {string} The formatted string. + */ + function format(string) { + string = trim(string); + return /^(?:webOS|i(?:OS|P))/.test(string) + ? string + : capitalize(string); + } + + /** + * Iterates over an object's own properties, executing the `callback` for each. + * + * @private + * @param {Object} object The object to iterate over. + * @param {Function} callback The function executed per own property. + */ + function forOwn(object, callback) { + for (var key in object) { + if (hasOwnProperty.call(object, key)) { + callback(object[key], key, object); + } + } + } + + /** + * Gets the internal `[[Class]]` of a value. + * + * @private + * @param {*} value The value. + * @returns {string} The `[[Class]]`. + */ + function getClassOf(value) { + return value == null + ? capitalize(value) + : toString.call(value).slice(8, -1); + } + + /** + * Host objects can return type values that are different from their actual + * data type. The objects we are concerned with usually return non-primitive + * types of "object", "function", or "unknown". + * + * @private + * @param {*} object The owner of the property. + * @param {string} property The property to check. + * @returns {boolean} Returns `true` if the property value is a non-primitive, else `false`. + */ + function isHostType(object, property) { + var type = object != null ? typeof object[property] : 'number'; + return !/^(?:boolean|number|string|undefined)$/.test(type) && + (type == 'object' ? !!object[property] : true); + } + + /** + * Prepares a string for use in a `RegExp` by making hyphens and spaces optional. + * + * @private + * @param {string} string The string to qualify. + * @returns {string} The qualified string. + */ + function qualify(string) { + return String(string).replace(/([ -])(?!$)/g, '$1?'); + } + + /** + * A bare-bones `Array#reduce` like utility function. + * + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function called per iteration. + * @returns {*} The accumulated result. + */ + function reduce(array, callback) { + var accumulator = null; + each(array, function(value, index) { + accumulator = callback(accumulator, value, index, array); + }); + return accumulator; + } + + /** + * Removes leading and trailing whitespace from a string. + * + * @private + * @param {string} string The string to trim. + * @returns {string} The trimmed string. + */ + function trim(string) { + return String(string).replace(/^ +| +$/g, ''); + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a new platform object. + * + * @memberOf platform + * @param {Object|string} [ua=navigator.userAgent] The user agent string or + * context object. + * @returns {Object} A platform object. + */ + function parse(ua) { + + /** The environment context object. */ + var context = root; + + /** Used to flag when a custom context is provided. */ + var isCustomContext = ua && typeof ua == 'object' && getClassOf(ua) != 'String'; + + // Juggle arguments. + if (isCustomContext) { + context = ua; + ua = null; + } + + /** Browser navigator object. */ + var nav = context.navigator || {}; + + /** Browser user agent string. */ + var userAgent = nav.userAgent || ''; + + ua || (ua = userAgent); + + /** Used to flag when `thisBinding` is the [ModuleScope]. */ + var isModuleScope = isCustomContext || thisBinding == oldRoot; + + /** Used to detect if browser is like Chrome. */ + var likeChrome = isCustomContext + ? !!nav.likeChrome + : /\bChrome\b/.test(ua) && !/internal|\n/i.test(toString.toString()); + + /** Internal `[[Class]]` value shortcuts. */ + var objectClass = 'Object', + airRuntimeClass = isCustomContext ? objectClass : 'ScriptBridgingProxyObject', + enviroClass = isCustomContext ? objectClass : 'Environment', + javaClass = (isCustomContext && context.java) ? 'JavaPackage' : getClassOf(context.java), + phantomClass = isCustomContext ? objectClass : 'RuntimeObject'; + + /** Detect Java environments. */ + var java = /\bJava/.test(javaClass) && context.java; + + /** Detect Rhino. */ + var rhino = java && getClassOf(context.environment) == enviroClass; + + /** A character to represent alpha. */ + var alpha = java ? 'a' : '\u03b1'; + + /** A character to represent beta. */ + var beta = java ? 'b' : '\u03b2'; + + /** Browser document object. */ + var doc = context.document || {}; + + /** + * Detect Opera browser (Presto-based). + * http://www.howtocreate.co.uk/operaStuff/operaObject.html + * http://dev.opera.com/articles/view/opera-mini-web-content-authoring-guidelines/#operamini + */ + var opera = context.operamini || context.opera; + + /** Opera `[[Class]]`. */ + var operaClass = reOpera.test(operaClass = (isCustomContext && opera) ? opera['[[Class]]'] : getClassOf(opera)) + ? operaClass + : (opera = null); + + /*------------------------------------------------------------------------*/ + + /** Temporary variable used over the script's lifetime. */ + var data; + + /** The CPU architecture. */ + var arch = ua; + + /** Platform description array. */ + var description = []; + + /** Platform alpha/beta indicator. */ + var prerelease = null; + + /** A flag to indicate that environment features should be used to resolve the platform. */ + var useFeatures = ua == userAgent; + + /** The browser/environment version. */ + var version = useFeatures && opera && typeof opera.version == 'function' && opera.version(); + + /** A flag to indicate if the OS begins with "Name Version /". */ + var isSpecialCasedOS; + + /* Detectable layout engines (order is important). */ + var layout = getLayout([ + { 'label': 'EdgeHTML', 'pattern': 'Edge' }, + 'Trident', + { 'label': 'WebKit', 'pattern': 'AppleWebKit' }, + 'iCab', + 'Presto', + 'NetFront', + 'Tasman', + 'KHTML', + 'Gecko' + ]); + + /* Detectable browser names (order is important). */ + var name = getName([ + 'Adobe AIR', + 'Arora', + 'Avant Browser', + 'Breach', + 'Camino', + 'Epiphany', + 'Fennec', + 'Flock', + 'Galeon', + 'GreenBrowser', + 'iCab', + 'Iceweasel', + 'K-Meleon', + 'Konqueror', + 'Lunascape', + 'Maxthon', + { 'label': 'Microsoft Edge', 'pattern': 'Edge' }, + 'Midori', + 'Nook Browser', + 'PaleMoon', + 'PhantomJS', + 'Raven', + 'Rekonq', + 'RockMelt', + 'SeaMonkey', + { 'label': 'Silk', 'pattern': '(?:Cloud9|Silk-Accelerated)' }, + 'Sleipnir', + 'SlimBrowser', + { 'label': 'SRWare Iron', 'pattern': 'Iron' }, + 'Sunrise', + 'Swiftfox', + 'WebPositive', + 'Opera Mini', + { 'label': 'Opera Mini', 'pattern': 'OPiOS' }, + 'Opera', + { 'label': 'Opera', 'pattern': 'OPR' }, + 'Chrome', + { 'label': 'Chrome Mobile', 'pattern': '(?:CriOS|CrMo)' }, + { 'label': 'Firefox', 'pattern': '(?:Firefox|Minefield)' }, + { 'label': 'Firefox Mobile', 'pattern': 'FxiOS' }, + { 'label': 'IE', 'pattern': 'IEMobile' }, + { 'label': 'IE', 'pattern': 'MSIE' }, + 'Safari' + ]); + + /* Detectable products (order is important). */ + var product = getProduct([ + { 'label': 'BlackBerry', 'pattern': 'BB10' }, + 'BlackBerry', + { 'label': 'Galaxy S', 'pattern': 'GT-I9000' }, + { 'label': 'Galaxy S2', 'pattern': 'GT-I9100' }, + { 'label': 'Galaxy S3', 'pattern': 'GT-I9300' }, + { 'label': 'Galaxy S4', 'pattern': 'GT-I9500' }, + 'Google TV', + 'Lumia', + 'iPad', + 'iPod', + 'iPhone', + 'Kindle', + { 'label': 'Kindle Fire', 'pattern': '(?:Cloud9|Silk-Accelerated)' }, + 'Nexus', + 'Nook', + 'PlayBook', + 'PlayStation 3', + 'PlayStation 4', + 'PlayStation Vita', + 'TouchPad', + 'Transformer', + { 'label': 'Wii U', 'pattern': 'WiiU' }, + 'Wii', + 'Xbox One', + { 'label': 'Xbox 360', 'pattern': 'Xbox' }, + 'Xoom' + ]); + + /* Detectable manufacturers. */ + var manufacturer = getManufacturer({ + 'Apple': { 'iPad': 1, 'iPhone': 1, 'iPod': 1 }, + 'Amazon': { 'Kindle': 1, 'Kindle Fire': 1 }, + 'Asus': { 'Transformer': 1 }, + 'Barnes & Noble': { 'Nook': 1 }, + 'BlackBerry': { 'PlayBook': 1 }, + 'Google': { 'Google TV': 1, 'Nexus': 1 }, + 'HP': { 'TouchPad': 1 }, + 'HTC': {}, + 'LG': {}, + 'Microsoft': { 'Xbox': 1, 'Xbox One': 1 }, + 'Motorola': { 'Xoom': 1 }, + 'Nintendo': { 'Wii U': 1, 'Wii': 1 }, + 'Nokia': { 'Lumia': 1 }, + 'Samsung': { 'Galaxy S': 1, 'Galaxy S2': 1, 'Galaxy S3': 1, 'Galaxy S4': 1 }, + 'Sony': { 'PlayStation 4': 1, 'PlayStation 3': 1, 'PlayStation Vita': 1 } + }); + + /* Detectable operating systems (order is important). */ + var os = getOS([ + 'Windows Phone ', + 'Android', + 'CentOS', + { 'label': 'Chrome OS', 'pattern': 'CrOS' }, + 'Debian', + 'Fedora', + 'FreeBSD', + 'Gentoo', + 'Haiku', + 'Kubuntu', + 'Linux Mint', + 'OpenBSD', + 'Red Hat', + 'SuSE', + 'Ubuntu', + 'Xubuntu', + 'Cygwin', + 'Symbian OS', + 'hpwOS', + 'webOS ', + 'webOS', + 'Tablet OS', + 'Linux', + 'Mac OS X', + 'Macintosh', + 'Mac', + 'Windows 98;', + 'Windows ' + ]); + + /*------------------------------------------------------------------------*/ + + /** + * Picks the layout engine from an array of guesses. + * + * @private + * @param {Array} guesses An array of guesses. + * @returns {null|string} The detected layout engine. + */ + function getLayout(guesses) { + return reduce(guesses, function(result, guess) { + return result || RegExp('\\b' + ( + guess.pattern || qualify(guess) + ) + '\\b', 'i').exec(ua) && (guess.label || guess); + }); + } + + /** + * Picks the manufacturer from an array of guesses. + * + * @private + * @param {Array} guesses An object of guesses. + * @returns {null|string} The detected manufacturer. + */ + function getManufacturer(guesses) { + return reduce(guesses, function(result, value, key) { + // Lookup the manufacturer by product or scan the UA for the manufacturer. + return result || ( + value[product] || + value[/^[a-z]+(?: +[a-z]+\b)*/i.exec(product)] || + RegExp('\\b' + qualify(key) + '(?:\\b|\\w*\\d)', 'i').exec(ua) + ) && key; + }); + } + + /** + * Picks the browser name from an array of guesses. + * + * @private + * @param {Array} guesses An array of guesses. + * @returns {null|string} The detected browser name. + */ + function getName(guesses) { + return reduce(guesses, function(result, guess) { + return result || RegExp('\\b' + ( + guess.pattern || qualify(guess) + ) + '\\b', 'i').exec(ua) && (guess.label || guess); + }); + } + + /** + * Picks the OS name from an array of guesses. + * + * @private + * @param {Array} guesses An array of guesses. + * @returns {null|string} The detected OS name. + */ + function getOS(guesses) { + return reduce(guesses, function(result, guess) { + var pattern = guess.pattern || qualify(guess); + if (!result && (result = + RegExp('\\b' + pattern + '(?:/[\\d.]+|[ \\w.]*)', 'i').exec(ua) + )) { + result = cleanupOS(result, pattern, guess.label || guess); + } + return result; + }); + } + + /** + * Picks the product name from an array of guesses. + * + * @private + * @param {Array} guesses An array of guesses. + * @returns {null|string} The detected product name. + */ + function getProduct(guesses) { + return reduce(guesses, function(result, guess) { + var pattern = guess.pattern || qualify(guess); + if (!result && (result = + RegExp('\\b' + pattern + ' *\\d+[.\\w_]*', 'i').exec(ua) || + RegExp('\\b' + pattern + '(?:; *(?:[a-z]+[_-])?[a-z]+\\d+|[^ ();-]*)', 'i').exec(ua) + )) { + // Split by forward slash and append product version if needed. + if ((result = String((guess.label && !RegExp(pattern, 'i').test(guess.label)) ? guess.label : result).split('/'))[1] && !/[\d.]+/.test(result[0])) { + result[0] += ' ' + result[1]; + } + // Correct character case and cleanup string. + guess = guess.label || guess; + result = format(result[0] + .replace(RegExp(pattern, 'i'), guess) + .replace(RegExp('; *(?:' + guess + '[_-])?', 'i'), ' ') + .replace(RegExp('(' + guess + ')[-_.]?(\\w)', 'i'), '$1 $2')); + } + return result; + }); + } + + /** + * Resolves the version using an array of UA patterns. + * + * @private + * @param {Array} patterns An array of UA patterns. + * @returns {null|string} The detected version. + */ + function getVersion(patterns) { + return reduce(patterns, function(result, pattern) { + return result || (RegExp(pattern + + '(?:-[\\d.]+/|(?: for [\\w-]+)?[ /-])([\\d.]+[^ ();/_-]*)', 'i').exec(ua) || 0)[1] || null; + }); + } + + /** + * Returns `platform.description` when the platform object is coerced to a string. + * + * @name toString + * @memberOf platform + * @returns {string} Returns `platform.description` if available, else an empty string. + */ + function toStringPlatform() { + return this.description || ''; + } + + /*------------------------------------------------------------------------*/ + + // Convert layout to an array so we can add extra details. + layout && (layout = [layout]); + + // Detect product names that contain their manufacturer's name. + if (manufacturer && !product) { + product = getProduct([manufacturer]); + } + // Clean up Google TV. + if ((data = /\bGoogle TV\b/.exec(product))) { + product = data[0]; + } + // Detect simulators. + if (/\bSimulator\b/i.test(ua)) { + product = (product ? product + ' ' : '') + 'Simulator'; + } + // Detect Opera Mini 8+ running in Turbo/Uncompressed mode on iOS. + if (name == 'Opera Mini' && /\bOPiOS\b/.test(ua)) { + description.push('running in Turbo/Uncompressed mode'); + } + // Detect iOS. + if (/^iP/.test(product)) { + name || (name = 'Safari'); + os = 'iOS' + ((data = / OS ([\d_]+)/i.exec(ua)) + ? ' ' + data[1].replace(/_/g, '.') + : ''); + } + // Detect Kubuntu. + else if (name == 'Konqueror' && !/buntu/i.test(os)) { + os = 'Kubuntu'; + } + // Detect Android browsers. + else if (manufacturer && manufacturer != 'Google' && + ((/Chrome/.test(name) && !/\bMobile Safari\b/i.test(ua)) || /\bVita\b/.test(product))) { + name = 'Android Browser'; + os = /\bAndroid\b/.test(os) ? os : 'Android'; + } + // Detect Silk desktop/accelerated modes. + else if (name == 'Silk') { + if (!/\bMobi/i.test(ua)) { + os = 'Android'; + description.unshift('desktop mode'); + } + if (/Accelerated *= *true/i.test(ua)) { + description.unshift('accelerated'); + } + } + // Detect PaleMoon identifying as Firefox. + else if (name == 'PaleMoon' && (data = /\bFirefox\/([\d.]+)\b/.exec(ua))) { + description.push('identifying as Firefox ' + data[1]); + } + // Detect Firefox OS and products running Firefox. + else if (name == 'Firefox' && (data = /\b(Mobile|Tablet|TV)\b/i.exec(ua))) { + os || (os = 'Firefox OS'); + product || (product = data[1]); + } + // Detect false positives for Firefox/Safari. + else if (!name || (data = !/\bMinefield\b/i.test(ua) && /\b(?:Firefox|Safari)\b/.exec(name))) { + // Escape the `/` for Firefox 1. + if (name && !product && /[\/,]|^[^(]+?\)/.test(ua.slice(ua.indexOf(data + '/') + 8))) { + // Clear name of false positives. + name = null; + } + // Reassign a generic name. + if ((data = product || manufacturer || os) && + (product || manufacturer || /\b(?:Android|Symbian OS|Tablet OS|webOS)\b/.test(os))) { + name = /[a-z]+(?: Hat)?/i.exec(/\bAndroid\b/.test(os) ? os : data) + ' Browser'; + } + } + // Detect non-Opera (Presto-based) versions (order is important). + if (!version) { + version = getVersion([ + '(?:Cloud9|CriOS|CrMo|Edge|FxiOS|IEMobile|Iron|Opera ?Mini|OPiOS|OPR|Raven|Silk(?!/[\\d.]+$))', + 'Version', + qualify(name), + '(?:Firefox|Minefield|NetFront)' + ]); + } + // Detect stubborn layout engines. + if ((data = + layout == 'iCab' && parseFloat(version) > 3 && 'WebKit' || + /\bOpera\b/.test(name) && (/\bOPR\b/.test(ua) ? 'Blink' : 'Presto') || + /\b(?:Midori|Nook|Safari)\b/i.test(ua) && !/^(?:Trident|EdgeHTML)$/.test(layout) && 'WebKit' || + !layout && /\bMSIE\b/i.test(ua) && (os == 'Mac OS' ? 'Tasman' : 'Trident') || + layout == 'WebKit' && /\bPlayStation\b(?! Vita\b)/i.test(name) && 'NetFront' + )) { + layout = [data]; + } + // Detect Windows Phone 7 desktop mode. + if (name == 'IE' && (data = (/; *(?:XBLWP|ZuneWP)(\d+)/i.exec(ua) || 0)[1])) { + name += ' Mobile'; + os = 'Windows Phone ' + (/\+$/.test(data) ? data : data + '.x'); + description.unshift('desktop mode'); + } + // Detect Windows Phone 8.x desktop mode. + else if (/\bWPDesktop\b/i.test(ua)) { + name = 'IE Mobile'; + os = 'Windows Phone 8.x'; + description.unshift('desktop mode'); + version || (version = (/\brv:([\d.]+)/.exec(ua) || 0)[1]); + } + // Detect IE 11. + else if (name != 'IE' && layout == 'Trident' && (data = /\brv:([\d.]+)/.exec(ua))) { + if (name) { + description.push('identifying as ' + name + (version ? ' ' + version : '')); + } + name = 'IE'; + version = data[1]; + } + // Leverage environment features. + if (useFeatures) { + // Detect server-side environments. + // Rhino has a global function while others have a global object. + if (isHostType(context, 'global')) { + if (java) { + data = java.lang.System; + arch = data.getProperty('os.arch'); + os = os || data.getProperty('os.name') + ' ' + data.getProperty('os.version'); + } + if (isModuleScope && isHostType(context, 'system') && (data = [context.system])[0]) { + os || (os = data[0].os || null); + try { + data[1] = context.require('ringo/engine').version; + version = data[1].join('.'); + name = 'RingoJS'; + } catch(e) { + if (data[0].global.system == context.system) { + name = 'Narwhal'; + } + } + } + else if (typeof context.process == 'object' && (data = context.process)) { + name = 'Node.js'; + arch = data.arch; + os = data.platform; + version = /[\d.]+/.exec(data.version)[0]; + } + else if (rhino) { + name = 'Rhino'; + } + } + // Detect Adobe AIR. + else if (getClassOf((data = context.runtime)) == airRuntimeClass) { + name = 'Adobe AIR'; + os = data.flash.system.Capabilities.os; + } + // Detect PhantomJS. + else if (getClassOf((data = context.phantom)) == phantomClass) { + name = 'PhantomJS'; + version = (data = data.version || null) && (data.major + '.' + data.minor + '.' + data.patch); + } + // Detect IE compatibility modes. + else if (typeof doc.documentMode == 'number' && (data = /\bTrident\/(\d+)/i.exec(ua))) { + // We're in compatibility mode when the Trident version + 4 doesn't + // equal the document mode. + version = [version, doc.documentMode]; + if ((data = +data[1] + 4) != version[1]) { + description.push('IE ' + version[1] + ' mode'); + layout && (layout[1] = ''); + version[1] = data; + } + version = name == 'IE' ? String(version[1].toFixed(1)) : version[0]; + } + os = os && format(os); + } + // Detect prerelease phases. + if (version && (data = + /(?:[ab]|dp|pre|[ab]\d+pre)(?:\d+\+?)?$/i.exec(version) || + /(?:alpha|beta)(?: ?\d)?/i.exec(ua + ';' + (useFeatures && nav.appMinorVersion)) || + /\bMinefield\b/i.test(ua) && 'a' + )) { + prerelease = /b/i.test(data) ? 'beta' : 'alpha'; + version = version.replace(RegExp(data + '\\+?$'), '') + + (prerelease == 'beta' ? beta : alpha) + (/\d+\+?/.exec(data) || ''); + } + // Detect Firefox Mobile. + if (name == 'Fennec' || name == 'Firefox' && /\b(?:Android|Firefox OS)\b/.test(os)) { + name = 'Firefox Mobile'; + } + // Obscure Maxthon's unreliable version. + else if (name == 'Maxthon' && version) { + version = version.replace(/\.[\d.]+/, '.x'); + } + // Detect Xbox 360 and Xbox One. + else if (/\bXbox\b/i.test(product)) { + os = null; + if (product == 'Xbox 360' && /\bIEMobile\b/.test(ua)) { + description.unshift('mobile mode'); + } + } + // Add mobile postfix. + else if ((/^(?:Chrome|IE|Opera)$/.test(name) || name && !product && !/Browser|Mobi/.test(name)) && + (os == 'Windows CE' || /Mobi/i.test(ua))) { + name += ' Mobile'; + } + // Detect IE platform preview. + else if (name == 'IE' && useFeatures && context.external === null) { + description.unshift('platform preview'); + } + // Detect BlackBerry OS version. + // http://docs.blackberry.com/en/developers/deliverables/18169/HTTP_headers_sent_by_BB_Browser_1234911_11.jsp + else if ((/\bBlackBerry\b/.test(product) || /\bBB10\b/.test(ua)) && (data = + (RegExp(product.replace(/ +/g, ' *') + '/([.\\d]+)', 'i').exec(ua) || 0)[1] || + version + )) { + data = [data, /BB10/.test(ua)]; + os = (data[1] ? (product = null, manufacturer = 'BlackBerry') : 'Device Software') + ' ' + data[0]; + version = null; + } + // Detect Opera identifying/masking itself as another browser. + // http://www.opera.com/support/kb/view/843/ + else if (this != forOwn && product != 'Wii' && ( + (useFeatures && opera) || + (/Opera/.test(name) && /\b(?:MSIE|Firefox)\b/i.test(ua)) || + (name == 'Firefox' && /\bOS X (?:\d+\.){2,}/.test(os)) || + (name == 'IE' && ( + (os && !/^Win/.test(os) && version > 5.5) || + /\bWindows XP\b/.test(os) && version > 8 || + version == 8 && !/\bTrident\b/.test(ua) + )) + ) && !reOpera.test((data = parse.call(forOwn, ua.replace(reOpera, '') + ';'))) && data.name) { + // When "identifying", the UA contains both Opera and the other browser's name. + data = 'ing as ' + data.name + ((data = data.version) ? ' ' + data : ''); + if (reOpera.test(name)) { + if (/\bIE\b/.test(data) && os == 'Mac OS') { + os = null; + } + data = 'identify' + data; + } + // When "masking", the UA contains only the other browser's name. + else { + data = 'mask' + data; + if (operaClass) { + name = format(operaClass.replace(/([a-z])([A-Z])/g, '$1 $2')); + } else { + name = 'Opera'; + } + if (/\bIE\b/.test(data)) { + os = null; + } + if (!useFeatures) { + version = null; + } + } + layout = ['Presto']; + description.push(data); + } + // Detect WebKit Nightly and approximate Chrome/Safari versions. + if ((data = (/\bAppleWebKit\/([\d.]+\+?)/i.exec(ua) || 0)[1])) { + // Correct build number for numeric comparison. + // (e.g. "532.5" becomes "532.05") + data = [parseFloat(data.replace(/\.(\d)$/, '.0$1')), data]; + // Nightly builds are postfixed with a "+". + if (name == 'Safari' && data[1].slice(-1) == '+') { + name = 'WebKit Nightly'; + prerelease = 'alpha'; + version = data[1].slice(0, -1); + } + // Clear incorrect browser versions. + else if (version == data[1] || + version == (data[2] = (/\bSafari\/([\d.]+\+?)/i.exec(ua) || 0)[1])) { + version = null; + } + // Use the full Chrome version when available. + data[1] = (/\bChrome\/([\d.]+)/i.exec(ua) || 0)[1]; + // Detect Blink layout engine. + if (data[0] == 537.36 && data[2] == 537.36 && parseFloat(data[1]) >= 28 && layout == 'WebKit') { + layout = ['Blink']; + } + // Detect JavaScriptCore. + // http://stackoverflow.com/questions/6768474/how-can-i-detect-which-javascript-engine-v8-or-jsc-is-used-at-runtime-in-androi + if (!useFeatures || (!likeChrome && !data[1])) { + layout && (layout[1] = 'like Safari'); + data = (data = data[0], data < 400 ? 1 : data < 500 ? 2 : data < 526 ? 3 : data < 533 ? 4 : data < 534 ? '4+' : data < 535 ? 5 : data < 537 ? 6 : data < 538 ? 7 : data < 601 ? 8 : '8'); + } else { + layout && (layout[1] = 'like Chrome'); + data = data[1] || (data = data[0], data < 530 ? 1 : data < 532 ? 2 : data < 532.05 ? 3 : data < 533 ? 4 : data < 534.03 ? 5 : data < 534.07 ? 6 : data < 534.10 ? 7 : data < 534.13 ? 8 : data < 534.16 ? 9 : data < 534.24 ? 10 : data < 534.30 ? 11 : data < 535.01 ? 12 : data < 535.02 ? '13+' : data < 535.07 ? 15 : data < 535.11 ? 16 : data < 535.19 ? 17 : data < 536.05 ? 18 : data < 536.10 ? 19 : data < 537.01 ? 20 : data < 537.11 ? '21+' : data < 537.13 ? 23 : data < 537.18 ? 24 : data < 537.24 ? 25 : data < 537.36 ? 26 : layout != 'Blink' ? '27' : '28'); + } + // Add the postfix of ".x" or "+" for approximate versions. + layout && (layout[1] += ' ' + (data += typeof data == 'number' ? '.x' : /[.+]/.test(data) ? '' : '+')); + // Obscure version for some Safari 1-2 releases. + if (name == 'Safari' && (!version || parseInt(version) > 45)) { + version = data; + } + } + // Detect Opera desktop modes. + if (name == 'Opera' && (data = /\bzbov|zvav$/.exec(os))) { + name += ' '; + description.unshift('desktop mode'); + if (data == 'zvav') { + name += 'Mini'; + version = null; + } else { + name += 'Mobile'; + } + os = os.replace(RegExp(' *' + data + '$'), ''); + } + // Detect Chrome desktop mode. + else if (name == 'Safari' && /\bChrome\b/.exec(layout && layout[1])) { + description.unshift('desktop mode'); + name = 'Chrome Mobile'; + version = null; + + if (/\bOS X\b/.test(os)) { + manufacturer = 'Apple'; + os = 'iOS 4.3+'; + } else { + os = null; + } + } + // Strip incorrect OS versions. + if (version && version.indexOf((data = /[\d.]+$/.exec(os))) == 0 && + ua.indexOf('/' + data + '-') > -1) { + os = trim(os.replace(data, '')); + } + // Add layout engine. + if (layout && !/\b(?:Avant|Nook)\b/.test(name) && ( + /Browser|Lunascape|Maxthon/.test(name) || + name != 'Safari' && /^iOS/.test(os) && /\bSafari\b/.test(layout[1]) || + /^(?:Adobe|Arora|Breach|Midori|Opera|Phantom|Rekonq|Rock|Sleipnir|Web)/.test(name) && layout[1])) { + // Don't add layout details to description if they are falsey. + (data = layout[layout.length - 1]) && description.push(data); + } + // Combine contextual information. + if (description.length) { + description = ['(' + description.join('; ') + ')']; + } + // Append manufacturer to description. + if (manufacturer && product && product.indexOf(manufacturer) < 0) { + description.push('on ' + manufacturer); + } + // Append product to description. + if (product) { + description.push((/^on /.test(description[description.length - 1]) ? '' : 'on ') + product); + } + // Parse the OS into an object. + if (os) { + data = + / ([\d.+]+)$/.exec(os) || + (isSpecialCasedOS = /^[a-z]+ ([\d.+]+) \//i.exec(os)); + os = { + 'architecture': 32, + 'family': (data && !isSpecialCasedOS) ? os.replace(data[0], '') : os, + 'version': data ? data[1] : null, + 'toString': function() { + var version = this.version; + return this.family + ((version && !isSpecialCasedOS) ? ' ' + version : '') + (this.architecture == 64 ? ' 64-bit' : ''); + } + }; + } + // Add browser/OS architecture. + if ((data = /\b(?:AMD|IA|Win|WOW|x86_|x)64\b/i.exec(arch)) && !/\bi686\b/i.test(arch)) { + if (os) { + os.architecture = 64; + os.family = os.family.replace(RegExp(' *' + data), ''); + } + if ( + name && (/\bWOW64\b/i.test(ua) || + (useFeatures && /\w(?:86|32)$/.test(nav.cpuClass || nav.platform) && !/\bWin64; x64\b/i.test(ua))) + ) { + description.unshift('32-bit'); + } + } + + ua || (ua = null); + + /*------------------------------------------------------------------------*/ + + /** + * The platform object. + * + * @name platform + * @type Object + */ + var platform = {}; + + /** + * The platform description. + * + * @memberOf platform + * @type string|null + */ + platform.description = ua; + + /** + * The name of the browser's layout engine. + * + * @memberOf platform + * @type string|null + */ + platform.layout = layout && layout[0]; + + /** + * The name of the product's manufacturer. + * + * @memberOf platform + * @type string|null + */ + platform.manufacturer = manufacturer; + + /** + * The name of the browser/environment. + * + * @memberOf platform + * @type string|null + */ + platform.name = name; + + /** + * The alpha/beta release indicator. + * + * @memberOf platform + * @type string|null + */ + platform.prerelease = prerelease; + + /** + * The name of the product hosting the browser. + * + * @memberOf platform + * @type string|null + */ + platform.product = product; + + /** + * The browser's user agent string. + * + * @memberOf platform + * @type string|null + */ + platform.ua = ua; + + /** + * The browser/environment version. + * + * @memberOf platform + * @type string|null + */ + platform.version = name && version; + + /** + * The name of the operating system. + * + * @memberOf platform + * @type Object + */ + platform.os = os || { + + /** + * The CPU architecture the OS is built for. + * + * @memberOf platform.os + * @type number|null + */ + 'architecture': null, + + /** + * The family of the OS. + * + * Common values include: + * "Windows", "Windows 7 / Server 2008 R2", "Windows Vista / Server 2008", + * "Windows XP", "OS X", "Ubuntu", "Debian", "Fedora", "Red Hat", "SuSE", + * "Android", "iOS" and "Windows Phone" + * + * @memberOf platform.os + * @type string|null + */ + 'family': null, + + /** + * The version of the OS. + * + * @memberOf platform.os + * @type string|null + */ + 'version': null, + + /** + * Returns the OS string. + * + * @memberOf platform.os + * @returns {string} The OS string. + */ + 'toString': function() { return 'null'; } + }; + + platform.parse = parse; + platform.toString = toStringPlatform; + + if (platform.version) { + description.unshift(version); + } + if (platform.name) { + description.unshift(name); + } + if (os && name && !(os == String(os).split(' ')[0] && (os == name.split(' ')[0] || product))) { + description.push(product ? '(' + os + ')' : 'on ' + os); + } + if (description.length) { + platform.description = description.join(' '); + } + return platform; + } + + /*--------------------------------------------------------------------------*/ + + // Export platform. + // Some AMD build optimizers, like r.js, check for condition patterns like the following: + if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) { + // Define as an anonymous module so platform can be aliased through path mapping. + define(function() { + return parse(); + }); + } + // Check for `exports` after `define` in case a build optimizer adds an `exports` object. + else if (freeExports && freeModule) { + // Export for CommonJS support. + forOwn(parse(), function(value, key) { + freeExports[key] = value; + }); + } + else { + // Export to the global object. + root.platform = parse(); + } +}.call(this)); \ No newline at end of file diff --git a/services/web/public/stylesheets/app/contact-us.less b/services/web/public/stylesheets/app/contact-us.less new file mode 100644 index 0000000000..06747b5412 --- /dev/null +++ b/services/web/public/stylesheets/app/contact-us.less @@ -0,0 +1,6 @@ +.contact-us-modal { + + textarea { + height: 120px; + } +} \ No newline at end of file diff --git a/services/web/public/stylesheets/app/editor/binary-file.less b/services/web/public/stylesheets/app/editor/binary-file.less index 8e8467e5f9..04bc62931a 100644 --- a/services/web/public/stylesheets/app/editor/binary-file.less +++ b/services/web/public/stylesheets/app/editor/binary-file.less @@ -13,9 +13,35 @@ .box-shadow(0 2px 3px @gray;); background-color: white; } + .img-preview { + background: url('/img/spinner.gif') no-repeat; + min-width: 200px; + min-height: 200px; + } p.no-preview { font-size: 24px; color: @gray; } + .bib-loading { + font-size: 24px; + color: @gray; + margin-bottom: 12px; + } + .bib-preview { + margin-bottom: 12px; + .scroll-container { + font-size: 0.8em; + line-height: 1.1em; + overflow: auto; + border: 1px solid @gray-lighter; + padding-left: 12px; + padding-right: 12px; + padding-top: 8px; + padding-bottom: 8px; + text-align: left; + white-space: pre; + font-family: monospace; + } + } } diff --git a/services/web/public/stylesheets/app/editor/file-tree.less b/services/web/public/stylesheets/app/editor/file-tree.less index 6f70c3299f..5a4d7feed1 100644 --- a/services/web/public/stylesheets/app/editor/file-tree.less +++ b/services/web/public/stylesheets/app/editor/file-tree.less @@ -52,13 +52,13 @@ aside#file-tree { } } - i.fa-folder-open, i.fa-folder { - color: lighten(desaturate(@link-color, 10%), 5%); + i.fa { + color: @gray-light; font-size: 14px; } - i.fa-file, i.fa-image, i.fa-file-pdf-o { - color: @gray-light; + i.fa-folder-open, i.fa-folder { + color: lighten(desaturate(@link-color, 10%), 5%); font-size: 14px; } @@ -112,7 +112,7 @@ aside#file-tree { border-right: 4px solid @link-color; font-weight: bold; padding-right: 32px; - i.fa-folder-open, i.fa-folder, i.fa-file, i.fa-image, i.fa-file-pdf-o { + i.fa-folder-open, i.fa { color: @link-color; } .entity-menu-toggle { diff --git a/services/web/public/stylesheets/app/wiki.less b/services/web/public/stylesheets/app/wiki.less index cd4022dd89..210230989c 100644 --- a/services/web/public/stylesheets/app/wiki.less +++ b/services/web/public/stylesheets/app/wiki.less @@ -104,4 +104,27 @@ /*]]>*/ + a.search-result { + display: block; + margin-top: @line-height-computed / 2; + .search-result-content { + margin-top: @line-height-computed / 4; + white-space: pre-wrap; + font-size: 0.8em; + color: @gray-dark; + em { + font-weight: bold; + } + } + + &:hover, &:active, &:focus { + text-decoration: none; + .search-result-content { + color: @gray-darker; + + } + box-shadow: 0 2px 4px rgba(0,0,0,0.35); + } + } + } \ No newline at end of file diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less index be7367a08b..4e9823631c 100755 --- a/services/web/public/stylesheets/style.less +++ b/services/web/public/stylesheets/style.less @@ -71,3 +71,4 @@ @import "app/templates.less"; @import "app/wiki.less"; @import "app/translations.less"; +@import "app/contact-us.less"; diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee index 63ff12a374..f83b38617b 100644 --- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee @@ -173,7 +173,7 @@ describe "AuthenticationController", -> beforeEach -> @req.session = user: @user - @AuthenticationController.getLoggedInUser(@req, {}, @callback) + @AuthenticationController.getLoggedInUser(@req, @callback) it "should look up the user in the database", -> @UserGetter.getUser @@ -183,105 +183,37 @@ describe "AuthenticationController", -> it "should return the user", -> @callback.calledWith(null, @user).should.equal true - describe "with an auth token, but without auth_token_allowed set to true", -> - beforeEach -> - @req.query = - auth_token: "auth-token" - @AuthenticationController.getLoggedInUser(@req, {}, @callback) - - it "should not look up the user in the database", -> - @UserGetter.getUser.called.should.equal false - - it "should return null in the callback", -> - @callback.calledWith(null, null).should.equal true - - describe "with an auth token and auth_token_allowed set to true", -> - beforeEach -> - @req.query = - auth_token: "auth-token" - @AuthenticationController.getLoggedInUser(@req, {allow_auth_token: true}, @callback) - - it "should look up the user in the database", -> - @UserGetter.getUser - .calledWith(auth_token: @req.query.auth_token) - .should.equal true - - it "should return the user", -> - @callback.calledWith(null, @user).should.equal true - describe "requireLogin", -> beforeEach -> @user = _id: "user-id-123" email: "user@sharelatex.com" + @middleware = @AuthenticationController.requireLogin() - describe "when loading from the database", -> + describe "when the user is logged in", -> beforeEach -> - @middleware = @AuthenticationController.requireLogin(@options = { allow_auth_token: true, load_from_db: true }) - - describe "when the user is logged in", -> - beforeEach -> - @AuthenticationController.getLoggedInUser = sinon.stub().callsArgWith(2, null, @user) - @middleware(@req, @res, @next) - - it "should call getLoggedInUser with the passed options", -> - @AuthenticationController.getLoggedInUser.calledWith(@req, { allow_auth_token: true }).should.equal true - - it "should set the user property on the request", -> - @req.user.should.deep.equal @user - - it "should call the next method in the chain", -> - @next.called.should.equal true - - describe "when the user is not logged in", -> - beforeEach -> - @AuthenticationController._redirectToLoginOrRegisterPage = sinon.stub() - @AuthenticationController.getLoggedInUser = sinon.stub().callsArgWith(2, null, null) - @middleware(@req, @res, @next) - - it "should redirect to the register page", -> - @AuthenticationController._redirectToLoginOrRegisterPage.calledWith(@req, @res).should.equal true - - describe "when not loading from the database", -> - beforeEach -> - @middleware = @AuthenticationController.requireLogin(@options = { load_from_db: false }) - - describe "when the user is logged in", -> - beforeEach -> - @req.session = - user: @user = { - _id: "user-id-123" - email: "user@sharelatex.com" - } - @middleware(@req, @res, @next) - - it "should set the user property on the request", -> - @req.user.should.deep.equal @user - - it "should call the next method in the chain", -> - @next.called.should.equal true - - describe "when the user is not logged in", -> - beforeEach -> - @req.session = {} - @AuthenticationController._redirectToLoginOrRegisterPage = sinon.stub() - @req.query = {} - @middleware(@req, @res, @next) - - it "should redirect to the register or login page", -> - @AuthenticationController._redirectToLoginOrRegisterPage.calledWith(@req, @res).should.equal true - - describe "when not loading from the database but an auth_token is provided", -> - beforeEach -> - @AuthenticationController.getLoggedInUser = sinon.stub().callsArgWith(2, null, @user) - @middleware = @AuthenticationController.requireLogin(@options = { load_from_db: false, allow_auth_token: true }) - @req.query = auth_token: @auth_token = "auth-token-provided" + @req.session = + user: @user = { + _id: "user-id-123" + email: "user@sharelatex.com" + } @middleware(@req, @res, @next) - it "should try to load the user from the database anyway", -> - @AuthenticationController.getLoggedInUser - .calledWith(@req, {allow_auth_token: true}) - .should.equal true + it "should set the user property on the request", -> + @req.user.should.deep.equal @user + + it "should call the next method in the chain", -> + @next.called.should.equal true + + describe "when the user is not logged in", -> + beforeEach -> + @req.session = {} + @AuthenticationController._redirectToLoginOrRegisterPage = sinon.stub() + @req.query = {} + @middleware(@req, @res, @next) + + it "should redirect to the register or login page", -> + @AuthenticationController._redirectToLoginOrRegisterPage.calledWith(@req, @res).should.equal true describe "requireGlobalLogin", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee b/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee index 3a5c60cf66..f4d5e72c26 100644 --- a/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Authentication/AuthenticationManagerTests.coffee @@ -95,49 +95,3 @@ describe "AuthenticationManager", -> it "should call the callback", -> @callback.called.should.equal true - - describe "getAuthToken", -> - beforeEach -> - @auth_token = "auth-token" - - describe "when the user has an auth token set", -> - beforeEach -> - @db.users.findOne = sinon.stub().callsArgWith(2, null, auth_token: @auth_token) - @AuthenticationManager.getAuthToken(@user_id, @callback) - - it "should look up the auth token in the db", -> - @db.users.findOne - .calledWith({ - _id: ObjectId(@user_id.toString()) - }, { - auth_token: true - }) - .should.equal true - - it "should return the auth token", -> - @callback.calledWith(null, @auth_token).should.equal true - - describe "when the user does not have an auth token set", -> - beforeEach -> - @db.users.findOne = sinon.stub().callsArgWith(2, null, auth_token: null) - @db.users.update = sinon.stub().callsArgWith(2, null) - @AuthenticationManager._createSecureToken = sinon.stub().callsArgWith(0, null, @auth_token) - @AuthenticationManager.getAuthToken(@user_id, @callback) - - it "should generate a new auth token", -> - @AuthenticationManager._createSecureToken.called.should.equal true - - it "should set the auth token on the user document in the db", -> - @db.users.update - .calledWith({ - _id: ObjectId(@user_id.toString()) - }, { - $set: auth_token: @auth_token - }) - .should.equal true - - it "should return the auth token", -> - @callback.calledWith(null, @auth_token).should.equal true - - - diff --git a/services/web/test/UnitTests/coffee/Authorization/AuthorizationManagerTests.coffee b/services/web/test/UnitTests/coffee/Authorization/AuthorizationManagerTests.coffee new file mode 100644 index 0000000000..fcacce5164 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Authorization/AuthorizationManagerTests.coffee @@ -0,0 +1,385 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/Features/Authorization/AuthorizationManager.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Features/Errors/Errors.js" + +describe "AuthorizationManager", -> + beforeEach -> + @AuthorizationManager = SandboxedModule.require modulePath, requires: + "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} + "../../models/Project": Project: @Project = {} + "../../models/User": User: @User = {} + "../Errors/Errors": Errors + @user_id = "user-id-1" + @project_id = "project-id-1" + @callback = sinon.stub() + + describe "getPrivilegeLevelForProject", -> + beforeEach -> + @Project.findOne = sinon.stub() + @AuthorizationManager.isUserSiteAdmin = sinon.stub() + @CollaboratorsHandler.getMemberIdPrivilegeLevel = sinon.stub() + + describe "with a private project", -> + beforeEach -> + @Project.findOne + .withArgs({ _id: @project_id }, { publicAccesLevel: 1 }) + .yields(null, { publicAccesLevel: "private" }) + + describe "with a user_id with a privilege level", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, false) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, "readOnly") + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return the user's privilege level", -> + @callback.calledWith(null, "readOnly", false).should.equal true + + describe "with a user_id with no privilege level", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, false) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, false) + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return false", -> + @callback.calledWith(null, false, false).should.equal true + + describe "with a user_id who is an admin", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, true) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, false) + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return the user as an owner", -> + @callback.calledWith(null, "owner", false).should.equal true + + describe "with no user (anonymous)", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject null, @project_id, @callback + + it "should not call CollaboratorsHandler.getMemberIdPrivilegeLevel", -> + @CollaboratorsHandler.getMemberIdPrivilegeLevel.called.should.equal false + + it "should not call AuthorizationManager.isUserSiteAdmin", -> + @AuthorizationManager.isUserSiteAdmin.called.should.equal false + + it "should return false", -> + @callback.calledWith(null, false, false).should.equal true + + describe "with a public project", -> + beforeEach -> + @Project.findOne + .withArgs({ _id: @project_id }, { publicAccesLevel: 1 }) + .yields(null, { publicAccesLevel: "readAndWrite" }) + + describe "with a user_id with a privilege level", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, false) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, "readOnly") + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return the user's privilege level", -> + @callback.calledWith(null, "readOnly", false).should.equal true + + describe "with a user_id with no privilege level", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, false) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, false) + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return the public privilege level", -> + @callback.calledWith(null, "readAndWrite", true).should.equal true + + describe "with a user_id who is an admin", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, true) + @CollaboratorsHandler.getMemberIdPrivilegeLevel + .withArgs(@user_id, @project_id) + .yields(null, false) + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, @callback + + it "should return the user as an owner", -> + @callback.calledWith(null, "owner", false).should.equal true + + describe "with no user (anonymous)", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject null, @project_id, @callback + + it "should not call CollaboratorsHandler.getMemberIdPrivilegeLevel", -> + @CollaboratorsHandler.getMemberIdPrivilegeLevel.called.should.equal false + + it "should not call AuthorizationManager.isUserSiteAdmin", -> + @AuthorizationManager.isUserSiteAdmin.called.should.equal false + + it "should return the public privilege level", -> + @callback.calledWith(null, "readAndWrite", true).should.equal true + + describe "when the project doesn't exist", -> + beforeEach -> + @Project.findOne + .withArgs({ _id: @project_id }, { publicAccesLevel: 1 }) + .yields(null, null) + + it "should return a NotFoundError", -> + @AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, (error) -> + error.should.be.instanceof Errors.NotFoundError + + describe "canUserReadProject", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject = sinon.stub() + + describe "when user is owner", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "owner", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserReadProject @user_id, @project_id, (error, canRead) -> + expect(canRead).to.equal true + done() + + describe "when user has read-write access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readAndWrite", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserReadProject @user_id, @project_id, (error, canRead) -> + expect(canRead).to.equal true + done() + + describe "when user has read-only access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readOnly", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserReadProject @user_id, @project_id, (error, canRead) -> + expect(canRead).to.equal true + done() + + describe "when user has no access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, false, false) + + it "should return false", (done) -> + @AuthorizationManager.canUserReadProject @user_id, @project_id, (error, canRead) -> + expect(canRead).to.equal false + done() + + describe "canUserWriteProjectContent", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject = sinon.stub() + + describe "when user is owner", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "owner", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserWriteProjectContent @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal true + done() + + describe "when user has read-write access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readAndWrite", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserWriteProjectContent @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal true + done() + + describe "when user has read-only access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readOnly", false) + + it "should return false", (done) -> + @AuthorizationManager.canUserWriteProjectContent @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal false + done() + + describe "when user has no access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, false, false) + + it "should return false", (done) -> + @AuthorizationManager.canUserWriteProjectContent @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal false + done() + + describe "canUserWriteProjectSettings", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject = sinon.stub() + + describe "when user is owner", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "owner", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserWriteProjectSettings @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal true + done() + + describe "when user has read-write access as a collaborator", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readAndWrite", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserWriteProjectSettings @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal true + done() + + describe "when user has read-write access as the public", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readAndWrite", true) + + it "should return false", (done) -> + @AuthorizationManager.canUserWriteProjectSettings @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal false + done() + + describe "when user has read-only access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readOnly", false) + + it "should return false", (done) -> + @AuthorizationManager.canUserWriteProjectSettings @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal false + done() + + describe "when user has no access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, false, false) + + it "should return false", (done) -> + @AuthorizationManager.canUserWriteProjectSettings @user_id, @project_id, (error, canWrite) -> + expect(canWrite).to.equal false + done() + + describe "canUserAdminProject", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject = sinon.stub() + + describe "when user is owner", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "owner", false) + + it "should return true", (done) -> + @AuthorizationManager.canUserAdminProject @user_id, @project_id, (error, canAdmin) -> + expect(canAdmin).to.equal true + done() + + describe "when user has read-write access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readAndWrite", false) + + it "should return false", (done) -> + @AuthorizationManager.canUserAdminProject @user_id, @project_id, (error, canAdmin) -> + expect(canAdmin).to.equal false + done() + + describe "when user has read-only access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, "readOnly", false) + + it "should return false", (done) -> + @AuthorizationManager.canUserAdminProject @user_id, @project_id, (error, canAdmin) -> + expect(canAdmin).to.equal false + done() + + describe "when user has no access", -> + beforeEach -> + @AuthorizationManager.getPrivilegeLevelForProject + .withArgs(@user_id, @project_id) + .yields(null, false, false) + + it "should return false", (done) -> + @AuthorizationManager.canUserAdminProject @user_id, @project_id, (error, canAdmin) -> + expect(canAdmin).to.equal false + done() + + describe "isUserSiteAdmin", -> + beforeEach -> + @User.findOne = sinon.stub() + + describe "when user is admin", -> + beforeEach -> + @User.findOne + .withArgs({ _id: @user_id }, { isAdmin: 1 }) + .yields(null, { isAdmin: true }) + + it "should return true", (done) -> + @AuthorizationManager.isUserSiteAdmin @user_id, (error, isAdmin) -> + expect(isAdmin).to.equal true + done() + + describe "when user is not admin", -> + beforeEach -> + @User.findOne + .withArgs({ _id: @user_id }, { isAdmin: 1 }) + .yields(null, { isAdmin: false }) + + it "should return false", (done) -> + @AuthorizationManager.isUserSiteAdmin @user_id, (error, isAdmin) -> + expect(isAdmin).to.equal false + done() + + describe "when user is not found", -> + beforeEach -> + @User.findOne + .withArgs({ _id: @user_id }, { isAdmin: 1 }) + .yields(null, null) + + it "should return false", (done) -> + @AuthorizationManager.isUserSiteAdmin @user_id, (error, isAdmin) -> + expect(isAdmin).to.equal false + done() + + describe "when no user is passed", -> + it "should return false", (done) -> + @AuthorizationManager.isUserSiteAdmin null, (error, isAdmin) => + @User.findOne.called.should.equal false + expect(isAdmin).to.equal false + done() diff --git a/services/web/test/UnitTests/coffee/Authorization/AuthorizationMiddlewearTests.coffee b/services/web/test/UnitTests/coffee/Authorization/AuthorizationMiddlewearTests.coffee new file mode 100644 index 0000000000..bc62e603de --- /dev/null +++ b/services/web/test/UnitTests/coffee/Authorization/AuthorizationMiddlewearTests.coffee @@ -0,0 +1,237 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/Features/Authorization/AuthorizationMiddlewear.js" +SandboxedModule = require('sandboxed-module') +Errors = require "../../../../app/js/Features/Errors/Errors.js" + +describe "AuthorizationMiddlewear", -> + beforeEach -> + @AuthorizationMiddlewear = SandboxedModule.require modulePath, requires: + "./AuthorizationManager": @AuthorizationManager = {} + "logger-sharelatex": {log: () ->} + "mongojs": ObjectId: @ObjectId = {} + "../Errors/Errors": Errors + @user_id = "user-id-123" + @project_id = "project-id-123" + @req = {} + @res = {} + @ObjectId.isValid = sinon.stub() + @ObjectId.isValid.withArgs(@project_id).returns true + @next = sinon.stub() + + METHODS_TO_TEST = { + "ensureUserCanReadProject": "canUserReadProject" + "ensureUserCanWriteProjectSettings": "canUserWriteProjectSettings" + "ensureUserCanWriteProjectContent": "canUserWriteProjectContent" + "ensureUserCanAdminProject": "canUserAdminProject" + } + for middlewearMethod, managerMethod of METHODS_TO_TEST + do (middlewearMethod, managerMethod) -> + describe middlewearMethod, -> + beforeEach -> + @req.params = + project_id: @project_id + @AuthorizationManager[managerMethod] = sinon.stub() + @AuthorizationMiddlewear.redirectToRestricted = sinon.stub() + + describe "with missing project_id", -> + beforeEach -> + @req.params = {} + + it "should return an error to next", -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, @next + @next.calledWith(new Error()).should.equal true + + describe "with logged in user", -> + beforeEach -> + @req.session = + user: _id: @user_id + + describe "when user has permission", -> + beforeEach -> + @AuthorizationManager[managerMethod] + .withArgs(@user_id, @project_id) + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission", -> + beforeEach -> + @AuthorizationManager[managerMethod] + .withArgs(@user_id, @project_id) + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true + + describe "with anonymous user", -> + describe "when user has permission", -> + beforeEach -> + @AuthorizationManager[managerMethod] + .withArgs(null, @project_id) + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission", -> + beforeEach -> + @AuthorizationManager[managerMethod] + .withArgs(null, @project_id) + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true + + describe "with malformed project id", -> + beforeEach -> + @req.params = + project_id: "blah" + @ObjectId.isValid = sinon.stub().returns false + + it "should return a not found error", (done) -> + @AuthorizationMiddlewear[middlewearMethod] @req, @res, (error) -> + error.should.be.instanceof Errors.NotFoundError + done() + + describe "ensureUserIsSiteAdmin", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin = sinon.stub() + @AuthorizationMiddlewear.redirectToRestricted = sinon.stub() + + describe "with logged in user", -> + beforeEach -> + @req.session = + user: _id: @user_id + + describe "when user has permission", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin + .withArgs(@user_id) + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear.ensureUserIsSiteAdmin @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin + .withArgs(@user_id) + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear.ensureUserIsSiteAdmin @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true + + describe "with anonymous user", -> + describe "when user has permission", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin + .withArgs(null) + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear.ensureUserIsSiteAdmin @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission", -> + beforeEach -> + @AuthorizationManager.isUserSiteAdmin + .withArgs(null) + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear.ensureUserIsSiteAdmin @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true + + describe "ensureUserCanReadMultipleProjects", -> + beforeEach -> + @AuthorizationManager.canUserReadProject = sinon.stub() + @AuthorizationMiddlewear.redirectToRestricted = sinon.stub() + @req.query = + project_ids: "project1,project2" + + describe "with logged in user", -> + beforeEach -> + @req.session = + user: _id: @user_id + + describe "when user has permission to access all projects", -> + beforeEach -> + @AuthorizationManager.canUserReadProject + .withArgs(@user_id, "project1") + .yields(null, true) + @AuthorizationManager.canUserReadProject + .withArgs(@user_id, "project2") + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear.ensureUserCanReadMultipleProjects @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission to access one of the projects", -> + beforeEach -> + @AuthorizationManager.canUserReadProject + .withArgs(@user_id, "project1") + .yields(null, true) + @AuthorizationManager.canUserReadProject + .withArgs(@user_id, "project2") + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear.ensureUserCanReadMultipleProjects @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true + + describe "with anonymous user", -> + describe "when user has permission", -> + describe "when user has permission to access all projects", -> + beforeEach -> + @AuthorizationManager.canUserReadProject + .withArgs(null, "project1") + .yields(null, true) + @AuthorizationManager.canUserReadProject + .withArgs(null, "project2") + .yields(null, true) + + it "should return next", -> + @AuthorizationMiddlewear.ensureUserCanReadMultipleProjects @req, @res, @next + @next.called.should.equal true + + describe "when user doesn't have permission to access one of the projects", -> + beforeEach -> + @AuthorizationManager.canUserReadProject + .withArgs(null, "project1") + .yields(null, true) + @AuthorizationManager.canUserReadProject + .withArgs(null, "project2") + .yields(null, false) + + it "should redirect to redirectToRestricted", -> + @AuthorizationMiddlewear.ensureUserCanReadMultipleProjects @req, @res, @next + @next.called.should.equal false + @AuthorizationMiddlewear.redirectToRestricted + .calledWith(@req, @res, @next) + .should.equal true diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee index 6930707249..32de9ebe0a 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee @@ -24,35 +24,6 @@ describe "CollaboratorsController", -> @project_id = "project-id-123" @callback = sinon.stub() - describe "getCollaborators", -> - beforeEach -> - @project = - _id: @project_id = "project-id-123" - @collaborators = ["array of collaborators"] - @req.params = Project_id: @project_id - @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @project) - @ProjectGetter.populateProjectWithUsers = sinon.stub().callsArgWith(1, null, @project) - @CollaboratorsController._formatCollaborators = sinon.stub().callsArgWith(1, null, @collaborators) - @CollaboratorsController.getCollaborators(@req, @res) - - it "should get the project", -> - @ProjectGetter.getProject - .calledWith(@project_id, { owner_ref: true, collaberator_refs: true, readOnly_refs: true }) - .should.equal true - - it "should populate the users in the project", -> - @ProjectGetter.populateProjectWithUsers - .calledWith(@project) - .should.equal true - - it "should format the collaborators", -> - @CollaboratorsController._formatCollaborators - .calledWith(@project) - .should.equal true - - it "should return the formatted collaborators", -> - @res.body.should.equal JSON.stringify(@collaborators) - describe "addUserToProject", -> beforeEach -> @req.params = @@ -179,85 +150,3 @@ describe "CollaboratorsController", -> it "should return a success code", -> @res.sendStatus.calledWith(204).should.equal true - describe "_formatCollaborators", -> - beforeEach -> - @owner = - _id: ObjectId() - first_name: "Lenny" - last_name: "Lion" - email: "test@sharelatex.com" - hashed_password: "password" # should not be included - - describe "formatting the owner", -> - beforeEach -> - @project = - owner_ref: @owner - collaberator_refs: [] - @CollaboratorsController._formatCollaborators(@project, @callback) - - it "should return the owner with read, write and admin permissions", -> - @formattedOwner = @callback.args[0][1][0] - expect(@formattedOwner).to.deep.equal { - id: @owner._id.toString() - first_name: @owner.first_name - last_name: @owner.last_name - email: @owner.email - permissions: ["read", "write", "admin"] - owner: true - } - - describe "formatting a collaborator with write access", -> - beforeEach -> - @collaborator = - _id: ObjectId() - first_name: "Douglas" - last_name: "Adams" - email: "doug@sharelatex.com" - hashed_password: "password" # should not be included - - @project = - owner_ref: @owner - collaberator_refs: [ @collaborator ] - @CollaboratorsController._formatCollaborators(@project, @callback) - - it "should return the collaborator with read and write permissions", -> - @formattedCollaborator = @callback.args[0][1][1] - expect(@formattedCollaborator).to.deep.equal { - id: @collaborator._id.toString() - first_name: @collaborator.first_name - last_name: @collaborator.last_name - email: @collaborator.email - permissions: ["read", "write"] - owner: false - } - - describe "formatting a collaborator with read only access", -> - beforeEach -> - @collaborator = - _id: ObjectId() - first_name: "Douglas" - last_name: "Adams" - email: "doug@sharelatex.com" - hashed_password: "password" # should not be included - - @project = - owner_ref: @owner - collaberator_refs: [] - readOnly_refs: [ @collaborator ] - @CollaboratorsController._formatCollaborators(@project, @callback) - - it "should return the collaborator with read permissions", -> - @formattedCollaborator = @callback.args[0][1][1] - expect(@formattedCollaborator).to.deep.equal { - id: @collaborator._id.toString() - first_name: @collaborator.first_name - last_name: @collaborator.last_name - email: @collaborator.email - permissions: ["read"] - owner: false - } - - - - - diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee index 1fd3a0c2d5..632b43e7f2 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee @@ -5,6 +5,7 @@ path = require('path') sinon = require('sinon') modulePath = path.join __dirname, "../../../../app/js/Features/Collaborators/CollaboratorsHandler" expect = require("chai").expect +Errors = require "../../../../app/js/Features/Errors/Errors.js" describe "CollaboratorsHandler", -> beforeEach -> @@ -16,12 +17,145 @@ describe "CollaboratorsHandler", -> "../../models/Project": Project: @Project = {} "../Project/ProjectEntityHandler": @ProjectEntityHandler = {} "./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {} + "../Errors/Errors": Errors @project_id = "mock-project-id" @user_id = "mock-user-id" @adding_user_id = "adding-user-id" @email = "joe@sharelatex.com" @callback = sinon.stub() + + describe "getMemberIdsWithPrivilegeLevels", -> + describe "with project", -> + beforeEach -> + @Project.findOne = sinon.stub() + @Project.findOne.withArgs({_id: @project_id}, {owner_ref: 1, collaberator_refs: 1, readOnly_refs: 1}).yields(null, @project = { + owner_ref: [ "owner-ref" ] + readOnly_refs: [ "read-only-ref-1", "read-only-ref-2" ] + collaberator_refs: [ "read-write-ref-1", "read-write-ref-2" ] + }) + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels @project_id, @callback + + it "should return an array of member ids with their privilege levels", -> + @callback + .calledWith(null, [ + { id: "owner-ref", privilegeLevel: "owner" } + { id: "read-only-ref-1", privilegeLevel: "readOnly" } + { id: "read-only-ref-2", privilegeLevel: "readOnly" } + { id: "read-write-ref-1", privilegeLevel: "readAndWrite" } + { id: "read-write-ref-2", privilegeLevel: "readAndWrite" } + ]) + .should.equal true + + describe "with a missing project", -> + beforeEach -> + @Project.findOne = sinon.stub().yields(null, null) + + it "should return a NotFoundError", (done) -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels @project_id, (error) -> + error.should.be.instanceof Errors.NotFoundError + done() + + describe "getMemberIds", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels + .withArgs(@project_id) + .yields(null, [{id: "member-id-1"}, {id: "member-id-2"}]) + @CollaboratorHandler.getMemberIds @project_id, @callback + + it "should return the ids", -> + @callback + .calledWith(null, ["member-id-1", "member-id-2"]) + .should.equal true + + describe "getMembersWithPrivilegeLevels", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels.withArgs(@project_id).yields(null, [ + { id: "read-only-ref-1", privilegeLevel: "readOnly" } + { id: "read-only-ref-2", privilegeLevel: "readOnly" } + { id: "read-write-ref-1", privilegeLevel: "readAndWrite" } + { id: "read-write-ref-2", 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" }) + @CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback + + it "should return an array of members with their privilege levels", -> + @callback + .calledWith(undefined, [ + { user: { _id: "read-only-ref-1" }, privilegeLevel: "readOnly" } + { user: { _id: "read-only-ref-2" }, privilegeLevel: "readOnly" } + { user: { _id: "read-write-ref-1" }, privilegeLevel: "readAndWrite" } + { user: { _id: "read-write-ref-2" }, privilegeLevel: "readAndWrite" } + ]) + .should.equal true + + describe "getMemberIdPrivilegeLevel", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels + .withArgs(@project_id) + .yields(null, [ + {id: "member-id-1", privilegeLevel: "readAndWrite"} + {id: "member-id-2", privilegeLevel: "readOnly"} + ]) + + it "should return the privilege level if it exists", (done) -> + @CollaboratorHandler.getMemberIdPrivilegeLevel "member-id-2", @project_id, (error, level) -> + expect(level).to.equal "readOnly" + done() + + it "should return false if the member has no privilege level", (done) -> + @CollaboratorHandler.getMemberIdPrivilegeLevel "member-id-3", @project_id, (error, level) -> + expect(level).to.equal false + done() + + describe "isUserMemberOfProject", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() + + describe "when user is a member of the project", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels.withArgs(@project_id).yields(null, [ + { id: "not-the-user", privilegeLevel: "readOnly" } + { id: @user_id, privilegeLevel: "readAndWrite" } + ]) + @CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback + + it "should return true and the privilegeLevel", -> + @callback + .calledWith(null, true, "readAndWrite") + .should.equal true + + describe "when user is not a member of the project", -> + beforeEach -> + @CollaboratorHandler.getMemberIdsWithPrivilegeLevels.withArgs(@project_id).yields(null, [ + { id: "not-the-user", privilegeLevel: "readOnly" } + ]) + @CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback + + it "should return false", -> + @callback + .calledWith(null, false, null) + .should.equal true + + describe "getProjectsUserIsCollaboratorOf", -> + beforeEach -> + @fields = "mock fields" + @Project.find = sinon.stub() + @Project.find.withArgs({collaberator_refs:@user_id}, @fields).yields(null, ["mock-read-write-project-1", "mock-read-write-project-2"]) + @Project.find.withArgs({readOnly_refs:@user_id}, @fields).yields(null, ["mock-read-only-project-1", "mock-read-only-project-2"]) + @CollaboratorHandler.getProjectsUserIsCollaboratorOf @user_id, @fields, @callback + + it "should call the callback with the projects", -> + @callback + .calledWith(null, ["mock-read-write-project-1", "mock-read-write-project-2"], ["mock-read-only-project-1", "mock-read-only-project-2"]) + .should.equal true describe "removeUserFromProject", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee new file mode 100644 index 0000000000..01d4ad0002 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Compile/ClsiCookieManagerTests.coffee @@ -0,0 +1,134 @@ +sinon = require('sinon') +chai = require('chai') +assert = chai.assert +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/Features/Compile/ClsiCookieManager.js" +SandboxedModule = require('sandboxed-module') +realRequst = require("request") + +describe "ClsiCookieManager", -> + beforeEach -> + self = @ + @redisMulti = + set:sinon.stub() + get:sinon.stub() + expire:sinon.stub() + exec:sinon.stub() + @redis = + auth:-> + get:sinon.stub() + multi: -> return self.redisMulti + @project_id = "123423431321" + @request = + get: sinon.stub() + cookie:realRequst.cookie + jar: realRequst.jar + @settings = + redis: + web:"redis.something" + apis: + clsi: + url: "http://clsi.example.com" + clsiCookie: + ttl:Math.random() + key: "coooookie" + @requires = + "redis-sharelatex" : + createClient: => + @redis + "settings-sharelatex": @settings + "request": @request + + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } + @ClsiCookieManager = SandboxedModule.require modulePath, requires:@requires + + + + describe "getServerId", -> + + it "should call get for the key", (done)-> + @redis.get.callsArgWith(1, null, "clsi-7") + @ClsiCookieManager._getServerId @project_id, (err, serverId)=> + @redis.get.calledWith("clsiserver:#{@project_id}").should.equal true + serverId.should.equal "clsi-7" + done() + + it "should _populateServerIdViaRequest if no key is found", (done)-> + @ClsiCookieManager._populateServerIdViaRequest = sinon.stub().callsArgWith(1) + @redis.get.callsArgWith(1, null) + @ClsiCookieManager._getServerId @project_id, (err, serverId)=> + @ClsiCookieManager._populateServerIdViaRequest.calledWith(@project_id).should.equal true + done() + + + describe "_populateServerIdViaRequest", -> + + beforeEach -> + @response = "some data" + @request.get.callsArgWith(1, null, @response) + @ClsiCookieManager.setServerId = sinon.stub().callsArgWith(2, null, "clsi-9") + + it "should make a request to the clsi", (done)-> + @ClsiCookieManager._populateServerIdViaRequest @project_id, (err, serverId)=> + args = @ClsiCookieManager.setServerId.args[0] + args[0].should.equal @project_id + args[1].should.deep.equal @response + done() + + it "should return the server id", (done)-> + @ClsiCookieManager._populateServerIdViaRequest @project_id, (err, serverId)=> + serverId.should.equal "clsi-9" + done() + + describe "setServerId", -> + + beforeEach -> + @response = "dsadsakj" + @ClsiCookieManager._parseServerIdFromResponse = sinon.stub().returns("clsi-8") + @redisMulti.exec.callsArgWith(0) + + it "should set the server id with a ttl", (done)-> + @ClsiCookieManager.setServerId @project_id, @response, (err)=> + @redisMulti.set.calledWith("clsiserver:#{@project_id}", "clsi-8").should.equal true + @redisMulti.expire.calledWith("clsiserver:#{@project_id}", @settings.clsiCookie.ttl).should.equal true + done() + + it "should return the server id", (done)-> + @ClsiCookieManager.setServerId @project_id, @response, (err, serverId)=> + serverId.should.equal "clsi-8" + done() + + + it "should not set the server id if clsiCookies are not enabled", (done)-> + delete @settings.clsiCookie.key + @ClsiCookieManager = SandboxedModule.require modulePath, requires:@requires + @ClsiCookieManager.setServerId @project_id, @response, (err, serverId)=> + @redisMulti.exec.called.should.equal false + done() + + describe "getCookieJar", -> + + beforeEach -> + @ClsiCookieManager._getServerId = sinon.stub().callsArgWith(1, null, "clsi-11") + + it "should return a jar with the cookie set populated from redis", (done)-> + @ClsiCookieManager.getCookieJar @project_id, (err, jar)=> + jar._jar.store.idx["clsi.example.com"]["/"][@settings.clsiCookie.key].key.should.equal + jar._jar.store.idx["clsi.example.com"]["/"][@settings.clsiCookie.key].value.should.equal "clsi-11" + done() + + + it "should return empty cookie jar if clsiCookies are not enabled", (done)-> + delete @settings.clsiCookie.key + @ClsiCookieManager = SandboxedModule.require modulePath, requires:@requires + @ClsiCookieManager.getCookieJar @project_id, (err, jar)-> + assert.deepEqual jar, realRequst.jar() + done() + + + + + + + diff --git a/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee index baf2643c05..9889bcb87b 100644 --- a/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Compile/ClsiManagerTests.coffee @@ -7,6 +7,11 @@ SandboxedModule = require('sandboxed-module') describe "ClsiManager", -> beforeEach -> + @jar = {cookie:"stuff"} + @ClsiCookieManager = + getCookieJar: sinon.stub().callsArgWith(1, null, @jar) + setServerId: sinon.stub().callsArgWith(2) + _getServerId:sinon.stub() @ClsiManager = SandboxedModule.require modulePath, requires: "settings-sharelatex": @settings = apis: @@ -19,14 +24,16 @@ describe "ClsiManager", -> url: "https://clsipremium.example.com" "../../models/Project": Project: @Project = {} "../Project/ProjectEntityHandler": @ProjectEntityHandler = {} - "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } - "request": @request = {} + "./ClsiCookieManager": @ClsiCookieManager + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } + "request": @request = sinon.stub() @project_id = "project-id" @callback = sinon.stub() describe "sendRequest", -> beforeEach -> @ClsiManager._buildRequest = sinon.stub().callsArgWith(2, null, @request = "mock-request") + @ClsiCookieManager._getServerId.callsArgWith(1, null, "clsi3") describe "with a successful compile", -> beforeEach -> @@ -80,28 +87,20 @@ describe "ClsiManager", -> describe "deleteAuxFiles", -> beforeEach -> - @request.del = sinon.stub().callsArg(1) + @ClsiManager._makeRequest = sinon.stub().callsArg(2) describe "with the standard compileGroup", -> beforeEach -> @ClsiManager.deleteAuxFiles @project_id, {compileGroup: "standard"}, @callback it "should call the delete method in the standard CLSI", -> - @request.del - .calledWith("#{@settings.apis.clsi.url}/project/#{@project_id}") + @ClsiManager._makeRequest + .calledWith(@project_id, { method:"DELETE", url:"#{@settings.apis.clsi.url}/project/#{@project_id}"}) .should.equal true it "should call the callback", -> @callback.called.should.equal true - describe "with the priority compileGroup", -> - beforeEach -> - @ClsiManager.deleteAuxFiles @project_id, {compileGroup: "priority"}, @callback - - it "should call the delete method in the CLSI", -> - @request.del - .calledWith("#{@settings.apis.clsi_priority.url}/project/#{@project_id}") - .should.equal true describe "_buildRequest", -> beforeEach -> @@ -218,9 +217,9 @@ describe "ClsiManager", -> @project.rootDoc_id = "not-valid" @ClsiManager._buildRequest @project, null, (@error, @request) => done() - - it "should return an error", -> - expect(@error).to.exist + + it "should set to main.tex", -> + @request.compile.rootResourcePath.should.equal "main.tex" describe "with the draft option", -> it "should add the draft option into the request", (done) -> @@ -235,15 +234,15 @@ describe "ClsiManager", -> describe "successfully", -> beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204}, @body = { mock: "foo" }) + @ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 204}, @body = { mock: "foo" }) @ClsiManager._postToClsi @project_id, @req, "standard", @callback it 'should send the request to the CLSI', -> url = "#{@settings.apis.clsi.url}/project/#{@project_id}/compile" - @request.post.calledWith({ + @ClsiManager._makeRequest.calledWith(@project_id, { + method: "POST", url: url json: @req - jar: false }).should.equal true it "should call the callback with the body and no error", -> @@ -251,28 +250,16 @@ describe "ClsiManager", -> describe "when the CLSI returns an error", -> beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 500}, @body = { mock: "foo" }) + @ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 500}, @body = { mock: "foo" }) @ClsiManager._postToClsi @project_id, @req, "standard", @callback it "should call the callback with the body and the error", -> @callback.calledWith(new Error("CLSI returned non-success code: 500"), @body).should.equal true - describe "when the compiler is priority", -> - beforeEach -> - @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 500}, @body = { mock: "foo" }) - @ClsiManager._postToClsi @project_id, @req, "priority", @callback - - it "should use the clsi_priority url", -> - url = "#{@settings.apis.clsi_priority.url}/project/#{@project_id}/compile" - @request.post.calledWith({ - url: url - json: @req - jar: false - }).should.equal true describe "wordCount", -> beforeEach -> - @request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body = { mock: "foo" }) + @ClsiManager._makeRequest = sinon.stub().callsArgWith(2, null, {statusCode: 200}, @body = { mock: "foo" }) @ClsiManager._buildRequest = sinon.stub().callsArgWith(2, null, @req = { compile: { rootResourcePath: "rootfile.text", options: {} } }) @ClsiManager._getCompilerUrl = sinon.stub().returns "compiler.url" @@ -281,8 +268,8 @@ describe "ClsiManager", -> @ClsiManager.wordCount @project_id, false, {}, @callback it "should call wordCount with root file", -> - @request.get - .calledWith({ url: "compiler.url/project/#{@project_id}/wordcount?file=rootfile.text" }) + @ClsiManager._makeRequest + .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=rootfile.text" }) .should.equal true it "should call the callback", -> @@ -293,8 +280,8 @@ describe "ClsiManager", -> @ClsiManager.wordCount @project_id, "main.tex", {}, @callback it "should call wordCount with param file", -> - @request.get - .calledWith({ url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex" }) + @ClsiManager._makeRequest + .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex" }) .should.equal true describe "with image", -> @@ -303,6 +290,50 @@ describe "ClsiManager", -> @ClsiManager.wordCount @project_id, "main.tex", {}, @callback it "should call wordCount with file and image", -> - @request.get - .calledWith({ url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex&image=#{encodeURIComponent(@image)}" }) + @ClsiManager._makeRequest + .calledWith(@project_id, { method: "GET", url: "compiler.url/project/#{@project_id}/wordcount?file=main.tex&image=#{encodeURIComponent(@image)}" }) .should.equal true + + + + describe "_makeRequest", -> + + beforeEach -> + @response = {there:"something"} + @request.callsArgWith(1, null, @response) + @opts = + method: "SOMETHIGN" + url: "http://a place on the web" + + it "should process a request with a cookie jar", (done)-> + @ClsiManager._makeRequest @project_id, @opts, => + args = @request.args[0] + args[0].method.should.equal @opts.method + args[0].url.should.equal @opts.url + args[0].jar.should.equal @jar + done() + + it "should set the cookie again on response as it might have changed", (done)-> + @ClsiManager._makeRequest @project_id, @opts, => + @ClsiCookieManager.setServerId.calledWith(@project_id, @response).should.equal true + done() + + + + + + + + + + + + + + + + + + + + diff --git a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee index 26cf0a2e2d..6bae5803c4 100644 --- a/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Compile/CompileControllerTests.coffee @@ -22,6 +22,9 @@ describe "CompileController", -> url: "clsi.example.com" clsi_priority: url: "clsi-priority.example.com" + @jar = {cookie:"stuff"} + @ClsiCookieManager = + getCookieJar:sinon.stub().callsArgWith(1, null, @jar) @CompileController = SandboxedModule.require modulePath, requires: "settings-sharelatex": @settings "request": @request = sinon.stub() @@ -33,6 +36,7 @@ describe "CompileController", -> "./ClsiManager": @ClsiManager "../Authentication/AuthenticationController": @AuthenticationController = {} "../../infrastructure/RateLimiter":@RateLimiter + "./ClsiCookieManager":@ClsiCookieManager @project_id = "project-id" @user = features: @@ -182,6 +186,7 @@ describe "CompileController", -> it "should open a request to the CLSI", -> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi.url}#{@url}", timeout: 60 * 1000 @@ -204,6 +209,7 @@ describe "CompileController", -> it "should proxy to the priority url if the user has the feature", ()-> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi_priority.url}#{@url}", timeout: 60 * 1000 @@ -218,6 +224,7 @@ describe "CompileController", -> it "should open a request to the CLSI", -> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi.url}#{@url}", timeout: 60 * 1000 @@ -240,6 +247,7 @@ describe "CompileController", -> it "should proxy to the priority url if the user has the feature", ()-> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi_priority.url}#{@url}", timeout: 60 * 1000 @@ -254,6 +262,7 @@ describe "CompileController", -> it "should proxy to the standard url", ()-> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi.url}#{@url}", timeout: 60 * 1000 @@ -269,6 +278,7 @@ describe "CompileController", -> it "should proxy to the standard url without the build parameter", ()-> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi.url}#{@url}", timeout: 60 * 1000 @@ -286,6 +296,7 @@ describe "CompileController", -> it "should open a request to the CLSI", -> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi.url}#{@url}", timeout: 60 * 1000 @@ -313,6 +324,7 @@ describe "CompileController", -> it "should proxy to the priority url if the user has the feature", ()-> @request .calledWith( + jar:@jar method: @req.method url: "#{@settings.apis.clsi_priority.url}#{@url}", timeout: 60 * 1000 @@ -331,8 +343,8 @@ describe "CompileController", -> @CompileController.proxyToClsi(@project_id, @url = "/test", @req, @res, @next) it "should proxy to the standard url with the build parameter", ()-> - @request - .calledWith( + @request.calledWith( + jar:@jar method: @req.method qs: {build: 1234} url: "#{@settings.apis.clsi.url}#{@url}", diff --git a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee index e68f5fa422..814b4f258b 100644 --- a/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Compile/CompileManagerTests.coffee @@ -27,7 +27,7 @@ describe "CompileManager", -> Timer: class Timer done: sinon.stub() inc: sinon.stub() - "logger-sharelatex": @logger = { log: sinon.stub() } + "logger-sharelatex": @logger = { log: sinon.stub(), warn: sinon.stub() } @project_id = "mock-project-id-123" @user_id = "mock-user-id-123" @callback = sinon.stub() @@ -90,15 +90,12 @@ describe "CompileManager", -> .should.equal true describe "when the project has been recently compiled", -> - beforeEach -> + it "should return", (done)-> @CompileManager._checkIfAutoCompileLimitHasBeenHit = (_, cb)-> cb(null, true) @CompileManager._checkIfRecentlyCompiled = sinon.stub().callsArgWith(2, null, true) - @CompileManager.compile @project_id, @user_id, {}, @callback - - it "should return the callback with an error", -> - @callback - .calledWith(new Error("project was recently compiled so not continuing")) - .should.equal true + @CompileManager.compile @project_id, @user_id, {}, (err, status)-> + status.should.equal "too-recently-compiled" + done() describe "should check the rate limit", -> it "should return", (done)-> diff --git a/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee b/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee index ee1360fa0a..1a7e2fb500 100644 --- a/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Documents/DocumentControllerTests.coffee @@ -7,7 +7,7 @@ SandboxedModule = require('sandboxed-module') events = require "events" MockRequest = require "../helpers/MockRequest" MockResponse = require "../helpers/MockResponse" -Errors = require "../../../../app/js/errors" +Errors = require "../../../../app/js/Features/Errors/Errors" describe "DocumentController", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee index a60830b226..fd56fbb021 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Editor/EditorControllerTests.coffee @@ -518,12 +518,23 @@ describe "EditorController", -> @folder_id = "313dasd21dasdsa" @ProjectEntityHandler.moveEntity = sinon.stub().callsArgWith(4, @err) @EditorRealTimeController.emitToRoom = sinon.stub() + @LockManager.releaseLock.callsArgWith(1) + @LockManager.getLock.callsArgWith(1) it "should call the ProjectEntityHandler", (done)-> @EditorController.moveEntity @project_id, @entity_id, @folder_id, @entityType, => @ProjectEntityHandler.moveEntity.calledWith(@project_id, @entity_id, @folder_id, @entityType).should.equal true done() + it "should take the lock", (done)-> + @EditorController.moveEntity @project_id, @entity_id, @folder_id, @entityType, => + @LockManager.getLock.calledWith(@project_id).should.equal true + done() + + it "should release the lock", (done)-> + @EditorController.moveEntity @project_id, @entity_id, @folder_id, @entityType, => + @LockManager.releaseLock.calledWith(@project_id).should.equal true + done() it "should emit the update to the room", (done)-> @EditorController.moveEntity @project_id, @entity_id, @folder_id, @entityType, => diff --git a/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee index ab003208c2..a98c34af28 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee @@ -10,12 +10,13 @@ describe "EditorHttpController", -> '../Project/ProjectDeleter' : @ProjectDeleter = {} '../Project/ProjectGetter' : @ProjectGetter = {} '../User/UserGetter' : @UserGetter = {} - "../Security/AuthorizationManager": @AuthorizationManager = {} + "../Authorization/AuthorizationManager": @AuthorizationManager = {} '../Project/ProjectEditorHandler': @ProjectEditorHandler = {} "./EditorRealTimeController": @EditorRealTimeController = {} "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } "./EditorController": @EditorController = {} '../../infrastructure/Metrics': @Metrics = {inc: sinon.stub()} + "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} @project_id = "mock-project-id" @doc_id = "mock-doc-id" @@ -76,6 +77,17 @@ describe "EditorHttpController", -> @ProjectDeleter.unmarkAsDeletedByExternalSource .calledWith(@project_id) .should.equal true + + describe "with an anonymous user", -> + beforeEach -> + @req.query = + user_id: "anonymous-user" + @EditorHttpController.joinProject @req, @res + + it "should pass the user id as null", -> + @EditorHttpController._buildJoinProjectView + .calledWith(@project_id, null) + .should.equal true describe "_buildJoinProjectView", -> beforeEach -> @@ -85,19 +97,20 @@ describe "EditorHttpController", -> @user = _id: @user_id = "user-id" projects: {} + @members = ["members", "mock"] @projectModelView = _id: @project_id owner:{_id:"something"} view: true @ProjectEditorHandler.buildProjectModelView = sinon.stub().returns(@projectModelView) @ProjectGetter.getProjectWithoutDocLines = sinon.stub().callsArgWith(1, null, @project) - @ProjectGetter.populateProjectWithUsers = sinon.stub().callsArgWith(1, null, @project) + @CollaboratorsHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, null, @members) @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) describe "when authorized", -> beforeEach -> @AuthorizationManager.getPrivilegeLevelForProject = - sinon.stub().callsArgWith(2, null, true, "owner") + sinon.stub().callsArgWith(2, null, "owner") @EditorHttpController._buildJoinProjectView(@project_id, @user_id, @callback) it "should find the project without doc lines", -> @@ -105,8 +118,8 @@ describe "EditorHttpController", -> .calledWith(@project_id) .should.equal true - it "should populate the user references in the project", -> - @ProjectGetter.populateProjectWithUsers + it "should get the list of users in the project", -> + @CollaboratorsHandler.getMembersWithPrivilegeLevels .calledWith(@project) .should.equal true @@ -117,7 +130,7 @@ describe "EditorHttpController", -> it "should check the privilege level", -> @AuthorizationManager.getPrivilegeLevelForProject - .calledWith(@project, @user) + .calledWith(@user_id, @project_id) .should.equal true it "should return the project model view, privilege level and protocol version", -> @@ -126,7 +139,7 @@ describe "EditorHttpController", -> describe "when not authorized", -> beforeEach -> @AuthorizationManager.getPrivilegeLevelForProject = - sinon.stub().callsArgWith(2, null, false, null) + sinon.stub().callsArgWith(2, null, null) @EditorHttpController._buildJoinProjectView(@project_id, @user_id, @callback) it "should return false in the callback", -> diff --git a/services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee b/services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee index 8f9d8a555e..caae9c0ae8 100644 --- a/services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee @@ -19,7 +19,7 @@ describe "FileStoreHandler", -> on: (type, cb)-> if type == "end" cb() - @readStream = {my:"readStream"} + @readStream = {my:"readStream", on: sinon.stub()} @request = sinon.stub() @settings = apis:{filestore:{url:"http//filestore.sharelatex.test"}} @handler = SandboxedModule.require modulePath, requires: @@ -111,24 +111,57 @@ describe "FileStoreHandler", -> describe "getFileStream", -> beforeEach -> + @query = {} @request.returns(@readStream) it "should get the stream with the correct params", (done)-> - @handler.getFileStream @project_id, @file_id, {}, (err, stream)=> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> @request.args[0][0].method.should.equal "get" @request.args[0][0].uri.should.equal @handler._buildUrl() done() it "should get stream from request", (done)-> - @handler.getFileStream @project_id, @file_id, {}, (err, stream)=> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> stream.should.equal @readStream done() it "builds the correct url", (done)-> - @handler.getFileStream @project_id, @file_id, {}, (err, stream)=> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> @handler._buildUrl.calledWith(@project_id, @file_id).should.equal true done() + it "should add an error handler", (done) -> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> + stream.on.calledWith("error").should.equal true + done() + + describe 'when range is specified in query', -> + + beforeEach -> + @query = {'range': '0-10'} + + it 'should add a range header', (done) -> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> + @request.callCount.should.equal 1 + headers = @request.firstCall.args[0].headers + expect(headers).to.have.keys('range') + expect(headers['range']).to.equal 'bytes=0-10' + done() + + describe 'when range is invalid', -> + + ['0-', '-100', 'one-two', 'nonsense'].forEach (r) => + + beforeEach -> + @query = {'range': "#{r}"} + + it "should not add a range header for '#{r}'", (done) -> + @handler.getFileStream @project_id, @file_id, @query, (err, stream)=> + @request.callCount.should.equal 1 + headers = @request.firstCall.args[0].headers + expect(headers).to.not.have.keys('range') + done() + describe "copyFile", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee index 0867fb821f..faa1456b51 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectControllerTests.coffee @@ -35,13 +35,10 @@ describe "ProjectController", -> getAllTags: sinon.stub() @NotificationsHandler = getUserNotifications: sinon.stub() - @ProjectModel = - findAllUsersProjects: sinon.stub() - findPopulatedById: sinon.stub() @UserModel = findById: sinon.stub() - @SecurityManager = - userCanAccessProject:sinon.stub() + @AuthorizationManager = + getPrivilegeLevelForProject:sinon.stub() @EditorController = renameProject:sinon.stub() @InactiveProjectManager = @@ -50,6 +47,9 @@ describe "ProjectController", -> markAsOpened: sinon.stub() @ReferencesSearchHandler = indexProjectReferences: sinon.stub() + @ProjectGetter = + findAllUsersProjects: sinon.stub() + getProject: sinon.stub() @ProjectController = SandboxedModule.require modulePath, requires: "settings-sharelatex":@settings "logger-sharelatex": @@ -67,12 +67,12 @@ describe "ProjectController", -> "../Subscription/LimitationsManager": @LimitationsManager "../Tags/TagsHandler":@TagsHandler "../Notifications/NotificationsHandler":@NotificationsHandler - '../../models/Project': Project:@ProjectModel "../../models/User":User:@UserModel - "../../managers/SecurityManager":@SecurityManager + "../Authorization/AuthorizationManager":@AuthorizationManager "../InactiveData/InactiveProjectManager":@InactiveProjectManager "./ProjectUpdateHandler":@ProjectUpdateHandler "../ReferencesSearch/ReferencesSearchHandler": @ReferencesSearchHandler + "./ProjectGetter": @ProjectGetter @user = _id:"!£123213kjljkl" @@ -128,18 +128,6 @@ describe "ProjectController", -> done() @ProjectController.updateProjectSettings @req, @res - it "should update the public access level", (done) -> - @EditorController.setPublicAccessLevel = sinon.stub().callsArg(2) - @req.body = - publicAccessLevel: @publicAccessLevel = "readonly" - @res.sendStatus = (code) => - @EditorController.setPublicAccessLevel - .calledWith(@project_id, @publicAccessLevel) - .should.equal true - code.should.equal 204 - done() - @ProjectController.updateProjectSettings @req, @res - it "should update the root doc", (done) -> @EditorController.setRootDoc = sinon.stub().callsArg(2) @req.body = @@ -151,6 +139,19 @@ describe "ProjectController", -> code.should.equal 204 done() @ProjectController.updateProjectSettings @req, @res + + describe "updateProjectAdminSettings", -> + it "should update the public access level", (done) -> + @EditorController.setPublicAccessLevel = sinon.stub().callsArg(2) + @req.body = + publicAccessLevel: @publicAccessLevel = "readonly" + @res.sendStatus = (code) => + @EditorController.setPublicAccessLevel + .calledWith(@project_id, @publicAccessLevel) + .should.equal true + code.should.equal 204 + done() + @ProjectController.updateProjectAdminSettings @req, @res describe "deleteProject", -> it "should tell the project deleter to archive when forever=false", (done)-> @@ -224,7 +225,7 @@ describe "ProjectController", -> @LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, false) @TagsHandler.getAllTags.callsArgWith(1, null, @tags, {}) @NotificationsHandler.getUserNotifications = sinon.stub().callsArgWith(1, null, @notifications, {}) - @ProjectModel.findAllUsersProjects.callsArgWith(2, null, @projects, @collabertions, @readOnly) + @ProjectGetter.findAllUsersProjects.callsArgWith(2, null, @projects, @collabertions, @readOnly) it "should render the project/list page", (done)-> @res.render = (pageName, opts)=> @@ -297,10 +298,10 @@ describe "ProjectController", -> fontSize:"massive" theme:"sexy" email: "bob@bob.com" - @ProjectModel.findPopulatedById.callsArgWith 1, null, @project + @ProjectGetter.getProject.callsArgWith 2, null, @project @UserModel.findById.callsArgWith(1, null, @user) @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, {}) - @SecurityManager.userCanAccessProject.callsArgWith 2, true, "owner" + @AuthorizationManager.getPrivilegeLevelForProject.callsArgWith 2, null, "owner" @ProjectDeleter.unmarkAsDeletedByExternalSource = sinon.stub() @InactiveProjectManager.reactivateProjectIfRequired.callsArgWith(1) @ProjectUpdateHandler.markAsOpened.callsArgWith(1) @@ -312,12 +313,6 @@ describe "ProjectController", -> done() @ProjectController.loadEditor @req, @res - it "should add the project onto the opts", (done)-> - @res.render = (pageName, opts)=> - opts.project.should.equal @project - done() - @ProjectController.loadEditor @req, @res - it "should add user", (done)-> @res.render = (pageName, opts)=> opts.user.email.should.equal @user.email @@ -339,7 +334,7 @@ describe "ProjectController", -> @ProjectController.loadEditor @req, @res it "should not render the page if the project can not be accessed", (done)-> - @SecurityManager.userCanAccessProject = sinon.stub().callsArgWith 2, false + @AuthorizationManager.getPrivilegeLevelForProject = sinon.stub().callsArgWith 2, null, null @res.sendStatus = (resCode, opts)=> resCode.should.equal 401 done() diff --git a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee index bdf9f79ece..601f7867b0 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectDeleterTests.coffee @@ -33,6 +33,7 @@ describe 'ProjectDeleter', -> '../DocumentUpdater/DocumentUpdaterHandler': @documentUpdaterHandler "../Tags/TagsHandler":@TagsHandler "../FileStore/FileStoreHandler": @FileStoreHandler = {} + "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} "./ProjectGetter": @ProjectGetter 'logger-sharelatex': log:-> @@ -92,6 +93,8 @@ describe 'ProjectDeleter', -> describe "archiveProject", -> beforeEach -> + @CollaboratorsHandler.getMemberIds = sinon.stub() + @CollaboratorsHandler.getMemberIds.withArgs(@project_id).yields(null, ["member-id-1", "member-id-2"]) @ProjectGetter.getProject.callsArgWith(2, null, @project) @Project.update.callsArgWith(2) @@ -111,12 +114,8 @@ describe 'ProjectDeleter', -> it "should removeProjectFromAllTags", (done)-> @deleter.archiveProject @project_id, => - @TagsHandler.removeProjectFromAllTags.calledWith(@project.owner_ref, @project_id).should.equal true - @TagsHandler.removeProjectFromAllTags.calledWith(@project.collaberator_refs[0], @project_id).should.equal true - @TagsHandler.removeProjectFromAllTags.calledWith(@project.collaberator_refs[1], @project_id).should.equal true - @TagsHandler.removeProjectFromAllTags.calledWith(@project.readOnly_refs[0], @project_id).should.equal true - @TagsHandler.removeProjectFromAllTags.calledWith(@project.readOnly_refs[1], @project_id).should.equal true - + @TagsHandler.removeProjectFromAllTags.calledWith("member-id-1", @project_id).should.equal true + @TagsHandler.removeProjectFromAllTags.calledWith("member-id-2", @project_id).should.equal true done() describe "restoreProject", -> diff --git a/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee index 52c871669a..eede50bfd3 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee @@ -38,33 +38,41 @@ describe "ProjectEditorHandler", -> folders : [] }] }] - owner_ref : - _id: "owner-id" - first_name : "Owner" - last_name : "ShareLaTeX" - email : "owner@sharelatex.com" - readOnly_refs: [{ - _id: "read-only-id" - first_name : "Read" - last_name : "Only" - email : "read-only@sharelatex.com" - }] - collaberator_refs: [{ - _id: "read-write-id" - first_name : "Read" - last_name : "Write" - email : "read-write@sharelatex.com" - }] deletedDocs: [{ _id: "deleted-doc-id" name: "main.tex" }] + @members = [{ + user: @owner = { + _id: "owner-id" + first_name : "Owner" + last_name : "ShareLaTeX" + email : "owner@sharelatex.com" + }, + privilegeLevel: "owner" + },{ + user: { + _id: "read-only-id" + first_name : "Read" + last_name : "Only" + email : "read-only@sharelatex.com" + }, + privilegeLevel: "readOnly" + },{ + user: { + _id: "read-write-id" + first_name : "Read" + last_name : "Write" + email : "read-write@sharelatex.com" + }, + privilegeLevel: "readAndWrite" + }] @handler = SandboxedModule.require modulePath describe "buildProjectModelView", -> describe "with owner and members included", -> beforeEach -> - @result = @handler.buildProjectModelView @project + @result = @handler.buildProjectModelView @project, @members it "should include the id", -> should.exist @result._id @@ -140,41 +148,30 @@ describe "ProjectEditorHandler", -> it "should set the deletedByExternalDataSource flag to false when it is not there", -> delete @project.deletedByExternalDataSource - result = @handler.buildProjectModelView @project + result = @handler.buildProjectModelView @project, @members result.deletedByExternalDataSource.should.equal false it "should set the deletedByExternalDataSource flag to false when it is false", -> - result = @handler.buildProjectModelView @project + result = @handler.buildProjectModelView @project, @members result.deletedByExternalDataSource.should.equal false it "should set the deletedByExternalDataSource flag to true when it is true", -> @project.deletedByExternalDataSource = true - result = @handler.buildProjectModelView @project + result = @handler.buildProjectModelView @project, @members result.deletedByExternalDataSource.should.equal true describe "features", -> beforeEach -> - @project.owner_ref.features = + @owner.features = versioning: true collaborators: 3 compileGroup:"priority" compileTimeout: 96 - @result = @handler.buildProjectModelView @project + @result = @handler.buildProjectModelView @project, @members it "should copy the owner features to the project", -> - @result.features.versioning.should.equal @project.owner_ref.features.versioning - @result.features.collaborators.should.equal @project.owner_ref.features.collaborators - @result.features.compileGroup.should.equal @project.owner_ref.features.compileGroup - @result.features.compileTimeout.should.equal @project.owner_ref.features.compileTimeout + @result.features.versioning.should.equal @owner.features.versioning + @result.features.collaborators.should.equal @owner.features.collaborators + @result.features.compileGroup.should.equal @owner.features.compileGroup + @result.features.compileTimeout.should.equal @owner.features.compileTimeout - - describe "without owners and members", -> - beforeEach -> - @result = @handler.buildProjectModelView @project, includeUsers: false - - it "should not include the owner", -> - should.not.exist @result.owner - - it "should not include the members", -> - should.not.exist @result.members - diff --git a/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee index a44a16c10b..e946850828 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectEntityHandlerTests.coffee @@ -7,7 +7,7 @@ modulePath = "../../../../app/js/Features/Project/ProjectEntityHandler" SandboxedModule = require('sandboxed-module') ObjectId = require("mongoose").Types.ObjectId tk = require 'timekeeper' -Errors = require "../../../../app/js/errors" +Errors = require "../../../../app/js/Features/Errors/Errors" describe 'ProjectEntityHandler', -> project_id = '4eecb1c1bffa66588e0000a1' @@ -1048,6 +1048,13 @@ describe 'ProjectEntityHandler', -> @projectLocator.findElement.args[0][0].element_id.should.equal @project.rootFolder[0]._id done() + it "should error if the element has no _id", (done)-> + doc = + name:"something" + @ProjectEntityHandler._putElement @project, @folder._id, doc, "doc", (err)=> + @ProjectModel.update.called.should.equal false + done() + describe "_countElements", -> diff --git a/services/web/test/UnitTests/coffee/Project/ProjectGetterTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectGetterTests.coffee index 98c7310d84..5ab82d611c 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectGetterTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectGetterTests.coffee @@ -16,6 +16,8 @@ describe "ProjectGetter", -> projects: {} users: {} ObjectId: ObjectId + "../../models/Project": Project: @Project = {} + "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} "logger-sharelatex": err:-> log:-> @@ -134,56 +136,16 @@ describe "ProjectGetter", -> @db.projects.find = sinon.stub().callsArgWith(2, null, [@project]) - it "should call find with the project id when string id is passed", (done)-> - @ProjectGetter.getProject @project_id, (err, project)=> - @db.projects.find.calledWith(_id: ObjectId(@project_id)).should.equal true - assert.deepEqual @project, project - done() - - it "should call find with the project id when object id is passed", (done)-> - @ProjectGetter.getProject ObjectId(@project_id), (err, project)=> - @db.projects.find.calledWith(_id: ObjectId(@project_id)).should.equal true - assert.deepEqual @project, project - done() - - it "should call the db when a mongoose objectid is used", (done)-> - mongooseID = require('mongoose').Types.ObjectId(@project_id) - @ProjectGetter.getProject mongooseID, (err, project)=> - @db.projects.find.calledWith(_id: ObjectId(@project_id)).should.equal true - assert.deepEqual @project, project - done() - - describe "populateProjectWithUsers", -> + describe "findAllUsersProjects", -> beforeEach -> - @users = [] - @user_lookup = {} - for i in [0..4] - @users[i] = _id: ObjectId.createPk() - @user_lookup[@users[i]._id.toString()] = @users[i] - @project = - _id: ObjectId.createPk() - owner_ref: @users[0]._id - readOnly_refs: [@users[1]._id, @users[2]._id] - collaberator_refs: [@users[3]._id, @users[4]._id] - @db.users.find = (query, callback) => - callback null, [@user_lookup[query._id.toString()]] - sinon.spy @db.users, "find" - @ProjectGetter.populateProjectWithUsers @project, (err, project)=> - @callback err, project - - it "should look up each user", -> - for user in @users - @db.users.find.calledWith(_id: user._id).should.equal true - - it "should set the owner_ref to the owner", -> - @project.owner_ref.should.equal @users[0] - - it "should set the readOnly_refs to the read only users", -> - expect(@project.readOnly_refs).to.deep.equal [@users[1], @users[2]] - - it "should set the collaberator_refs to the collaborators", -> - expect(@project.collaberator_refs).to.deep.equal [@users[3], @users[4]] - - it "should call the callback", -> - assert.deepEqual @callback.args[0][1], @project - + @fields = {"mock": "fields"} + @Project.find = sinon.stub() + @Project.find.withArgs({owner_ref: @user_id}, @fields).yields(null, ["mock-owned-projects"]) + @CollaboratorsHandler.getProjectsUserIsCollaboratorOf = sinon.stub() + @CollaboratorsHandler.getProjectsUserIsCollaboratorOf.withArgs(@user_id, @fields).yields(null, ["mock-rw-projects"], ["mock-ro-projects"]) + @ProjectGetter.findAllUsersProjects @user_id, @fields, @callback + + it "should call the callback with all the projects", -> + @callback + .calledWith(null, ["mock-owned-projects"], ["mock-rw-projects"], ["mock-ro-projects"]) + .should.equal true diff --git a/services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee index f43749a5ca..437c32b122 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee @@ -5,7 +5,7 @@ should = chai.should() modulePath = "../../../../app/js/Features/Project/ProjectLocator" SandboxedModule = require('sandboxed-module') sinon = require('sinon') -Errors = require "../../../../app/js/errors" +Errors = require "../../../../app/js/Features/Errors/Errors" expect = require("chai").expect Project = class Project @@ -30,7 +30,7 @@ project.rootFolder[0] = rootFolder project.rootDoc_id = rootDoc._id -describe 'ProjectLocatorTests', -> +describe 'ProjectLocator', -> beforeEach -> Project.getProject = (project_id, fields, callback)=> @@ -169,6 +169,13 @@ describe 'ProjectLocatorTests', -> assert !err? expect(doc).to.equal null done() + + it 'should return null when the rootDoc_id no longer exists', (done) -> + project.rootDoc_id = "doesntexist" + @locator.findRootDoc project, (err, doc)-> + assert !err? + expect(doc).to.equal null + done() describe 'findElementByPath', -> @@ -301,7 +308,7 @@ describe 'ProjectLocatorTests', -> user_id = "123jojoidns" stubbedProject = {name:"findThis"} projects = [{name:"notThis"}, {name:"wellll"}, stubbedProject, {name:"Noooo"}] - Project.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects) + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects) @locator.findUsersProjectByName user_id, stubbedProject.name.toLowerCase(), (err, project)-> project.should.equal stubbedProject done() @@ -310,7 +317,7 @@ describe 'ProjectLocatorTests', -> user_id = "123jojoidns" stubbedProject = {name:"findThis", _id:12331321} projects = [{name:"notThis"}, {name:"wellll"}, {name:"findThis",archived:true}, stubbedProject, {name:"findThis",archived:true}, {name:"Noooo"}] - Project.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects) + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects) @locator.findUsersProjectByName user_id, stubbedProject.name.toLowerCase(), (err, project)-> project._id.should.equal stubbedProject._id done() @@ -319,7 +326,7 @@ describe 'ProjectLocatorTests', -> user_id = "123jojoidns" stubbedProject = {name:"findThis"} projects = [{name:"notThis"}, {name:"wellll"}, {name:"Noooo"}] - Project.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects, [stubbedProject]) + @ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, projects, [stubbedProject]) @locator.findUsersProjectByName user_id, stubbedProject.name.toLowerCase(), (err, project)-> project.should.equal stubbedProject done() diff --git a/services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee b/services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee index 86cf9111dc..c46e71a542 100644 --- a/services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/References/ReferencesHandlerTests.coffee @@ -21,7 +21,11 @@ describe 'ReferencesHandler', -> {name: 'two.txt', _id: 'bbb'}, ] folders: [ - {docs: [{name: 'three.bib', _id: 'ccc'}], folders: []} + { + docs: [{name: 'three.bib', _id: 'ccc'}], + fileRefs: [{name: 'four.bib', _id: 'ghg'}], + folders: [] + } ] ] @docIds = ['aaa', 'ccc'] @@ -34,15 +38,17 @@ describe 'ReferencesHandler', -> apis: references: {url: 'http://some.url/references'} docstore: {url: 'http://some.url/docstore'} + filestore: {url: 'http://some.url/filestore'} } 'request': @request = { get: sinon.stub() post: sinon.stub() } - '../../models/Project': { - Project: @Project = { - findPopulatedById: sinon.stub().callsArgWith(1, null, @fakeProject) - } + '../Project/ProjectGetter': @ProjectGetter = { + getProject: sinon.stub().callsArgWith(2, null, @fakeProject) + } + '../User/UserGetter': @UserGetter = { + getUser: sinon.stub() } '../DocumentUpdater/DocumentUpdaterHandler': @DocumentUpdaterHandler = { flushDocToMongo: sinon.stub().callsArgWith(2, null) @@ -55,6 +61,7 @@ describe 'ReferencesHandler', -> beforeEach -> sinon.stub(@handler, '_findBibDocIds') + sinon.stub(@handler, '_findBibFileIds') sinon.stub(@handler, '_isFullIndex').callsArgWith(1, null, true) @request.post.callsArgWith(1, null, {statusCode: 200}, @fakeResponseData) @call = (callback) => @@ -70,10 +77,10 @@ describe 'ReferencesHandler', -> @handler._findBibDocIds.callCount.should.equal 0 done() - it 'should call Project.findPopulatedById', (done) -> + it 'should call ProjectGetter.getProject', (done) -> @call (err, data) => - @Project.findPopulatedById.callCount.should.equal 1 - @Project.findPopulatedById.calledWith(@projectId).should.equal true + @ProjectGetter.getProject.callCount.should.equal 1 + @ProjectGetter.getProject.calledWith(@projectId).should.equal true done() it 'should not call _findBibDocIds', (done) -> @@ -109,10 +116,10 @@ describe 'ReferencesHandler', -> expect(data).to.equal @fakeResponseData done() - describe 'when Project.findPopulatedById produces an error', -> + describe 'when ProjectGetter.getProject produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, new Error('woops')) + @ProjectGetter.getProject.callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err, data) => @@ -129,7 +136,7 @@ describe 'ReferencesHandler', -> describe 'when _isFullIndex produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, new Error('woops')) it 'should produce an error', (done) -> @@ -147,7 +154,7 @@ describe 'ReferencesHandler', -> describe 'when flushDocToMongo produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, false) @DocumentUpdaterHandler.flushDocToMongo.callsArgWith(2, new Error('woops')) @@ -167,7 +174,7 @@ describe 'ReferencesHandler', -> describe 'when request produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, null, false) @DocumentUpdaterHandler.flushDocToMongo.callsArgWith(2, null) @request.post.callsArgWith(1, new Error('woops')) @@ -182,7 +189,7 @@ describe 'ReferencesHandler', -> describe 'when request responds with error status', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, null, false) @request.post.callsArgWith(1, null, {statusCode: 500}, null) @@ -197,6 +204,7 @@ describe 'ReferencesHandler', -> beforeEach -> sinon.stub(@handler, '_findBibDocIds').returns(['aaa', 'ccc']) + sinon.stub(@handler, '_findBibFileIds').returns(['fff', 'ggg']) sinon.stub(@handler, '_isFullIndex').callsArgWith(1, null, true) @request.post.callsArgWith(1, null, {statusCode: 200}, @fakeResponseData) @call = (callback) => @@ -208,6 +216,12 @@ describe 'ReferencesHandler', -> @handler._findBibDocIds.calledWith(@fakeProject).should.equal true done() + it 'should call _findBibFileIds', (done) -> + @call (err, data) => + @handler._findBibDocIds.callCount.should.equal 1 + @handler._findBibDocIds.calledWith(@fakeProject).should.equal true + done() + it 'should call DocumentUpdaterHandler.flushDocToMongo', (done) -> @call (err, data) => @DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal 2 @@ -218,7 +232,7 @@ describe 'ReferencesHandler', -> @request.post.callCount.should.equal 1 arg = @request.post.firstCall.args[0] expect(arg.json).to.have.all.keys 'docUrls', 'fullIndex' - expect(arg.json.docUrls.length).to.equal 2 + expect(arg.json.docUrls.length).to.equal 4 expect(arg.json.fullIndex).to.equal true done() @@ -234,10 +248,10 @@ describe 'ReferencesHandler', -> expect(data).to.equal @fakeResponseData done() - describe 'when Project.findPopulatedById produces an error', -> + describe 'when ProjectGetter.getProject produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, new Error('woops')) + @ProjectGetter.getProject.callsArgWith(2, new Error('woops')) it 'should produce an error', (done) -> @call (err, data) => @@ -254,7 +268,7 @@ describe 'ReferencesHandler', -> describe 'when _isFullIndex produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, new Error('woops')) it 'should produce an error', (done) -> @@ -272,7 +286,7 @@ describe 'ReferencesHandler', -> describe 'when flushDocToMongo produces an error', -> beforeEach -> - @Project.findPopulatedById.callsArgWith(1, null, @fakeProject) + @ProjectGetter.getProject.callsArgWith(2, null, @fakeProject) @handler._isFullIndex.callsArgWith(1, false) @DocumentUpdaterHandler.flushDocToMongo.callsArgWith(2, new Error('woops')) @@ -308,20 +322,49 @@ describe 'ReferencesHandler', -> result = @handler._findBibDocIds(@fakeProject) expect(result).to.deep.equal @expectedIds + describe '_findBibFileIds', -> + + beforeEach -> + @fakeProject = + rootFolder: [ + docs: [ + {name: 'one.bib', _id: 'aaa'}, + {name: 'two.txt', _id: 'bbb'}, + ] + fileRefs: [ + {name: 'other.bib', _id: 'ddd'} + ], + folders: [ + { + docs: [{name: 'three.bib', _id: 'ccc'}], + fileRefs: [{name: 'four.bib', _id: 'ghg'}], + folders: [] + } + ] + ] + @expectedIds = ['ddd', 'ghg'] + + it 'should select the correct docIds', -> + result = @handler._findBibFileIds(@fakeProject) + expect(result).to.deep.equal @expectedIds + describe '_isFullIndex', -> beforeEach -> @fakeProject = - owner_ref: - features: - references: false + owner_ref: @owner_ref = "owner-ref-123" + @owner = + features: + references: false + @UserGetter.getUser = sinon.stub() + @UserGetter.getUser.withArgs(@owner_ref, {features: true}).yields(null, @owner) @call = (callback) => @handler._isFullIndex @fakeProject, callback describe 'with references feature on', -> beforeEach -> - @fakeProject.owner_ref.features.references = true + @owner.features.references = true it 'should return true', -> @call (err, isFullIndex) => @@ -331,7 +374,7 @@ describe 'ReferencesHandler', -> describe 'with references feature off', -> beforeEach -> - @fakeProject.owner_ref.features.references = false + @owner.features.references = false it 'should return false', -> @call (err, isFullIndex) => diff --git a/services/web/test/UnitTests/coffee/Security/AuthorizationManagerTests.coffee b/services/web/test/UnitTests/coffee/Security/AuthorizationManagerTests.coffee deleted file mode 100644 index 5be9c5ea20..0000000000 --- a/services/web/test/UnitTests/coffee/Security/AuthorizationManagerTests.coffee +++ /dev/null @@ -1,96 +0,0 @@ -SandboxedModule = require('sandboxed-module') -sinon = require('sinon') -require('chai').should() -modulePath = require('path').join __dirname, '../../../../app/js/Features/Security/AuthorizationManager' -MockClient = require "../helpers/MockClient" - -describe "AuthorizationManager", -> - beforeEach -> - @client = new MockClient() - @AuthorizationManager = SandboxedModule.require modulePath, requires: - '../../managers/SecurityManager':{} - - describe "ensureClientCanViewProject", -> - beforeEach -> - @client.set("project_id", "project-id") - - it "should let the request through for a readOnly privilege", (done) -> - @client.set("privilege_level", "readOnly") - @AuthorizationManager.ensureClientCanViewProject @client, done - - it "should let the request through for a readAndWrite privilege", (done) -> - @client.set("privilege_level", "readAndWrite") - @AuthorizationManager.ensureClientCanViewProject @client, done - - it "should let the request through for a owner privilege", (done) -> - @client.set("privilege_level", "owner") - @AuthorizationManager.ensureClientCanViewProject @client, done - - it "should ignore an empty privilege", -> - @AuthorizationManager.ensureClientCanViewProject @client, () -> - throw new Error("Should not be called") - - describe "ensureClientCanEditProject", -> - beforeEach -> - @client.set("project_id", "project-id") - - it "should ignore a readOnly privilege", -> - @client.set("privilege_level", "readOnly") - @AuthorizationManager.ensureClientCanEditProject @client, () -> - throw new Error("Should not be called") - - it "should let the request through for a readAndWrite privilege", (done) -> - @client.set("privilege_level", "readAndWrite") - @AuthorizationManager.ensureClientCanEditProject @client, done - - it "should let the request through for a owner privilege", (done) -> - @client.set("privilege_level", "owner") - @AuthorizationManager.ensureClientCanEditProject @client, done - - it "should ignore an empty privilege", -> - @AuthorizationManager.ensureClientCanEditProject @client, () -> - throw new Error("Should not be called") - - describe "ensureClientCanAdminProject", -> - beforeEach -> - @client.set("project_id", "project-id") - - it "should ignore a readOnly privilege", -> - @client.set("privilege_level", "readOnly") - @AuthorizationManager.ensureClientCanAdminProject @client, () -> - throw new Error("Should not be called") - - it "should ignore a readAndWrite privilege", -> - @client.set("privilege_level", "readAndWrite") - @AuthorizationManager.ensureClientCanAdminProject @client, () -> - throw new Error("Should not be called") - - it "should let the request through for a owner privilege", (done) -> - @client.set("privilege_level", "owner") - @AuthorizationManager.ensureClientCanAdminProject @client, done - - it "should ignore an empty privilege", -> - @AuthorizationManager.ensureClientCanAdminProject @client, () -> - throw new Error("Should not be called") - - describe "ensureClientHasPrivilegeLevelForProject", -> - it "should ignore callback if privilege_level is not set", -> - @client.set("project_id", "project-id") - @AuthorizationManager.ensureClientHasPrivilegeLevelForProject @client, - ["owner"], (error, project_id) -> - throw new Error("Should not be called") - - it "should ignore callback if project_id is not set", -> - @client.set("privilege_level", "owner") - @AuthorizationManager.ensureClientHasPrivilegeLevelForProject @client, - ["owner"], (error, project_id) -> - throw new Error("Should not be called") - - it "should return the project_id", (done) -> - @client.set("privilege_level", "owner") - @client.set("project_id", "project-id-123") - @AuthorizationManager.ensureClientHasPrivilegeLevelForProject @client, - ["owner"], (error, project_id) -> - project_id.should.equal "project-id-123" - done() - diff --git a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee index df1edca280..581d5cd6da 100644 --- a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee @@ -29,6 +29,7 @@ describe "LimitationsManager", -> '../../models/User' : User: @User './SubscriptionLocator':@SubscriptionLocator 'settings-sharelatex' : @Settings = {} + "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} 'logger-sharelatex':log:-> describe "allowedNumberOfCollaboratorsInProject", -> @@ -51,22 +52,10 @@ describe "LimitationsManager", -> it "should return the number of collaborators the user is allowed", -> @callback.calledWith(null, @user.features.collaborators).should.equal true - - describe "currentNumberOfCollaboratorsInProject", -> - beforeEach -> - @project.collaberator_refs = ["one", "two"] - @project.readOnly_refs = ["three"] - @callback = sinon.stub() - @LimitationsManager.currentNumberOfCollaboratorsInProject(@project_id, @callback) - - it "should return the total number of collaborators", -> - @callback.calledWith(null, 3).should.equal true describe "canAddXCollaborators", -> beforeEach -> - sinon.stub @LimitationsManager, - "currentNumberOfCollaboratorsInProject", - (project_id, callback) => callback(null, @current_number) + @CollaboratorsHandler.getCollaboratorCount = (project_id, callback) => callback(null, @current_number) sinon.stub @LimitationsManager, "allowedNumberOfCollaboratorsInProject", (project_id, callback) => callback(null, @allowed_number) diff --git a/services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee index 87cbc11671..27e9e571d1 100644 --- a/services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee @@ -23,8 +23,8 @@ describe "SubscriptionController sanboxed", -> @user = {email:"tom@yahoo.com"} @activeRecurlySubscription = mockSubscriptions["subscription-123-active"] - @SecurityManager = - getCurrentUser: sinon.stub().callsArgWith(1, null, @user) + @AuthenticationController = + getLoggedInUser: sinon.stub().callsArgWith(1, null, @user) @SubscriptionHandler = createSubscription: sinon.stub().callsArgWith(3) updateSubscription: sinon.stub().callsArgWith(3) @@ -61,14 +61,16 @@ describe "SubscriptionController sanboxed", -> @SubscriptionDomainHandler = getDomainLicencePage:sinon.stub() @SubscriptionController = SandboxedModule.require modulePath, requires: - '../../managers/SecurityManager': @SecurityManager + '../Authentication/AuthenticationController': @AuthenticationController './SubscriptionHandler': @SubscriptionHandler "./PlansLocator": @PlansLocator './SubscriptionViewModelBuilder': @SubscriptionViewModelBuilder "./LimitationsManager": @LimitationsManager "../../infrastructure/GeoIpLookup":@GeoIpLookup './RecurlyWrapper': @RecurlyWrapper - "logger-sharelatex": log:-> + "logger-sharelatex": + log:-> + warn:-> "settings-sharelatex": @settings "./SubscriptionDomainHandler":@SubscriptionDomainHandler @@ -97,7 +99,7 @@ describe "SubscriptionController sanboxed", -> describe "with a user with a subscription", -> beforeEach (done) -> @LimitationsManager.userHasSubscription.callsArgWith(1, null, true) - @user.id = @activeRecurlySubscription.account.account_code + @user._id = @activeRecurlySubscription.account.account_code @res.callback = done @SubscriptionController.editBillingDetailsPage(@req, @res) @@ -108,7 +110,7 @@ describe "SubscriptionController sanboxed", -> it "should set the correct variables for the template", -> should.exist @res.renderedVariables.signature @res.renderedVariables.successURL.should.equal "#{@settings.siteUrl}/user/subscription/update" - @res.renderedVariables.user.id.should.equal @user.id + @res.renderedVariables.user.id.should.equal @user._id describe "with a user without subscription", -> beforeEach (done) -> @@ -138,14 +140,6 @@ describe "SubscriptionController sanboxed", -> done() @SubscriptionController.paymentPage @req, @res - it "should set the successURL", (done)-> - @req.session._csrf = @csrfToken = "mock-csrf-token" - @res.render = (page, opts)=> - url = JSON.parse(opts.subscriptionFormOptions).successURL - url.should.equal("#{@settings.siteUrl}/user/subscription/create?_csrf=#{@csrfToken}") - done() - @SubscriptionController.paymentPage @req, @res - describe "with a user with subscription", -> it "should redirect to the subscription dashboard", (done)-> @LimitationsManager.userHasSubscription.callsArgWith(1, null, true) @@ -273,7 +267,7 @@ describe "SubscriptionController sanboxed", -> describe "userCustomSubscriptionPage", -> beforeEach (done) -> @res.callback = done - @LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, true) + @LimitationsManager.userHasSubscriptionOrIsGroupMember.callsArgWith(1, null, true, {}) @SubscriptionController.userCustomSubscriptionPage @req, @res it "should render the page", (done)-> diff --git a/services/web/test/UnitTests/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/UnitTests/coffee/Subscription/SubscriptionUpdaterTests.coffee index 3fab075c30..ffef94157b 100644 --- a/services/web/test/UnitTests/coffee/Subscription/SubscriptionUpdaterTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/SubscriptionUpdaterTests.coffee @@ -5,7 +5,7 @@ modulePath = "../../../../app/js/Features/Subscription/SubscriptionUpdater" assert = require("chai").assert ObjectId = require('mongoose').Types.ObjectId -describe "Subscription Updater", -> +describe "SubscriptionUpdater", -> beforeEach -> @recurlySubscription = @@ -13,15 +13,23 @@ describe "Subscription Updater", -> plan: plan_code: "kjhsakjds" @adminUser = - _id:"5208dd34438843e2db000007" + _id: @adminuser_id = "5208dd34438843e2db000007" @otherUserId = "5208dd34438842e2db000005" @allUserIds = ["13213", "dsadas", "djsaiud89"] @subscription = subscription = admin_id: @adminUser._id - members_id: @allUserIds + member_ids: @allUserIds save: sinon.stub().callsArgWith(0) freeTrial:{} - plan_code:"student_or_something" + planCode:"student_or_something" + + @groupSubscription = + admin_id: @adminUser._id + member_ids: @allUserIds + save: sinon.stub().callsArgWith(0) + freeTrial:{} + planCode:"group_subscription" + @updateStub = sinon.stub().callsArgWith(2, null) @findAndModifyStub = sinon.stub().callsArgWith(2, null, @subscription) @@ -34,6 +42,7 @@ describe "Subscription Updater", -> @SubscriptionLocator = getUsersSubscription: sinon.stub() + getGroupSubscriptionMemberOf:sinon.stub() @Settings = freeTrialPlanCode: "collaborator" @@ -58,21 +67,37 @@ describe "Subscription Updater", -> describe "syncSubscription", -> - it "should update the subscription if the user already is admin of one", (done)-> + + beforeEach -> + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) - @SubscriptionUpdater._updateSubscription = sinon.stub().callsArgWith(2) + @SubscriptionUpdater._updateSubscriptionFromRecurly = sinon.stub().callsArgWith(2) + + it "should update the subscription if the user already is admin of one", (done)-> @SubscriptionUpdater._createNewSubscription = sinon.stub() + @SubscriptionUpdater.syncSubscription @recurlySubscription, @adminUser._id, (err)=> @SubscriptionLocator.getUsersSubscription.calledWith(@adminUser._id).should.equal true - @SubscriptionUpdater._updateSubscription.called.should.equal true - @SubscriptionUpdater._updateSubscription.calledWith(@recurlySubscription, @subscription).should.equal true + @SubscriptionUpdater._updateSubscriptionFromRecurly.called.should.equal true + @SubscriptionUpdater._updateSubscriptionFromRecurly.calledWith(@recurlySubscription, @subscription).should.equal true + done() + + it "should not call updateFeatures with group subscription if recurly subscription is not expired", (done)-> + + @SubscriptionUpdater.syncSubscription @recurlySubscription, @adminUser._id, (err)=> + @SubscriptionLocator.getUsersSubscription.calledWith(@adminUser._id).should.equal true + @SubscriptionUpdater._updateSubscriptionFromRecurly.called.should.equal true + @SubscriptionUpdater._updateSubscriptionFromRecurly.calledWith(@recurlySubscription, @subscription).should.equal true + @UserFeaturesUpdater.updateFeatures.called.should.equal false done() - describe "_updateSubscription", -> - + describe "_updateSubscriptionFromRecurly", -> + beforeEach -> + @SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub().callsArgWith(1) + it "should update the subscription with token etc when not expired", (done)-> - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> + @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> @subscription.recurlySubscription_id.should.equal @recurlySubscription.uuid @subscription.planCode.should.equal @recurlySubscription.plan.plan_code @@ -80,43 +105,40 @@ describe "Subscription Updater", -> assert.equal(@subscription.freeTrial.expiresAt, undefined) assert.equal(@subscription.freeTrial.planCode, undefined) @subscription.save.called.should.equal true - @UserFeaturesUpdater.updateFeatures.calledWith(@adminUser._id, @recurlySubscription.plan.plan_code).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true done() it "should remove the recurlySubscription_id when expired", (done)-> @recurlySubscription.state = "expired" - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> + @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> assert.equal(@subscription.recurlySubscription_id, undefined) @subscription.save.called.should.equal true - @UserFeaturesUpdater.updateFeatures.calledWith(@adminUser._id, @Settings.defaultPlanCode).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true done() it "should update all the users features", (done)-> - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> - @UserFeaturesUpdater.updateFeatures.calledWith(@adminUser._id, @recurlySubscription.plan.plan_code).should.equal true - @UserFeaturesUpdater.updateFeatures.calledWith(@allUserIds[0], @recurlySubscription.plan.plan_code).should.equal true - @UserFeaturesUpdater.updateFeatures.calledWith(@allUserIds[1], @recurlySubscription.plan.plan_code).should.equal true - @UserFeaturesUpdater.updateFeatures.calledWith(@allUserIds[2], @recurlySubscription.plan.plan_code).should.equal true + @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@adminUser._id).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[0]).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[1]).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@allUserIds[2]).should.equal true done() it "should set group to true and save how many members can be added to group", (done)-> @PlansLocator.findLocalPlanInSettings.withArgs(@recurlySubscription.plan.plan_code).returns({groupPlan:true, membersLimit:5}) - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> + @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> @subscription.membersLimit.should.equal 5 @subscription.groupPlan.should.equal true done() it "should not set group to true or set groupPlan", (done)-> - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> + @SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=> assert.notEqual @subscription.membersLimit, 5 assert.notEqual @subscription.groupPlan, true done() - it "should call assignBonus", (done)-> - @SubscriptionUpdater._updateSubscription @recurlySubscription, @subscription, (err)=> - @ReferalAllocator.assignBonus.calledWith(@subscription.admin_id).should.equal true - done() + describe "_createNewSubscription", -> it "should create a new subscription then update the subscription", (done)-> @@ -142,6 +164,9 @@ describe "Subscription Updater", -> done() describe "removeUserFromGroup", -> + beforeEach -> + @SubscriptionUpdater._setUsersMinimumFeatures = sinon.stub().callsArgWith(1) + it "should pull the users id from the group", (done)-> @SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, => searchOps = @@ -153,5 +178,55 @@ describe "Subscription Updater", -> it "should update the users features", (done)-> @SubscriptionUpdater.removeUserFromGroup @adminUser._id, @otherUserId, => - @UserFeaturesUpdater.updateFeatures.calledWith(@otherUserId, @Settings.defaultPlanCode).should.equal true + @SubscriptionUpdater._setUsersMinimumFeatures.calledWith(@otherUserId).should.equal true done() + + describe "_setUsersMinimumFeatures", -> + + it "should call updateFeatures with the subscription if set", (done)-> + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) + @SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null) + + @SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=> + args = @UserFeaturesUpdater.updateFeatures.args[0] + assert.equal args[0], @adminUser._id + assert.equal args[1], @subscription.planCode + done() + + it "should call updateFeatures with the group subscription if set", (done)-> + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null) + @SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription) + + @SubscriptionUpdater._setUsersMinimumFeatures @adminUser._id, (err)=> + args = @UserFeaturesUpdater.updateFeatures.args[0] + assert.equal args[0], @adminUser._id + assert.equal args[1], @groupSubscription.planCode + done() + + it "should call not call updateFeatures with users subscription if the subscription plan code is the default one (downgraded)", (done)-> + @subscription.planCode = @Settings.defaultPlanCode + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) + @SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null, @groupSubscription) + @SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=> + args = @UserFeaturesUpdater.updateFeatures.args[0] + assert.equal args[0], @adminUser._id + assert.equal args[1], @groupSubscription.planCode + done() + + + it "should call updateFeatures with default if there are no subscriptions for user", (done)-> + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null) + @SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null) + @SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=> + args = @UserFeaturesUpdater.updateFeatures.args[0] + assert.equal args[0], @adminUser._id + assert.equal args[1], @Settings.defaultPlanCode + done() + + it "should call assignBonus", (done)-> + @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null) + @SubscriptionLocator.getGroupSubscriptionMemberOf.callsArgWith(1, null) + @SubscriptionUpdater._setUsersMinimumFeatures @adminuser_id, (err)=> + @ReferalAllocator.assignBonus.calledWith(@adminuser_id).should.equal true + done() + diff --git a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee b/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee index bb434c8fb6..6e95e4e100 100644 --- a/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee +++ b/services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee @@ -20,7 +20,10 @@ filestoreUrl = "filestore.sharelatex.com" describe 'TpdsUpdateSender', -> beforeEach -> @requestQueuer = (queue, meth, opts, callback)-> - project = {owner_ref:user_id,readOnly_refs:[read_only_ref_1], collaberator_refs:[collaberator_ref_1]} + project = {owner_ref:user_id} + member_ids = [collaberator_ref_1, read_only_ref_1, user_id] + @CollaboratorsHandler = + getMemberIds: sinon.stub().yields(null, member_ids) @Project = findById:sinon.stub().callsArgWith(2, null, project) @docstoreUrl = "docstore.sharelatex.env" @request = sinon.stub().returns(pipe:->) @@ -38,6 +41,7 @@ describe 'TpdsUpdateSender', -> "logger-sharelatex":{log:->} '../../models/Project': Project:@Project 'request':@request + '../Collaborators/CollaboratorsHandler': @CollaboratorsHandler "../../infrastructure/Metrics": inc:-> diff --git a/services/web/test/UnitTests/coffee/Uploads/ArchiveManagerTests.coffee b/services/web/test/UnitTests/coffee/Uploads/ArchiveManagerTests.coffee index 89b14c78c9..eab3d2fcbd 100644 --- a/services/web/test/UnitTests/coffee/Uploads/ArchiveManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Uploads/ArchiveManagerTests.coffee @@ -39,7 +39,7 @@ describe "ArchiveManager", -> describe "successfully", -> beforeEach (done) -> @ArchiveManager.extractZipArchive @source, @destination, done - @process.emit "exit" + @process.emit "close" it "should run unzip", -> @child.spawn.calledWithExactly("unzip", [@source, "-d", @destination]).should.equal true @@ -56,7 +56,7 @@ describe "ArchiveManager", -> @callback(error) done() @process.stderr.emit "data", "Something went wrong" - @process.emit "exit" + @process.emit "close" it "should return the callback with an error", -> @callback.calledWithExactly(new Error("Something went wrong")).should.equal true @@ -99,35 +99,35 @@ describe "ArchiveManager", -> isTooLarge.should.equal false done() @process.stdout.emit "data", @output("109042") - @process.emit "exit" + @process.emit "close" it "should return true with large bytes", (done)-> @ArchiveManager._isZipTooLarge @source, (error, isTooLarge) => isTooLarge.should.equal true done() @process.stdout.emit "data", @output("1090000000000000042") - @process.emit "exit" + @process.emit "close" it "should return error on no data", (done)-> @ArchiveManager._isZipTooLarge @source, (error, isTooLarge) => expect(error).to.exist done() @process.stdout.emit "data", "" - @process.emit "exit" + @process.emit "close" it "should return error if it didn't get a number", (done)-> @ArchiveManager._isZipTooLarge @source, (error, isTooLarge) => expect(error).to.exist done() @process.stdout.emit "data", @output("total_size_string") - @process.emit "exit" + @process.emit "close" it "should return error if the is only a bit of data", (done)-> @ArchiveManager._isZipTooLarge @source, (error, isTooLarge) => expect(error).to.exist done() @process.stdout.emit "data", " Length Date Time Name \n--------" - @process.emit "exit" + @process.emit "close" describe "findTopLevelDirectory", -> beforeEach -> diff --git a/services/web/test/UnitTests/coffee/helpers/MockResponse.coffee b/services/web/test/UnitTests/coffee/helpers/MockResponse.coffee index a7182c9df0..5ed7c58522 100644 --- a/services/web/test/UnitTests/coffee/helpers/MockResponse.coffee +++ b/services/web/test/UnitTests/coffee/helpers/MockResponse.coffee @@ -63,6 +63,10 @@ class MockResponse @body = body if body @callback() if @callback? + status: (@statusCode)-> + return @ + + setHeader: (header, value) -> @headers[header] = value diff --git a/services/web/test/acceptance/coffee/AuthorizationTests.coffee b/services/web/test/acceptance/coffee/AuthorizationTests.coffee new file mode 100644 index 0000000000..c6678656b9 --- /dev/null +++ b/services/web/test/acceptance/coffee/AuthorizationTests.coffee @@ -0,0 +1,306 @@ +expect = require("chai").expect +async = require("async") +User = require "./helpers/User" +request = require "./helpers/request" +settings = require "settings-sharelatex" + +try_read_access = (user, project_id, test, callback) -> + async.series [ + (cb) -> + user.request.get "/project/#{project_id}", (error, response, body) -> + return cb(error) if error? + test(response, body) + cb() + (cb) -> + user.request.get "/project/#{project_id}/download/zip", (error, response, body) -> + return cb(error) if error? + test(response, body) + cb() + ], callback + +try_settings_write_access = (user, project_id, test, callback) -> + async.series [ + (cb) -> + user.request.post { + uri: "/project/#{project_id}/settings" + json: + compiler: "latex" + }, (error, response, body) -> + return cb(error) if error? + test(response, body) + cb() + ], callback + +try_admin_access = (user, project_id, test, callback) -> + async.series [ + (cb) -> + user.request.post { + uri: "/project/#{project_id}/rename" + json: + newProjectName: "new-name" + }, (error, response, body) -> + return cb(error) if error? + test(response, body) + cb() + (cb) -> + user.request.post { + uri: "/project/#{project_id}/settings/admin" + json: + publicAccessLevel: "private" + }, (error, response, body) -> + return cb(error) if error? + test(response, body) + cb() + ], callback + +try_content_access = (user, project_id, test, callback) -> + # The real-time service calls this end point to determine the user's + # permissions. + if user.id? + user_id = user.id + else + user_id = "anonymous-user" + request.post { + url: "/project/#{project_id}/join" + qs: {user_id} + auth: + user: settings.apis.web.user + pass: settings.apis.web.pass + sendImmediately: true + json: true + jar: false + }, (error, response, body) -> + return callback(error) if error? + test(response, body) + callback() + +expect_read_access = (user, project_id, callback) -> + async.series [ + (cb) -> + try_read_access(user, project_id, (response, body) -> + expect(response.statusCode).to.be.oneOf [200, 204] + , cb) + (cb) -> + try_content_access(user, project_id, (response, body) -> + expect(body.privilegeLevel).to.be.oneOf ["owner", "readAndWrite", "readOnly"] + , cb) + ], callback + +expect_content_write_access = (user, project_id, callback) -> + try_content_access(user, project_id, (response, body) -> + expect(body.privilegeLevel).to.be.oneOf ["owner", "readAndWrite"] + , callback) + +expect_settings_write_access = (user, project_id, callback) -> + try_settings_write_access(user, project_id, (response, body) -> + expect(response.statusCode).to.be.oneOf [200, 204] + , callback) + +expect_admin_access = (user, project_id, callback) -> + try_admin_access(user, project_id, (response, body) -> + expect(response.statusCode).to.be.oneOf [200, 204] + , callback) + +expect_no_read_access = (user, project_id, options, callback) -> + async.series [ + (cb) -> + try_read_access(user, project_id, (response, body) -> + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.match new RegExp(options.redirect_to) + , cb) + (cb) -> + try_content_access(user, project_id, (response, body) -> + expect(body.privilegeLevel).to.be.equal false + , cb) + ], callback + +expect_no_content_write_access = (user, project_id, callback) -> + try_content_access(user, project_id, (response, body) -> + expect(body.privilegeLevel).to.be.oneOf [false, "readOnly"] + , callback) + +expect_no_settings_write_access = (user, project_id, options, callback) -> + try_settings_write_access(user, project_id, (response, body) -> + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.match new RegExp(options.redirect_to) + , callback) + +expect_no_admin_access = (user, project_id, options, callback) -> + try_admin_access(user, project_id, (response, body) -> + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.match new RegExp(options.redirect_to) + , callback) + +describe "Authorization", -> + before (done) -> + @timeout(10000) + @owner = new User() + @other1 = new User() + @other2 = new User() + @anon = new User() + @site_admin = new User({email: "admin@example.com"}) + async.parallel [ + (cb) => @owner.login cb + (cb) => @other1.login cb + (cb) => @other2.login cb + (cb) => @anon.getCsrfToken cb + (cb) => + @site_admin.login (err) => + return cb(err) if error? + @site_admin.ensure_admin cb + ], done + + describe "private project", -> + before (done) -> + @owner.createProject "private-project", (error, project_id) => + return done(error) if error? + @project_id = project_id + done() + + it "should allow the owner read access to it", (done) -> + expect_read_access @owner, @project_id, done + + it "should allow the owner write access to its content", (done) -> + expect_content_write_access @owner, @project_id, done + + it "should allow the owner write access to its settings", (done) -> + expect_settings_write_access @owner, @project_id, done + + it "should allow the owner admin access to it", (done) -> + expect_admin_access @owner, @project_id, done + + it "should not allow another user read access to the project", (done) -> + expect_no_read_access @other1, @project_id, redirect_to: "/restricted", done + + it "should not allow another user write access to its content", (done) -> + expect_no_content_write_access @other1, @project_id, done + + it "should not allow another user write access to its settings", (done) -> + expect_no_settings_write_access @other1, @project_id, redirect_to: "/restricted", done + + it "should not allow another user admin access to it", (done) -> + expect_no_admin_access @other1, @project_id, redirect_to: "/restricted", done + + it "should not allow anonymous user read access to it", (done) -> + expect_no_read_access @anon, @project_id, redirect_to: "/restricted", done + + it "should not allow anonymous user write access to its content", (done) -> + expect_no_content_write_access @anon, @project_id, done + + it "should not allow anonymous user write access to its settings", (done) -> + expect_no_settings_write_access @anon, @project_id, redirect_to: "/restricted", done + + it "should not allow anonymous user admin access to it", (done) -> + expect_no_admin_access @anon, @project_id, redirect_to: "/restricted", done + + it "should allow site admin users read access to it", (done) -> + expect_read_access @site_admin, @project_id, done + + it "should allow site admin users write access to its content", (done) -> + expect_content_write_access @site_admin, @project_id, done + + it "should allow site admin users write access to its settings", (done) -> + expect_settings_write_access @site_admin, @project_id, done + + it "should allow site admin users admin access to it", (done) -> + expect_admin_access @site_admin, @project_id, done + + + describe "shared project", -> + before (done) -> + @rw_user = @other1 + @ro_user = @other2 + @owner.createProject "private-project", (error, project_id) => + return done(error) if error? + @project_id = project_id + @owner.addUserToProject @project_id, @ro_user.email, "readOnly", (error) => + return done(error) if error? + @owner.addUserToProject @project_id, @rw_user.email, "readAndWrite", (error) => + return done(error) if error? + done() + + it "should allow the read-only user read access to it", (done) -> + expect_read_access @ro_user, @project_id, done + + it "should not allow the read-only user write access to its content", (done) -> + expect_no_content_write_access @ro_user, @project_id, done + + it "should not allow the read-only user write access to its settings", (done) -> + expect_no_settings_write_access @ro_user, @project_id, redirect_to: "/restricted", done + + it "should not allow the read-only user admin access to it", (done) -> + expect_no_admin_access @ro_user, @project_id, redirect_to: "/restricted", done + + it "should allow the read-write user read access to it", (done) -> + expect_read_access @rw_user, @project_id, done + + it "should allow the read-write user write access to its content", (done) -> + expect_content_write_access @rw_user, @project_id, done + + it "should allow the read-write user write access to its settings", (done) -> + expect_settings_write_access @rw_user, @project_id, done + + it "should not allow the read-write user admin access to it", (done) -> + expect_no_admin_access @rw_user, @project_id, redirect_to: "/restricted", done + + describe "public read-write project", -> + before (done) -> + @owner.createProject "public-rw-project", (error, project_id) => + return done(error) if error? + @project_id = project_id + @owner.makePublic @project_id, "readAndWrite", done + + it "should allow a user read access to it", (done) -> + expect_read_access @other1, @project_id, done + + it "should allow a user write access to its content", (done) -> + expect_content_write_access @other1, @project_id, done + + it "should not allow a user write access to its settings", (done) -> + expect_no_settings_write_access @other1, @project_id, redirect_to: "/restricted", done + + it "should not allow a user admin access to it", (done) -> + expect_no_admin_access @other1, @project_id, redirect_to: "/restricted", done + + it "should allow an anonymous user read access to it", (done) -> + expect_read_access @anon, @project_id, done + + it "should allow an anonymous user write access to its content", (done) -> + expect_content_write_access @anon, @project_id, done + + it "should not allow an anonymous user write access to its settings", (done) -> + expect_no_settings_write_access @anon, @project_id, redirect_to: "/restricted", done + + it "should not allow an anonymous user admin access to it", (done) -> + expect_no_admin_access @anon, @project_id, redirect_to: "/restricted", done + + describe "public read-only project", -> + before (done) -> + @owner.createProject "public-ro-project", (error, project_id) => + return done(error) if error? + @project_id = project_id + @owner.makePublic @project_id, "readOnly", done + + it "should allow a user read access to it", (done) -> + expect_read_access @other1, @project_id, done + + it "should not allow a user write access to its content", (done) -> + expect_no_content_write_access @other1, @project_id, done + + it "should not allow a user write access to its settings", (done) -> + expect_no_settings_write_access @other1, @project_id, redirect_to: "/restricted", done + + it "should not allow a user admin access to it", (done) -> + expect_no_admin_access @other1, @project_id, redirect_to: "/restricted", done + + it "should allow an anonymous user read access to it", (done) -> + expect_read_access @anon, @project_id, done + + it "should not allow an anonymous user write access to its content", (done) -> + expect_no_content_write_access @anon, @project_id, done + + it "should not allow an anonymous user write access to its settings", (done) -> + expect_no_settings_write_access @anon, @project_id, redirect_to: "/restricted", done + + it "should not allow an anonymous user admin access to it", (done) -> + expect_no_admin_access @anon, @project_id, redirect_to: "/restricted", done \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/ProjectCRUDTests.coffee b/services/web/test/acceptance/coffee/ProjectCRUDTests.coffee new file mode 100644 index 0000000000..19e5f445f7 --- /dev/null +++ b/services/web/test/acceptance/coffee/ProjectCRUDTests.coffee @@ -0,0 +1,21 @@ +expect = require("chai").expect +async = require("async") +User = require "./helpers/User" + +describe "Project CRUD", -> + before (done) -> + @user = new User() + @user.login done + + describe "when project doesn't exist", -> + it "should return 404", (done) -> + @user.request.get "/project/aaaaaaaaaaaaaaaaaaaaaaaa", (err, res, body) -> + expect(res.statusCode).to.equal 404 + done() + + describe "when project has malformed id", -> + it "should return 404", (done) -> + @user.request.get "/project/blah", (err, res, body) -> + expect(res.statusCode).to.equal 404 + done() + \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee new file mode 100644 index 0000000000..c13a45499d --- /dev/null +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -0,0 +1,77 @@ +request = require("./request") +settings = require("settings-sharelatex") +{db, ObjectId} = require("../../../../app/js/infrastructure/mongojs") + +count = 0 + +class User + constructor: (options = {}) -> + @email = "acceptance-test-#{count}@example.com" + @password = "acceptance-test-#{count}-password" + count++ + @jar = request.jar() + @request = request.defaults({ + jar: @jar + }) + + login: (callback = (error) ->) -> + @getCsrfToken (error) => + return callback(error) if error? + @request.post { + url: "/register" # Register will log in, but also ensure user exists + 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() + 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", + json: + projectName: name + }, (error, response, body) -> + return callback(error) if error? + if !body?.project_id? + console.error "SOMETHING WENT WRONG CREATING PROJECT", response.statusCode, response.headers["location"], body + callback(null, body.project_id) + + addUserToProject: (project_id, email, privileges, callback = (error, user) ->) -> + @request.post { + url: "/project/#{project_id}/users", + json: {email, privileges} + }, (error, response, body) -> + return callback(error) if error? + callback(null, body.user) + + makePublic: (project_id, level, callback = (error) ->) -> + @request.post { + url: "/project/#{project_id}/settings/admin", + json: + publicAccessLevel: level + }, (error, response, body) -> + return callback(error) if error? + callback(null) + + getCsrfToken: (callback = (error) ->) -> + @request.get { + url: "/register" + }, (err, response, body) => + return callback(error) if error? + csrfMatches = body.match("window.csrfToken = \"(.*?)\";") + if !csrfMatches? + return callback(new Error("no csrf token found")) + @request = @request.defaults({ + headers: + "x-csrf-token": csrfMatches[1] + }) + callback() + +module.exports = User \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/helpers/request.coffee b/services/web/test/acceptance/coffee/helpers/request.coffee new file mode 100644 index 0000000000..879acd843a --- /dev/null +++ b/services/web/test/acceptance/coffee/helpers/request.coffee @@ -0,0 +1,5 @@ +BASE_URL = "http://localhost:3000" +module.exports = require("request").defaults({ + baseUrl: BASE_URL, + followRedirect: false +}) \ No newline at end of file