Kaynağa Gözat

Merge branch 'master' into node-4.2

Henry Oswald 10 yıl önce
ebeveyn
işleme
1dd4ea176a
100 değiştirilmiş dosya ile 1982 ekleme ve 1149 silme
  1. 1 0
      services/web/.gitignore
  2. 18 0
      services/web/Gruntfile.coffee
  3. 0 13
      services/web/app.coffee
  4. 6 29
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 0 16
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  6. 73 0
      services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee
  7. 111 0
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  8. 5 0
      services/web/app/coffee/Features/Authorization/PrivilegeLevels.coffee
  9. 4 0
      services/web/app/coffee/Features/Authorization/PublicAccessLevels.coffee
  10. 0 35
      services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee
  11. 68 3
      services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee
  12. 3 4
      services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee
  13. 64 0
      services/web/app/coffee/Features/Compile/ClsiCookieManager.coffee
  14. 60 33
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  15. 67 34
      services/web/app/coffee/Features/Compile/CompileController.coffee
  16. 6 5
      services/web/app/coffee/Features/Compile/CompileManager.coffee
  17. 0 1
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  18. 9 4
      services/web/app/coffee/Features/Editor/EditorController.coffee
  19. 9 5
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  20. 9 9
      services/web/app/coffee/Features/Editor/EditorRouter.coffee
  21. 5 0
      services/web/app/coffee/Features/Email/EmailSender.coffee
  22. 22 2
      services/web/app/coffee/Features/Errors/ErrorController.coffee
  23. 1 2
      services/web/app/coffee/Features/Errors/Errors.coffee
  24. 7 0
      services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee
  25. 22 11
      services/web/app/coffee/Features/Project/ProjectController.coffee
  26. 4 9
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  27. 2 1
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  28. 1 1
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  29. 24 41
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  30. 21 16
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  31. 8 41
      services/web/app/coffee/Features/Project/ProjectGetter.coffee
  32. 11 3
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  33. 1 1
      services/web/app/coffee/Features/Referal/ReferalAllocator.coffee
  34. 33 15
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  35. 0 38
      services/web/app/coffee/Features/Security/AuthorizationManager.coffee
  36. 1 0
      services/web/app/coffee/Features/Spelling/SpellingController.coffee
  37. 2 6
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  38. 22 30
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  39. 12 4
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  40. 4 1
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  41. 33 10
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  42. 6 3
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee
  43. 8 10
      services/web/app/coffee/Features/Uploads/ArchiveManager.coffee
  44. 3 2
      services/web/app/coffee/Features/Uploads/UploadsRouter.coffee
  45. 0 4
      services/web/app/coffee/Features/User/UserInfoController.coffee
  46. 15 13
      services/web/app/coffee/Features/Wiki/WikiController.coffee
  47. 6 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  48. 5 1
      services/web/app/coffee/infrastructure/Server.coffee
  49. 0 194
      services/web/app/coffee/managers/SecurityManager.coffee
  50. 1 28
      services/web/app/coffee/models/Project.coffee
  51. 23 0
      services/web/app/coffee/models/ProjectInvite.coffee
  52. 4 26
      services/web/app/coffee/models/User.coffee
  53. 61 45
      services/web/app/coffee/router.coffee
  54. 30 0
      services/web/app/views/contact-us-modal.jade
  55. 21 0
      services/web/app/views/general/500.jade
  56. 8 12
      services/web/app/views/layout.jade
  57. 26 1
      services/web/app/views/project/editor/binary-file.jade
  58. 6 2
      services/web/app/views/project/editor/editor.jade
  59. 17 18
      services/web/app/views/project/editor/file-tree.jade
  60. 8 8
      services/web/app/views/project/editor/left-menu.jade
  61. 95 88
      services/web/app/views/project/editor/pdf.jade
  62. 4 2
      services/web/app/views/project/list/modals.jade
  63. 9 9
      services/web/app/views/project/list/project-list.jade
  64. 19 4
      services/web/app/views/subscriptions/group/invite.jade
  65. 1 1
      services/web/app/views/subscriptions/new.jade
  66. 58 4
      services/web/app/views/wiki/page.jade
  67. 9 4
      services/web/config/settings.defaults.coffee
  68. 6 1
      services/web/package.json
  69. 0 6
      services/web/public/coffee/analytics/AbTestingManager.coffee
  70. 4 0
      services/web/public/coffee/base.coffee
  71. 16 0
      services/web/public/coffee/ide.coffee
  72. 9 1
      services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee
  73. 65 2
      services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
  74. 13 0
      services/web/public/coffee/ide/connection/ConnectionManager.coffee
  75. 32 7
      services/web/public/coffee/ide/editor/Document.coffee
  76. 3 0
      services/web/public/coffee/ide/editor/EditorManager.coffee
  77. 20 6
      services/web/public/coffee/ide/editor/ShareJsDoc.coffee
  78. 14 13
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  79. 159 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SnippetManager.coffee
  80. 0 100
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee
  81. 13 2
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee
  82. 7 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee
  83. 1 1
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  84. 13 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
  85. 139 64
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  86. 1 1
      services/web/public/coffee/ide/pdfng/directives/pdfRenderer.coffee
  87. 7 6
      services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee
  88. 3 0
      services/web/public/coffee/ide/references/ReferencesManager.coffee
  89. 5 0
      services/web/public/coffee/ide/settings/services/settings.coffee
  90. 2 2
      services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
  91. 6 2
      services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee
  92. 1 0
      services/web/public/coffee/libs.coffee
  93. 5 1
      services/web/public/coffee/main.coffee
  94. 66 0
      services/web/public/coffee/main/contact-us.coffee
  95. 92 0
      services/web/public/coffee/main/learn.coffee
  96. 3 3
      services/web/public/coffee/main/project-list/modal-controllers.coffee
  97. 22 3
      services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee
  98. 62 0
      services/web/public/coffee/main/templates.coffee
  99. 0 26
      services/web/public/coffee/main/universties-site.coffee
  100. 1 0
      services/web/public/coffee/main/user-details.coffee

+ 1 - 0
services/web/.gitignore

@@ -40,6 +40,7 @@ app.js
 app/js/*
 app/js/*
 test/UnitTests/js/*
 test/UnitTests/js/*
 test/smoke/js/*
 test/smoke/js/*
+test/acceptance/js/*
 cookies.txt
 cookies.txt
 requestQueueWorker.js
 requestQueueWorker.js
 TpdsWorker.js
 TpdsWorker.js

+ 18 - 0
services/web/Gruntfile.coffee

@@ -71,6 +71,14 @@ module.exports = (grunt) ->
 				dest: 'test/UnitTests/js/',
 				dest: 'test/UnitTests/js/',
 				ext: '.js'
 				ext: '.js'
 
 
+			acceptance_tests: 
+				expand: true,
+				flatten: false,
+				cwd: 'test/acceptance/coffee',
+				src: ['**/*.coffee'],
+				dest: 'test/acceptance/js/',
+				ext: '.js'
+
 		less:
 		less:
 			app:
 			app:
 				files:
 				files:
@@ -119,6 +127,7 @@ module.exports = (grunt) ->
 		clean:
 		clean:
 			app: ["app/js"]
 			app: ["app/js"]
 			unit_tests: ["test/UnitTests/js"]
 			unit_tests: ["test/UnitTests/js"]
+			acceptance_tests: ["test/acceptance/js"]
 
 
 		mochaTest:
 		mochaTest:
 			unit:
 			unit:
@@ -131,6 +140,12 @@ module.exports = (grunt) ->
 				options:
 				options:
 					reporter: grunt.option('reporter') or 'spec'
 					reporter: grunt.option('reporter') or 'spec'
 					grep: grunt.option("grep")
 					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":
 		"git-rev-parse":
 			version:
 			version:
@@ -184,6 +199,7 @@ module.exports = (grunt) ->
 		            	]
 		            	]
 		            	"Test tasks": [
 		            	"Test tasks": [
 		            		"test:unit"
 		            		"test:unit"
+		            		"test:acceptance"
 		            	]
 		            	]
 		            	"Run tasks": [
 		            	"Run tasks": [
 		            		"run"
 		            		"run"
@@ -290,6 +306,7 @@ module.exports = (grunt) ->
 	grunt.registerTask 'compile:css', 'Compile the less files to css', ['less']
 	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: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: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: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: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']
 	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 'install', "Compile everything when installing as an npm module", ['compile']
 
 
 	grunt.registerTask 'test:unit', 'Run the unit tests (use --grep=<regex> or --feature=<feature> for individual tests)', ['compile:server', 'compile:modules:server', 'compile:unit_tests', 'compile:modules:unit_tests', 'mochaTest:unit'].concat(moduleUnitTestTasks)
 	grunt.registerTask 'test:unit', 'Run the unit tests (use --grep=<regex> or --feature=<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=<regex> or --feature=<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: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)
 	grunt.registerTask 'test:modules:unit', 'Run the unit tests for the modules', ['compile:modules:server', 'compile:modules:unit_tests'].concat(moduleUnitTestTasks)

+ 0 - 13
services/web/app.coffee

@@ -10,7 +10,6 @@ metrics = require("metrics-sharelatex")
 metrics.initialize("web")
 metrics.initialize("web")
 metrics.memory.monitor(logger)
 metrics.memory.monitor(logger)
 Server = require("./app/js/infrastructure/Server")
 Server = require("./app/js/infrastructure/Server")
-Errors = require "./app/js/errors"
 
 
 argv = require("optimist")
 argv = require("optimist")
 	.options("user", {alias : "u", description : "Run the server with permissions of the specified user"})
 	.options("user", {alias : "u", description : "Run the server with permissions of the specified user"})
@@ -18,18 +17,6 @@ argv = require("optimist")
 	.usage("Usage: $0")
 	.usage("Usage: $0")
 	.argv
 	.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
 if Settings.catchErrors
 	process.removeAllListeners "uncaughtException"
 	process.removeAllListeners "uncaughtException"
 	process.on "uncaughtException", (error) ->
 	process.on "uncaughtException", (error) ->

+ 6 - 29
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"),
 						text: req.i18n.translate("email_or_password_wrong_try_again"),
 						type: 'error'
 						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) ->) ->
 	getLoggedInUserId: (req, callback = (error, user_id) ->) ->
 		if req?.session?.user?._id?
 		if req?.session?.user?._id?
 			callback null, req.session.user._id.toString()
 			callback null, req.session.user._id.toString()
 		else
 		else
 			callback null, null
 			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?
 		if req.session?.user?._id?
 			query = 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
 		else
 			return callback null, null
 			return callback null, null
 
 
 		UserGetter.getUser query, callback
 		UserGetter.getUser query, callback
 
 
-	requireLogin: (options = {allow_auth_token: false, load_from_db: false}) ->
+	requireLogin: () ->
 		doRequest = (req, res, next = (error) ->) ->
 		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
 			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
 		return doRequest
 
 

+ 0 - 16
services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee

@@ -35,19 +35,3 @@ module.exports = AuthenticationManager =
 					$unset: password: true
 					$unset: password: true
 				}, callback)
 				}, 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")

+ 73 - 0
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)

+ 111 - 0
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'
+		

+ 5 - 0
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"

+ 4 - 0
services/web/app/coffee/Features/Authorization/PublicAccessLevels.coffee

@@ -0,0 +1,4 @@
+module.exports =
+	READ_ONLY: "readOnly"
+	READ_AND_WRITE: "readAndWrite"
+	PRIVATE: "private"

+ 0 - 35
services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee

@@ -7,15 +7,6 @@ UserGetter = require "../User/UserGetter"
 mimelib = require("mimelib")
 mimelib = require("mimelib")
 
 
 module.exports = CollaboratorsController =
 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) ->
 	addUserToProject: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		LimitationsManager.canAddXCollaborators project_id, 1, (error, allowed) =>
 		LimitationsManager.canAddXCollaborators project_id, 1, (error, allowed) =>
@@ -59,29 +50,3 @@ module.exports = CollaboratorsController =
 			EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
 			EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id)
 			callback()
 			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
-			

+ 68 - 3
services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee

@@ -1,13 +1,77 @@
 UserCreator = require('../User/UserCreator')
 UserCreator = require('../User/UserCreator')
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
-ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 mimelib = require("mimelib")
 mimelib = require("mimelib")
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 ContactManager = require "../Contacts/ContactManager"
 ContactManager = require "../Contacts/ContactManager"
 CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
 CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler"
+async = require "async"
+PrivilegeLevels = require "../Authorization/PrivilegeLevels"
+Errors = require "../Errors/Errors"
 
 
 module.exports = CollaboratorsHandler =
 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) ->)->
 	removeUserFromProject: (project_id, user_id, callback = (error) ->)->
 		logger.log user_id: user_id, project_id: project_id, "removing user"
 		logger.log user_id: user_id, project_id: project_id, "removing user"
 		conditions = _id:project_id
 		conditions = _id:project_id
@@ -38,10 +102,10 @@ module.exports = CollaboratorsHandler =
 			if existing_users.indexOf(user_id.toString()) > -1
 			if existing_users.indexOf(user_id.toString()) > -1
 				return callback null # User already in Project
 				return callback null # User already in Project
 				
 				
-			if privilegeLevel == 'readAndWrite'
+			if privilegeLevel == PrivilegeLevels.READ_AND_WRITE
 				level = {"collaberator_refs":user_id}
 				level = {"collaberator_refs":user_id}
 				logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user"
 				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}
 				level = {"readOnly_refs":user_id}
 				logger.log {privileges: "readOnly", user_id, project_id}, "adding user"
 				logger.log {privileges: "readOnly", user_id, project_id}, "adding user"
 			else
 			else
@@ -57,6 +121,7 @@ module.exports = CollaboratorsHandler =
 			Project.update { _id: project_id }, { $addToSet: level }, (error) ->
 			Project.update { _id: project_id }, { $addToSet: level }, (error) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				# Flush to TPDS in background to add files to collaborator's Dropbox
 				# Flush to TPDS in background to add files to collaborator's Dropbox
+				ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 				ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, (error) ->
 				ProjectEntityHandler.flushProjectToThirdPartyDataStore project_id, (error) ->
 					if error?
 					if error?
 						logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator"
 						logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator"

+ 3 - 4
services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee

@@ -1,11 +1,10 @@
 CollaboratorsController = require('./CollaboratorsController')
 CollaboratorsController = require('./CollaboratorsController')
-SecurityManager = require('../../managers/SecurityManager')
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')
+AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear')
 
 
 module.exports =
 module.exports =
 	apply: (webRouter, apiRouter) ->
 	apply: (webRouter, apiRouter) ->
 		webRouter.post '/project/:Project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject
 		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

+ 64 - 0
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)
+
+

+ 60 - 33
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -5,39 +5,63 @@ request = require('request')
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
 ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 ProjectEntityHandler = require("../Project/ProjectEntityHandler")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
-url = require("url")
+Url = require("url")
+ClsiCookieManager = require("./ClsiCookieManager")
+
 
 
 module.exports = ClsiManager =
 module.exports = ClsiManager =
+
 	sendRequest: (project_id, options = {}, callback = (error, success) ->) ->
 	sendRequest: (project_id, options = {}, callback = (error, success) ->) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			logger.log project_id: project_id, "sending compile to CLSI"
 			logger.log project_id: project_id, "sending compile to CLSI"
 			ClsiManager._postToClsi project_id, req, options.compileGroup, (error, response) ->
 			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) ->) ->
 	deleteAuxFiles: (project_id, options, callback = (error) ->) ->
 		compilerUrl = @_getCompilerUrl(options?.compileGroup)
 		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) ->
 	_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) ->) ->
 	_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"
 			url:  "#{compilerUrl}/project/#{project_id}/compile"
 			json: req
 			json: req
-			jar:  false
-		}, (error, response, body) ->
+			method: "POST"
+		ClsiManager._makeRequest project_id, opts, (error, response, body) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if 200 <= response.statusCode < 300
 			if 200 <= response.statusCode < 300
 				callback null, body
 				callback null, body
@@ -51,8 +75,10 @@ module.exports = ClsiManager =
 	_parseOutputFiles: (project_id, rawOutputFiles = []) ->
 	_parseOutputFiles: (project_id, rawOutputFiles = []) ->
 		outputFiles = []
 		outputFiles = []
 		for file in rawOutputFiles
 		for file in rawOutputFiles
+			path = Url.parse(file.url).path
+			path = path.replace("/project/#{project_id}/output/", "")
 			outputFiles.push
 			outputFiles.push
-				path: url.parse(file.url).path.replace("/project/#{project_id}/output/", "")
+				path: path
 				type: file.type
 				type: file.type
 				build: file.build
 				build: file.build
 		return outputFiles
 		return outputFiles
@@ -86,6 +112,9 @@ module.exports = ClsiManager =
 							rootResourcePathOverride = path
 							rootResourcePathOverride = path
 
 
 					rootResourcePath = rootResourcePathOverride if rootResourcePathOverride?
 					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
 					for path, file of files
 						path = path.replace(/^\//, "") # Remove leading /
 						path = path.replace(/^\//, "") # Remove leading /
@@ -94,19 +123,16 @@ module.exports = ClsiManager =
 							url:      "#{Settings.apis.filestore.url}/project/#{project._id}/file/#{file._id}"
 							url:      "#{Settings.apis.filestore.url}/project/#{project._id}/file/#{file._id}"
 							modified: file.created?.getTime()
 							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) ->) ->
 	wordCount: (project_id, file, options, callback = (error, response) ->) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
@@ -115,9 +141,10 @@ module.exports = ClsiManager =
 			wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
 			wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
 			if req.compile.options.imageName?
 			if req.compile.options.imageName?
 				wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
 				wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
-			request.get {
+			opts =
 				url: wordcount_url
 				url: wordcount_url
-			}, (error, response, body) ->
+				method: "GET"
+			ClsiManager._makeRequest project_id, opts, (error, response, body) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if 200 <= response.statusCode < 300
 				if 200 <= response.statusCode < 300
 					callback null, body
 					callback null, body

+ 67 - 34
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -8,6 +8,8 @@ Settings = require "settings-sharelatex"
 AuthenticationController = require "../Authentication/AuthenticationController"
 AuthenticationController = require "../Authentication/AuthenticationController"
 UserGetter = require "../User/UserGetter"
 UserGetter = require "../User/UserGetter"
 RateLimiter = require("../../infrastructure/RateLimiter")
 RateLimiter = require("../../infrastructure/RateLimiter")
+ClsiCookieManager = require("./ClsiCookieManager")
+Path = require("path")
 
 
 module.exports = CompileController =
 module.exports = CompileController =
 	compile: (req, res, next = (error) ->) ->
 	compile: (req, res, next = (error) ->) ->
@@ -28,27 +30,23 @@ module.exports = CompileController =
 			if req.body?.draft
 			if req.body?.draft
 				options.draft = req.body.draft
 				options.draft = req.body.draft
 			logger.log {options, project_id}, "got compile request"
 			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?
 				return next(error) if error?
 				res.contentType("application/json")
 				res.contentType("application/json")
-				res.send 200, JSON.stringify {
+				res.status(200).send JSON.stringify {
 					status: status
 					status: status
 					outputFiles: outputFiles
 					outputFiles: outputFiles
 					compileGroup: limits?.compileGroup
 					compileGroup: limits?.compileGroup
+					clsiServerId:clsiServerId
 				}
 				}
 
 
 	downloadPdf: (req, res, next = (error) ->)->
 	downloadPdf: (req, res, next = (error) ->)->
-	
 		Metrics.inc "pdf-downloads"
 		Metrics.inc "pdf-downloads"
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		isPdfjsPartialDownload = req.query?.pdfng
 		isPdfjsPartialDownload = req.query?.pdfng
-
-
-
 		rateLimit = (callback)->
 		rateLimit = (callback)->
 			if isPdfjsPartialDownload
 			if isPdfjsPartialDownload
 				callback null, true
 				callback null, true
-	
 			else
 			else
 				rateLimitOpts =
 				rateLimitOpts =
 					endpointName: "full-pdf-download"
 					endpointName: "full-pdf-download"
@@ -93,10 +91,36 @@ module.exports = CompileController =
 
 
 	getFileFromClsi: (req, res, next = (error) ->) ->
 	getFileFromClsi: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		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)
+
+	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)
 
 
-	proxySync: (req, res, next = (error) ->) ->
-		CompileController.proxyToClsi(req.params.Project_id, req.url, 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) ->) ->
 	proxyToClsi: (project_id, url, req, res, next = (error) ->) ->
 		if req.query?.compileGroup
 		if req.query?.compileGroup
@@ -107,30 +131,39 @@ module.exports = CompileController =
 				CompileController.proxyToClsiWithLimits(project_id, url, limits, req, res, next)
 				CompileController.proxyToClsiWithLimits(project_id, url, limits, req, res, next)
 
 
 	proxyToClsiWithLimits: (project_id, url, limits, req, res, next = (error) ->) ->
 	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) ->
 	wordCount: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id

+ 6 - 5
services/web/app/coffee/Features/Compile/CompileManager.coffee

@@ -1,8 +1,6 @@
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
-
 redis = require("redis-sharelatex")
 redis = require("redis-sharelatex")
 rclient = redis.createClient(Settings.redis.web)
 rclient = redis.createClient(Settings.redis.web)
-
 DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
 DocumentUpdaterHandler = require "../DocumentUpdater/DocumentUpdaterHandler"
 Project = require("../../models/Project").Project
 Project = require("../../models/Project").Project
 ProjectRootDocManager = require "../Project/ProjectRootDocManager"
 ProjectRootDocManager = require "../Project/ProjectRootDocManager"
@@ -13,6 +11,8 @@ logger = require("logger-sharelatex")
 rateLimiter = require("../../infrastructure/RateLimiter")
 rateLimiter = require("../../infrastructure/RateLimiter")
 
 
 module.exports = CompileManager =
 module.exports = CompileManager =
+
+
 	compile: (project_id, user_id, options = {}, _callback = (error) ->) ->
 	compile: (project_id, user_id, options = {}, _callback = (error) ->) ->
 		timer = new Metrics.Timer("editor.compile")
 		timer = new Metrics.Timer("editor.compile")
 		callback = (args...) ->
 		callback = (args...) ->
@@ -26,7 +26,8 @@ module.exports = CompileManager =
 			CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
 			CompileManager._checkIfRecentlyCompiled project_id, user_id, (error, recentlyCompiled) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if recentlyCompiled
 				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) ->
 				CompileManager._ensureRootDocumentIsSet project_id, (error) ->
 					return callback(error) if error?
 					return callback(error) if error?
@@ -36,10 +37,10 @@ module.exports = CompileManager =
 							return callback(error) if error?
 							return callback(error) if error?
 							for key, value of limits
 							for key, value of limits
 								options[key] = value
 								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?
 								return callback(error) if error?
 								logger.log files: outputFiles, "output files"
 								logger.log files: outputFiles, "output files"
-								callback(null, status, outputFiles, output, limits)
+								callback(null, status, outputFiles, clsiServerId, limits)
 								
 								
 	deleteAuxFiles: (project_id, callback = (error) ->) ->
 	deleteAuxFiles: (project_id, callback = (error) ->) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->
 		CompileManager.getProjectCompileLimits project_id, (error, limits) ->

+ 0 - 1
services/web/app/coffee/Features/Documents/DocumentController.coffee

@@ -1,5 +1,4 @@
 ProjectEntityHandler = require "../Project/ProjectEntityHandler"
 ProjectEntityHandler = require "../Project/ProjectEntityHandler"
-Errors = require "../../errors"
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
 module.exports =
 module.exports =

+ 9 - 4
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -164,12 +164,17 @@ module.exports = EditorController =
 
 
 	moveEntity: (project_id, entity_id, folder_id, entityType, callback)->
 	moveEntity: (project_id, entity_id, folder_id, entityType, callback)->
 		Metrics.inc "editor.move-entity"
 		Metrics.inc "editor.move-entity"
-		ProjectEntityHandler.moveEntity project_id, entity_id, folder_id, entityType, =>
+		LockManager.getLock project_id, (err)->
 			if 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)
 				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) ->) ->
 	renameProject: (project_id, newName, callback = (err) ->) ->
 		ProjectDetailsHandler.renameProject project_id, newName, ->
 		ProjectDetailsHandler.renameProject project_id, newName, ->

+ 9 - 5
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -5,14 +5,18 @@ EditorRealTimeController = require "./EditorRealTimeController"
 EditorController = require "./EditorController"
 EditorController = require "./EditorController"
 ProjectGetter = require('../Project/ProjectGetter')
 ProjectGetter = require('../Project/ProjectGetter')
 UserGetter = require('../User/UserGetter')
 UserGetter = require('../User/UserGetter')
-AuthorizationManager = require("../Security/AuthorizationManager")
+AuthorizationManager = require("../Authorization/AuthorizationManager")
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 Metrics = require('../../infrastructure/Metrics')
 Metrics = require('../../infrastructure/Metrics')
+CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
+PrivilegeLevels = require "../Authorization/PrivilegeLevels"
 
 
 module.exports = EditorHttpController =
 module.exports = EditorHttpController =
 	joinProject: (req, res, next) ->
 	joinProject: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		user_id = req.query.user_id
 		user_id = req.query.user_id
+		if user_id == "anonymous-user"
+			user_id = null
 		logger.log {user_id, project_id}, "join project request"
 		logger.log {user_id, project_id}, "join project request"
 		Metrics.inc "editor.join-project"
 		Metrics.inc "editor.join-project"
 		EditorHttpController._buildJoinProjectView project_id, user_id, (error, project, privilegeLevel) ->
 		EditorHttpController._buildJoinProjectView project_id, user_id, (error, project, privilegeLevel) ->
@@ -29,17 +33,17 @@ module.exports = EditorHttpController =
 		ProjectGetter.getProjectWithoutDocLines project_id, (error, project) ->
 		ProjectGetter.getProjectWithoutDocLines project_id, (error, project) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			return callback(new Error("not found")) if !project?
 			return callback(new Error("not found")) if !project?
-			ProjectGetter.populateProjectWithUsers project, (error, project) ->
+			CollaboratorsHandler.getMembersWithPrivilegeLevels project, (error, members) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				UserGetter.getUser user_id, { isAdmin: true }, (error, user) ->
 				UserGetter.getUser user_id, { isAdmin: true }, (error, user) ->
 					return callback(error) if error?
 					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?
 						return callback(error) if error?
-						if !canAccess
+						if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE
 							callback null, null, false
 							callback null, null, false
 						else
 						else
 							callback(null,
 							callback(null,
-								ProjectEditorHandler.buildProjectModelView(project),
+								ProjectEditorHandler.buildProjectModelView(project, members),
 								privilegeLevel
 								privilegeLevel
 							)
 							)
 
 

+ 9 - 9
services/web/app/coffee/Features/Editor/EditorRouter.coffee

@@ -1,20 +1,20 @@
 EditorHttpController = require('./EditorHttpController')
 EditorHttpController = require('./EditorHttpController')
-SecurityManager = require('../../managers/SecurityManager')
 AuthenticationController = require "../Authentication/AuthenticationController"
 AuthenticationController = require "../Authentication/AuthenticationController"
+AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear')
 
 
 module.exports =
 module.exports =
 	apply: (webRouter, apiRouter) ->
 	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.
 		# 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
 		# This is a post request because it's more than just a getting of data. We take actions

+ 5 - 0
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -3,6 +3,8 @@ metrics = require('../../infrastructure/Metrics')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
 nodemailer = require("nodemailer")
 nodemailer = require("nodemailer")
 sesTransport = require('nodemailer-ses-transport')
 sesTransport = require('nodemailer-ses-transport')
+sgTransport = require('nodemailer-sendgrid-transport')
+
 _ = require("underscore")
 _ = require("underscore")
 
 
 if Settings.email? and Settings.email.fromAddress?
 if Settings.email? and Settings.email.fromAddress?
@@ -19,6 +21,9 @@ client =
 if Settings?.email?.parameters?.AWSAccessKeyID?
 if Settings?.email?.parameters?.AWSAccessKeyID?
 	logger.log "using aws ses for email"
 	logger.log "using aws ses for email"
 	nm_client = nodemailer.createTransport(sesTransport(Settings.email.parameters))
 	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?
 else if Settings?.email?.parameters?
 	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
 	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
 
 

+ 22 - 2
services/web/app/coffee/Features/Errors/ErrorController.coffee

@@ -1,5 +1,25 @@
+Errors = require "./Errors"
+logger = require "logger-sharelatex"
+
 module.exports = ErrorController =
 module.exports = ErrorController =
 	notFound: (req, res)->
 	notFound: (req, res)->
-		res.statusCode = 404
+		res.status(404)
 		res.render 'general/404',
 		res.render 'general/404',
-			title: "page_not_found"
+			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

+ 1 - 2
services/web/app/coffee/errors.coffee → services/web/app/coffee/Features/Errors/Errors.coffee

@@ -6,5 +6,4 @@ NotFoundError = (message) ->
 NotFoundError.prototype.__proto__ = Error.prototype
 NotFoundError.prototype.__proto__ = Error.prototype
 
 
 module.exports = Errors =
 module.exports = Errors =
-	NotFoundError: NotFoundError
-
+	NotFoundError: NotFoundError

+ 7 - 0
services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee

@@ -42,7 +42,14 @@ module.exports = FileStoreHandler =
 			method : "get"
 			method : "get"
 			uri: "#{@_buildUrl(project_id, file_id)}#{queryString}"
 			uri: "#{@_buildUrl(project_id, file_id)}#{queryString}"
 			timeout:fiveMinsInMs
 			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 = request(opts)
+		readStream.on "error", (err) ->
+			logger.err {err, project_id, file_id, query}, "error in file stream"
 		callback(null, readStream)
 		callback(null, readStream)
 
 
 	deleteFile: (project_id, file_id, callback)->
 	deleteFile: (project_id, file_id, callback)->

+ 22 - 11
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -5,7 +5,6 @@ projectDuplicator = require("./ProjectDuplicator")
 projectCreationHandler = require("./ProjectCreationHandler")
 projectCreationHandler = require("./ProjectCreationHandler")
 editorController = require("../Editor/EditorController")
 editorController = require("../Editor/EditorController")
 metrics = require('../../infrastructure/Metrics')
 metrics = require('../../infrastructure/Metrics')
-Project = require('../../models/Project').Project
 User = require('../../models/User').User
 User = require('../../models/User').User
 TagsHandler = require("../Tags/TagsHandler")
 TagsHandler = require("../Tags/TagsHandler")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
@@ -13,10 +12,12 @@ NotificationsHandler = require("../Notifications/NotificationsHandler")
 LimitationsManager = require("../Subscription/LimitationsManager")
 LimitationsManager = require("../Subscription/LimitationsManager")
 _ = require("underscore")
 _ = require("underscore")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
-SecurityManager = require("../../managers/SecurityManager")
+AuthorizationManager = require("../Authorization/AuthorizationManager")
 fs = require "fs"
 fs = require "fs"
 InactiveProjectManager = require("../InactiveData/InactiveProjectManager")
 InactiveProjectManager = require("../InactiveData/InactiveProjectManager")
 ProjectUpdateHandler = require("./ProjectUpdateHandler")
 ProjectUpdateHandler = require("./ProjectUpdateHandler")
+ProjectGetter = require("./ProjectGetter")
+PrivilegeLevels = require("../Authorization/PrivilegeLevels")
 
 
 module.exports = ProjectController =
 module.exports = ProjectController =
 
 
@@ -41,6 +42,14 @@ module.exports = ProjectController =
 			jobs.push (callback) ->
 			jobs.push (callback) ->
 				editorController.setRootDoc project_id, req.body.rootDocId, 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?
 		if req.body.publicAccessLevel?
 			jobs.push (callback) ->
 			jobs.push (callback) ->
 				editorController.setPublicAccessLevel project_id, req.body.publicAccessLevel, callback
 				editorController.setPublicAccessLevel project_id, req.body.publicAccessLevel, callback
@@ -129,7 +138,7 @@ module.exports = ProjectController =
 			notifications: (cb)->
 			notifications: (cb)->
 				NotificationsHandler.getUserNotifications user_id, cb
 				NotificationsHandler.getUserNotifications user_id, cb
 			projects: (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)->
 			hasSubscription: (cb)->
 				LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb
 				LimitationsManager.userHasSubscriptionOrIsGroupMember req.session.user, cb
 			user: (cb) ->
 			user: (cb) ->
@@ -179,23 +188,23 @@ module.exports = ProjectController =
 			anonymous = false
 			anonymous = false
 		else
 		else
 			anonymous = true
 			anonymous = true
-			user_id = 'openUser'
+			user_id = null
 
 
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		logger.log project_id:project_id, "loading editor"
 		logger.log project_id:project_id, "loading editor"
 
 
 		async.parallel {
 		async.parallel {
 			project: (cb)->
 			project: (cb)->
-				Project.findPopulatedById project_id, cb
+				ProjectGetter.getProject project_id, { name: 1, lastUpdated: 1}, cb
 			user: (cb)->
 			user: (cb)->
-				if user_id == 'openUser'
+				if !user_id?
 					cb null, defaultSettingsForAnonymousUser(user_id)
 					cb null, defaultSettingsForAnonymousUser(user_id)
 				else
 				else
 					User.findById user_id, (err, user)->
 					User.findById user_id, (err, user)->
 						logger.log project_id:project_id, user_id:user_id, "got user"
 						logger.log project_id:project_id, user_id:user_id, "got user"
 						cb err, user
 						cb err, user
 			subscription: (cb)->
 			subscription: (cb)->
-				if user_id == 'openUser'
+				if !user_id?
 					return cb()
 					return cb()
 				SubscriptionLocator.getUsersSubscription user_id, cb
 				SubscriptionLocator.getUsersSubscription user_id, cb
 			activate: (cb)->
 			activate: (cb)->
@@ -216,8 +225,9 @@ module.exports = ProjectController =
 			daysSinceLastUpdated =  (new Date() - project.lastUpdated) /86400000
 			daysSinceLastUpdated =  (new Date() - project.lastUpdated) /86400000
 			logger.log project_id:project_id, daysSinceLastUpdated:daysSinceLastUpdated, "got db results for loading editor"
 			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
 					return res.sendStatus 401
 
 
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
@@ -228,10 +238,9 @@ module.exports = ProjectController =
 					title:  project.name
 					title:  project.name
 					priority_title: true
 					priority_title: true
 					bodyClasses: ["editor"]
 					bodyClasses: ["editor"]
-					project : project
 					project_id : project._id
 					project_id : project._id
 					user : {
 					user : {
-						id    : user.id
+						id    : user_id
 						email : user.email
 						email : user.email
 						first_name : user.first_name
 						first_name : user.first_name
 						last_name  : user.last_name
 						last_name  : user.last_name
@@ -239,6 +248,8 @@ module.exports = ProjectController =
 						subscription :
 						subscription :
 							freeTrial: {allowed: allowedFreeTrial}
 							freeTrial: {allowed: allowedFreeTrial}
 						featureSwitches: user.featureSwitches
 						featureSwitches: user.featureSwitches
+						features: user.features
+						refProviders: user.refProviders
 					}
 					}
 					userSettings: {
 					userSettings: {
 						mode  : user.ace.mode
 						mode  : user.ace.mode

+ 4 - 9
services/web/app/coffee/Features/Project/ProjectDeleter.coffee

@@ -5,6 +5,7 @@ documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 tagsHandler = require("../Tags/TagsHandler")
 tagsHandler = require("../Tags/TagsHandler")
 async = require("async")
 async = require("async")
 FileStoreHandler = require("../FileStore/FileStoreHandler")
 FileStoreHandler = require("../FileStore/FileStoreHandler")
+CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 
 
 module.exports = ProjectDeleter =
 module.exports = ProjectDeleter =
 
 
@@ -44,16 +45,10 @@ module.exports = ProjectDeleter =
 					(cb)->
 					(cb)->
 						documentUpdaterHandler.flushProjectToMongoAndDelete project_id, cb
 						documentUpdaterHandler.flushProjectToMongoAndDelete project_id, cb
 					(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() #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)->
 					(cb)->
 						Project.update {_id:project_id}, { $set: { archived: true }}, cb
 						Project.update {_id:project_id}, { $set: { archived: true }}, cb
 				], (err)->
 				], (err)->

+ 2 - 1
services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee

@@ -4,6 +4,7 @@ Project = require('../../models/Project').Project
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 _ = require("underscore")
 _ = require("underscore")
+PublicAccessLevels = require("../Authorization/PublicAccessLevels")
 
 
 module.exports = 
 module.exports = 
 
 
@@ -49,6 +50,6 @@ module.exports =
 
 
 	setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)->
 	setPublicAccessLevel : (project_id, newAccessLevel, callback = ->)->
 		logger.log project_id: project_id, level: newAccessLevel, "set public access level"
 		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)->
 			Project.update {_id:project_id},{publicAccesLevel:newAccessLevel}, (err)->
 				callback()
 				callback()

+ 1 - 1
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -75,7 +75,7 @@ module.exports = ProjectDuplicator =
 				return callback(err)
 				return callback(err)
 			{originalProject, newProject, originalRootDoc, docContentsArray} = results
 			{originalProject, newProject, originalRootDoc, docContentsArray} = results
 
 
-			originalRootDoc = originalRootDoc[0]
+			originalRootDoc = originalRootDoc?[0]
 
 
 			docContents = {}
 			docContents = {}
 			for docContent in docContentsArray
 			for docContent in docContentsArray

+ 24 - 41
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -1,11 +1,7 @@
 _ = require("underscore")
 _ = require("underscore")
 
 
 module.exports = ProjectEditorHandler =
 module.exports = ProjectEditorHandler =
-	buildProjectModelView: (project, options) ->
-		options ||= {}
-		if !options.includeUsers?
-			options.includeUsers = true
-			
+	buildProjectModelView: (project, members) ->
 		result =
 		result =
 			_id        : project._id
 			_id        : project._id
 			name       : project.name
 			name       : project.name
@@ -18,40 +14,27 @@ module.exports = ProjectEditorHandler =
 			spellCheckLanguage: project.spellCheckLanguage
 			spellCheckLanguage: project.spellCheckLanguage
 			deletedByExternalDataSource : project.deletedByExternalDataSource || false
 			deletedByExternalDataSource : project.deletedByExternalDataSource || false
 			deletedDocs: project.deletedDocs
 			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"
+
+		result.features = _.defaults(owner?.features or {}, {
+			collaborators: -1 # Infinite
+			versioning: false
+			dropbox:false
+			compileTimeout: 60
+			compileGroup:"standard"
+			templates: false
+			references: false
+		})
 
 
-		if options.includeUsers
-			result.features =
-				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
 		return result
 
 
 	buildUserModelView: (user, privileges) ->
 	buildUserModelView: (user, privileges) ->
@@ -63,12 +46,12 @@ module.exports = ProjectEditorHandler =
 		signUpDate : user.signUpDate
 		signUpDate : user.signUpDate
 
 
 	buildFolderModelView: (folder) ->
 	buildFolderModelView: (folder) ->
-		fileRefs = _.filter folder.fileRefs, (file)-> file?
+		fileRefs = _.filter (folder.fileRefs or []), (file)-> file?
 		_id        : folder._id
 		_id        : folder._id
 		name       : folder.name
 		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
 		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) ->
 	buildFileModelView: (file) ->
 		_id  : file._id
 		_id  : file._id

+ 21 - 16
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -4,7 +4,7 @@ Doc = require('../../models/Doc').Doc
 Folder = require('../../models/Folder').Folder
 Folder = require('../../models/Folder').Folder
 File = require('../../models/File').File
 File = require('../../models/File').File
 FileStoreHandler = require("../FileStore/FileStoreHandler")
 FileStoreHandler = require("../FileStore/FileStoreHandler")
-Errors = require "../../errors"
+Errors = require "../Errors/Errors"
 tpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
 tpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
 projectLocator = require('./ProjectLocator')
 projectLocator = require('./ProjectLocator')
 path = require "path"
 path = require "path"
@@ -22,7 +22,9 @@ module.exports = ProjectEntityHandler =
 		folders = {}
 		folders = {}
 		processFolder = (basePath, folder) ->
 		processFolder = (basePath, folder) ->
 			folders[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) ->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project) ->
 			return callback(err) if err?
 			return callback(err) if err?
@@ -43,11 +45,11 @@ module.exports = ProjectEntityHandler =
 			for docContent in docContentsArray
 			for docContent in docContentsArray
 				docContents[docContent._id] = docContent
 				docContents[docContent._id] = docContent
 
 
-			ProjectEntityHandler.getAllFolders project_id, (error, folders) ->
+			ProjectEntityHandler.getAllFolders project_id, (error, folders = {}) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				docs = {}
 				docs = {}
 				for folderPath, folder of folders
 				for folderPath, folder of folders
-					for doc in folder.docs
+					for doc in (folder.docs or [])
 						content = docContents[doc._id.toString()]
 						content = docContents[doc._id.toString()]
 						if content?
 						if content?
 							docs[path.join(folderPath, doc.name)] = {
 							docs[path.join(folderPath, doc.name)] = {
@@ -61,11 +63,11 @@ module.exports = ProjectEntityHandler =
 
 
 	getAllFiles: (project_id, callback) ->
 	getAllFiles: (project_id, callback) ->
 		logger.log project_id:project_id, "getting all files for project"
 		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?
 			return callback(err) if err?
 			files = {}
 			files = {}
 			for folderPath, folder of folders
 			for folderPath, folder of folders
-				for file in folder.fileRefs
+				for file in (folder.fileRefs or [])
 					if file?
 					if file?
 						files[path.join(folderPath, file.name)] = file
 						files[path.join(folderPath, file.name)] = file
 			callback null, files
 			callback null, files
@@ -344,15 +346,18 @@ module.exports = ProjectEntityHandler =
 					return callback(error) if error?
 					return callback(error) if error?
 					self._removeElementFromMongoArray Project, project_id, path.mongo, (err)->
 					self._removeElementFromMongoArray Project, project_id, path.mongo, (err)->
 						return callback(err) if 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?
 							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) ->)->
 	deleteEntity: (project_id, entity_id, entityType, callback = (error) ->)->
 		self = @
 		self = @
@@ -501,7 +506,7 @@ module.exports = ProjectEntityHandler =
 				elementType = "fileRefs"
 				elementType = "fileRefs"
 			return elementType
 			return elementType
 
 
-		if !element?
+		if !element? or !element._id?
 			e = new Error("no element passed to be inserted")
 			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"
 			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)
 			return callback(e)
@@ -520,13 +525,13 @@ module.exports = ProjectEntityHandler =
 				newPath =
 				newPath =
 					fileSystem: "#{path.fileSystem}/#{element.name}"
 					fileSystem: "#{path.fileSystem}/#{element.name}"
 					mongo: path.mongo
 					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+''
 				id = element._id+''
 				element._id = require('mongoose').Types.ObjectId(id)
 				element._id = require('mongoose').Types.ObjectId(id)
 				conditions = _id:project._id
 				conditions = _id:project._id
 				mongopath = "#{path.mongo}.#{type}"
 				mongopath = "#{path.mongo}.#{type}"
 				update = "$push":{}
 				update = "$push":{}
 				update["$push"][mongopath] = element
 				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)->
 				Project.update conditions, update, {}, (err)->
 					if err?
 					if err?
 						logger.err err: err, project_id: project._id, 'error saving in putElement project'
 						logger.err err: err, project_id: project._id, 'error saving in putElement project'

+ 8 - 41
services/web/app/coffee/Features/Project/ProjectGetter.coffee

@@ -2,11 +2,9 @@ mongojs = require("../../infrastructure/mongojs")
 db = mongojs.db
 db = mongojs.db
 ObjectId = mongojs.ObjectId
 ObjectId = mongojs.ObjectId
 async = require "async"
 async = require "async"
-Errors = require("../../errors")
+Project = require("../../models/Project").Project
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
-
-
 module.exports = ProjectGetter =
 module.exports = ProjectGetter =
 	EXCLUDE_DEPTH: 8
 	EXCLUDE_DEPTH: 8
 
 
@@ -51,42 +49,11 @@ module.exports = ProjectGetter =
 				return callback(err)
 				return callback(err)
 			callback(null, project?[0])
 			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?
 				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

+ 11 - 3
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -1,9 +1,10 @@
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
 ProjectGetter = require("./ProjectGetter")
 ProjectGetter = require("./ProjectGetter")
-Errors = require "../../errors"
+Errors = require "../Errors/Errors"
 _ = require('underscore')
 _ = require('underscore')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 async = require('async')
 async = require('async')
+ProjectGetter = require "./ProjectGetter"
 
 
 module.exports = ProjectLocator =
 module.exports = ProjectLocator =
 	findElement: (options, _callback = (err, element, path, parentFolder)->)->
 	findElement: (options, _callback = (err, element, path, parentFolder)->)->
@@ -60,7 +61,13 @@ module.exports = ProjectLocator =
 	findRootDoc : (opts, callback)->
 	findRootDoc : (opts, callback)->
 		getRootDoc = (project)=>
 		getRootDoc = (project)=>
 			if project.rootDoc_id?
 			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
 			else
 				callback null, null
 				callback null, null
 		{project, project_id} = opts
 		{project, project_id} = opts
@@ -131,7 +138,8 @@ module.exports = ProjectLocator =
 			async.waterfall jobs, callback
 			async.waterfall jobs, callback
 
 
 	findUsersProjectByName: (user_id, projectName, 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)
 			projects = projects.concat(collabertions)
 			projectName = projectName.toLowerCase()
 			projectName = projectName.toLowerCase()
 			project = _.find projects, (project)->
 			project = _.find projects, (project)->

+ 1 - 1
services/web/app/coffee/Features/Referal/ReferalAllocator.coffee

@@ -35,7 +35,7 @@ module.exports = ReferalAllocator =
 		query = _id: user_id
 		query = _id: user_id
 		User.findOne query, (error, user) ->
 		User.findOne query, (error, user) ->
 			return callback(error) if error
 			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"
 			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
 			if user.refered_user_count? and user.refered_user_count > 0
 				newFeatures = ReferalAllocator._calculateFeatures(user)
 				newFeatures = ReferalAllocator._calculateFeatures(user)

+ 33 - 15
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -1,7 +1,8 @@
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 request = require("request")
 request = require("request")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
-Project = require("../../models/Project").Project
+ProjectGetter = require "../Project/ProjectGetter"
+UserGetter = require "../User/UserGetter"
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 U = require('underscore')
 U = require('underscore')
 Async = require('async')
 Async = require('async')
@@ -15,42 +16,56 @@ module.exports = ReferencesHandler =
 	_buildDocUrl: (projectId, docId) ->
 	_buildDocUrl: (projectId, docId) ->
 		"#{settings.apis.docstore.url}/project/#{projectId}/doc/#{docId}/raw"
 		"#{settings.apis.docstore.url}/project/#{projectId}/doc/#{docId}/raw"
 
 
-	_findBibDocIds: (project) ->
+	_buildFileUrl: (projectId, fileId) ->
+		"#{settings.apis.filestore.url}/project/#{projectId}/file/#{fileId}"
+
+	_findBibFileIds: (project) ->
 		ids = []
 		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) ->
 		_process = (folder) ->
-			folder.docs.forEach (doc) ->
+			(folder.docs or []).forEach (doc) ->
 				if doc?.name?.match(/^.*\.bib$/)
 				if doc?.name?.match(/^.*\.bib$/)
 					ids.push(doc._id)
 					ids.push(doc._id)
-			folder.folders.forEach (folder) ->
+			(folder.folders or []).forEach (folder) ->
 				_process(folder)
 				_process(folder)
-
-		project.rootFolder.forEach (rootFolder) ->
+		(project.rootFolder or []).forEach (rootFolder) ->
 			_process(rootFolder)
 			_process(rootFolder)
-
 		return ids
 		return ids
 
 
 	_isFullIndex: (project, callback = (err, result) ->) ->
 	_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)->) ->
 	indexAll: (projectId, callback=(err, data)->) ->
-		Project.findPopulatedById projectId, (err, project) ->
+		ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) ->
 			if err
 			if err
 				logger.err {err, projectId}, "error finding project"
 				logger.err {err, projectId}, "error finding project"
 				return callback(err)
 				return callback(err)
 			logger.log {projectId}, "indexing all bib files in project"
 			logger.log {projectId}, "indexing all bib files in project"
 			docIds = ReferencesHandler._findBibDocIds(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)->) ->
 	index: (projectId, docIds, callback=(err, data)->) ->
-		Project.findPopulatedById projectId, (err, project) ->
+		ProjectGetter.getProject projectId, {rootFolder: true, owner_ref: 1}, (err, project) ->
 			if err
 			if err
 				logger.err {err, projectId}, "error finding project"
 				logger.err {err, projectId}, "error finding project"
 				return callback(err)
 				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) ->
 		ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
 			if err
 			if err
 				logger.err {err, projectId}, "error checking whether to do full index"
 				logger.err {err, projectId}, "error checking whether to do full index"
@@ -65,11 +80,14 @@ module.exports = ReferencesHandler =
 						return callback(err)
 						return callback(err)
 					bibDocUrls = docIds.map (docId) ->
 					bibDocUrls = docIds.map (docId) ->
 						ReferencesHandler._buildDocUrl projectId, 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"
 					logger.log {projectId, isFullIndex, docIds, bibDocUrls}, "sending request to references service"
 					request.post {
 					request.post {
 						url: "#{settings.apis.references.url}/project/#{projectId}/index"
 						url: "#{settings.apis.references.url}/project/#{projectId}/index"
 						json:
 						json:
-							docUrls: bibDocUrls
+							docUrls: allUrls
 							fullIndex: isFullIndex
 							fullIndex: isFullIndex
 					}, (err, res, data) ->
 					}, (err, res, data) ->
 						if err
 						if err

+ 0 - 38
services/web/app/coffee/Features/Security/AuthorizationManager.coffee

@@ -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
-		
-

+ 1 - 0
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)
 		request(url: Settings.apis.spelling.url + url, method: req.method, headers: req.headers, json: req.body, timeout:TEN_SECONDS)
 		.on "error", (error) ->
 		.on "error", (error) ->
 			logger.error err: error, "Spelling API error"
 			logger.error err: error, "Spelling API error"
+			res.status(500).end()
 		.pipe(res)
 		.pipe(res)

+ 2 - 6
services/web/app/coffee/Features/Subscription/LimitationsManager.coffee

@@ -3,6 +3,7 @@ Project = require("../../models/Project").Project
 User = require("../../models/User").User
 User = require("../../models/User").User
 SubscriptionLocator = require("./SubscriptionLocator")
 SubscriptionLocator = require("./SubscriptionLocator")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
+CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
 
 
 module.exports =
 module.exports =
 
 
@@ -13,16 +14,11 @@ module.exports =
 				callback null, owner.features.collaborators
 				callback null, owner.features.collaborators
 			else
 			else
 				callback null, Settings.defaultPlanCode.collaborators
 				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)->) ->
 	canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
 		@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@currentNumberOfCollaboratorsInProject project_id, (error, current_number) =>
+			CollaboratorsHandler.getCollaboratorCount project_id, (error, current_number) =>
 				return callback(error) if error?
 				return callback(error) if error?
 				if current_number + x_collaborators <= allowed_number or allowed_number < 0
 				if current_number + x_collaborators <= allowed_number or allowed_number < 0
 					callback null, true
 					callback null, true

+ 22 - 30
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -1,4 +1,4 @@
-SecurityManager     = require '../../managers/SecurityManager'
+AuthenticationController = require '../Authentication/AuthenticationController'
 SubscriptionHandler  = require './SubscriptionHandler'
 SubscriptionHandler  = require './SubscriptionHandler'
 PlansLocator = require("./PlansLocator")
 PlansLocator = require("./PlansLocator")
 SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
 SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
@@ -31,7 +31,7 @@ module.exports = SubscriptionController =
 
 
 	#get to show the recurly.js page
 	#get to show the recurly.js page
 	paymentPage: (req, res, next) ->
 	paymentPage: (req, res, next) ->
-		SecurityManager.getCurrentUser req, (error, user) =>
+		AuthenticationController.getLoggedInUser req, (error, user) =>
 			return next(error) if error?
 			return next(error) if error?
 			plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
 			plan = PlansLocator.findLocalPlanInSettings(req.query.planCode)
 			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
@@ -48,7 +48,7 @@ module.exports = SubscriptionController =
 							subscription:
 							subscription:
 								plan_code : req.query.planCode
 								plan_code : req.query.planCode
 								currency: currency
 								currency: currency
-							account_code: user.id
+							account_code: user._id
 						}, (error, signature) ->
 						}, (error, signature) ->
 							return next(error) if error?
 							return next(error) if error?
 							res.render "subscriptions/new",
 							res.render "subscriptions/new",
@@ -64,28 +64,16 @@ module.exports = SubscriptionController =
 								showCouponField: req.query.scf
 								showCouponField: req.query.scf
 								showVatField: req.query.svf
 								showVatField: req.query.svf
 								couponCode:      req.query.cc or ""
 								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) ->
 	userSubscriptionPage: (req, res, next) ->
-		SecurityManager.getCurrentUser req, (error, user) =>
+		AuthenticationController.getLoggedInUser req, (error, user) =>
 			return next(error) if error?
 			return next(error) if error?
 			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
 			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
 				groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
 				groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
 				if subscription?.customAccount
 				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"
 					res.redirect "/user/subscription/custom_account"
 				else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
 				else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember
 					logger.log user:user, "redirecting to group subscription invite page"		
 					logger.log user:user, "redirecting to group subscription invite page"		
@@ -109,22 +97,26 @@ module.exports = SubscriptionController =
 
 
 
 
 	userCustomSubscriptionPage: (req, res, next)->
 	userCustomSubscriptionPage: (req, res, next)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)->
 			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",
 				res.render "subscriptions/custom_account",
 					title: "your_subscription"
 					title: "your_subscription"
 					subscription: subscription
 					subscription: subscription
 
 
 
 
 	editBillingDetailsPage: (req, res, next) ->
 	editBillingDetailsPage: (req, res, next) ->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			return next(error) if error?
 			return next(error) if error?
 			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription)->
 				if !hasSubscription
 				if !hasSubscription
 					res.redirect "/user/subscription"
 					res.redirect "/user/subscription"
 				else
 				else
 					RecurlyWrapper.sign {
 					RecurlyWrapper.sign {
-						account_code: user.id
+						account_code: user._id
 					}, (error, signature) ->
 					}, (error, signature) ->
 						return next(error) if error?
 						return next(error) if error?
 						res.render "subscriptions/edit-billing-details",
 						res.render "subscriptions/edit-billing-details",
@@ -135,10 +127,10 @@ module.exports = SubscriptionController =
 							signature  : signature
 							signature  : signature
 							successURL : "#{Settings.siteUrl}/user/subscription/update"
 							successURL : "#{Settings.siteUrl}/user/subscription/update"
 							user       :
 							user       :
-								id : user.id
+								id : user._id
 
 
 	createSubscription: (req, res, next)->
 	createSubscription: (req, res, next)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			recurly_token_id = req.body.recurly_token_id
 			recurly_token_id = req.body.recurly_token_id
 			subscriptionDetails = req.body.subscriptionDetails
 			subscriptionDetails = req.body.subscriptionDetails
@@ -150,14 +142,14 @@ module.exports = SubscriptionController =
 				res.sendStatus 201
 				res.sendStatus 201
 
 
 	successful_subscription: (req, res)->
 	successful_subscription: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) =>
+		AuthenticationController.getLoggedInUser req, (error, user) =>
 			SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
 			SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
 				res.render "subscriptions/successful_subscription",
 				res.render "subscriptions/successful_subscription",
 					title: "thank_you"
 					title: "thank_you"
 					subscription:subscription
 					subscription:subscription
 
 
 	cancelSubscription: (req, res, next) ->
 	cancelSubscription: (req, res, next) ->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			logger.log user_id:user._id, "canceling subscription"
 			logger.log user_id:user._id, "canceling subscription"
 			return next(error) if error?
 			return next(error) if error?
 			SubscriptionHandler.cancelSubscription user, (err)->
 			SubscriptionHandler.cancelSubscription user, (err)->
@@ -166,7 +158,7 @@ module.exports = SubscriptionController =
 				res.redirect "/user/subscription"
 				res.redirect "/user/subscription"
  
  
 	updateSubscription: (req, res)->
 	updateSubscription: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			return next(error) if error?
 			return next(error) if error?
 			planCode = req.body.plan_code
 			planCode = req.body.plan_code
 			logger.log planCode: planCode, user_id:user._id, "updating subscription"
 			logger.log planCode: planCode, user_id:user._id, "updating subscription"
@@ -176,7 +168,7 @@ module.exports = SubscriptionController =
 				res.redirect "/user/subscription"
 				res.redirect "/user/subscription"
 
 
 	reactivateSubscription: (req, res)->
 	reactivateSubscription: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			logger.log user_id:user._id, "reactivating subscription"
 			logger.log user_id:user._id, "reactivating subscription"
 			return next(error) if error?
 			return next(error) if error?
 			SubscriptionHandler.reactivateSubscription user, (err)->
 			SubscriptionHandler.reactivateSubscription user, (err)->
@@ -195,7 +187,7 @@ module.exports = SubscriptionController =
 			res.sendStatus 200
 			res.sendStatus 200
 
 
 	renderUpgradeToAnnualPlanPage: (req, res)->
 	renderUpgradeToAnnualPlanPage: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
 				planCode = subscription?.planCode.toLowerCase()
 				planCode = subscription?.planCode.toLowerCase()
 				if planCode?.indexOf("annual") != -1
 				if planCode?.indexOf("annual") != -1
@@ -212,7 +204,7 @@ module.exports = SubscriptionController =
 					planName: planName
 					planName: planName
 
 
 	processUpgradeToAnnualPlan: (req, res)->
 	processUpgradeToAnnualPlan: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			{planName} = req.body
 			{planName} = req.body
 			coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
 			coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName]
 			annualPlanName = "#{planName}-annual"
 			annualPlanName = "#{planName}-annual"
@@ -225,7 +217,7 @@ module.exports = SubscriptionController =
 					res.sendStatus 200
 					res.sendStatus 200
 
 
 	extendTrial: (req, res)->
 	extendTrial: (req, res)->
-		SecurityManager.getCurrentUser req, (error, user) ->
+		AuthenticationController.getLoggedInUser req, (error, user) ->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
 			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
 				SubscriptionHandler.extendTrial subscription, 14, (err)->
 				SubscriptionHandler.extendTrial subscription, 14, (err)->
 					if err?
 					if err?

+ 12 - 4
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -4,6 +4,7 @@ SubscriptionLocator = require("./SubscriptionLocator")
 ErrorsController = require("../Errors/ErrorController")
 ErrorsController = require("../Errors/ErrorController")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 _ = require("underscore")
 _ = require("underscore")
+async = require("async")
 
 
 module.exports =
 module.exports =
 
 
@@ -53,19 +54,26 @@ module.exports =
 					subscription: subscription
 					subscription: subscription
 
 
 	renderGroupInvitePage: (req, res)->
 	renderGroupInvitePage: (req, res)->
-		subscription_id = req.params.subscription_id
+		group_subscription_id = req.params.subscription_id
 		user_id = req.session.user._id
 		user_id = req.session.user._id
-		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
+		licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
 		if !licence?
 		if !licence?
 			return ErrorsController.notFound(req, res)
 			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
 			if partOfGroup
 				return res.redirect("/user/subscription/custom_account")
 				return res.redirect("/user/subscription/custom_account")
 			else
 			else
 				res.render "subscriptions/group/invite",
 				res.render "subscriptions/group/invite",
 					title: "Group Invitation"
 					title: "Group Invitation"
-					subscription_id:subscription_id
+					group_subscription_id:group_subscription_id
 					licenceName:licence.name
 					licenceName:licence.name
+					has_personal_subscription: subscription?
 
 
 	beginJoinGroup: (req, res)->
 	beginJoinGroup: (req, res)->
 		subscription_id = req.params.subscription_id
 		subscription_id = req.params.subscription_id

+ 4 - 1
services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee

@@ -22,4 +22,7 @@ module.exports =
 		Subscription.findOne _id:subscription_id, callback
 		Subscription.findOne _id:subscription_id, callback
 
 
 	getSubscriptionByMemberIdAndId: (user_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

+ 33 - 10
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -11,19 +11,18 @@ ReferalAllocator = require("../Referal/ReferalAllocator")
 
 
 oneMonthInSeconds = 60 * 60 * 24 * 30
 oneMonthInSeconds = 60 * 60 * 24 * 30
 
 
-module.exports =
+module.exports = SubscriptionUpdater =
 
 
 	syncSubscription: (recurlySubscription, adminUser_id, callback) ->
 	syncSubscription: (recurlySubscription, adminUser_id, callback) ->
-		self = @
 		logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist"
 		logger.log adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "syncSubscription, creating new if subscription does not exist"
 		SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
 		SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
 			if subscription?
 			if subscription?
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist"
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does exist"
-				self._updateSubscription recurlySubscription, subscription, callback
+				SubscriptionUpdater._updateSubscriptionFromRecurly recurlySubscription, subscription, callback
 			else
 			else
 				logger.log  adminUser_id:adminUser_id, recurlySubscription:recurlySubscription, "subscription does not exist, creating a new one"
 				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)->
 	addUserToGroup: (adminUser_id, user_id, callback)->
 		logger.log adminUser_id:adminUser_id, user_id:user_id, "adding user into mongo subscription"
 		logger.log adminUser_id:adminUser_id, user_id:user_id, "adding user into mongo subscription"
@@ -46,7 +45,8 @@ module.exports =
 			if err?
 			if err?
 				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
 				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
 				return callback(err)
 				return callback(err)
-			UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, callback
+			SubscriptionUpdater._setUsersMinimumFeatures user_id, callback
+
 
 
 	_createNewSubscription: (adminUser_id, callback)->
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
@@ -55,7 +55,7 @@ module.exports =
 		subscription.save (err)->
 		subscription.save (err)->
 			callback err, subscription
 			callback err, subscription
 
 
-	_updateSubscription: (recurlySubscription, subscription, callback)->
+	_updateSubscriptionFromRecurly: (recurlySubscription, subscription, callback)->
 		logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription"
 		logger.log recurlySubscription:recurlySubscription, subscription:subscription, "updaing subscription"
 		plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code)
 		plan = PlansLocator.findLocalPlanInSettings(recurlySubscription.plan.plan_code)
 		if recurlySubscription.state == "expired"
 		if recurlySubscription.state == "expired"
@@ -71,11 +71,34 @@ module.exports =
 			subscription.groupPlan = true
 			subscription.groupPlan = true
 			subscription.membersLimit = plan.membersLimit
 			subscription.membersLimit = plan.membersLimit
 		subscription.save ->
 		subscription.save ->
-			allIds = _.union subscription.members_id, [subscription.admin_id]
+			allIds = _.union subscription.member_ids, [subscription.admin_id]
 			jobs = allIds.map (user_id)->
 			jobs = allIds.map (user_id)->
 				return (cb)->
 				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
 			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
 
 

+ 6 - 3
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee

@@ -5,6 +5,7 @@ Project = require('../../models/Project').Project
 keys = require('../../infrastructure/Keys')
 keys = require('../../infrastructure/Keys')
 metrics = require("../../infrastructure/Metrics")
 metrics = require("../../infrastructure/Metrics")
 request = require("request")
 request = require("request")
+CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
 
 
 buildPath = (user_id, project_name, filePath)->
 buildPath = (user_id, project_name, filePath)->
 	projectPath = path.join(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
 		TpdsUpdateSender._enqueue "poll-dropbox:#{user_id}", "standardHttpRequest", options, callback
 
 
 getProjectsUsersIds = (project_id, callback = (err, owner_id, allUserIds)->)->
 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)->
 mergeProjectNameAndPath = (project_name, path)->
 	if(path.indexOf('/') == 0)
 	if(path.indexOf('/') == 0)

+ 8 - 10
services/web/app/coffee/Features/Uploads/ArchiveManager.coffee

@@ -25,33 +25,31 @@ module.exports = ArchiveManager =
 			error += chunk
 			error += chunk
 
 
 		unzip.on "error", (err) ->
 		unzip.on "error", (err) ->
-			logger.error {err, source, destination}, "unzip failed"
+			logger.error {err, source}, "unzip failed"
 			if err.code == "ENOENT"
 			if err.code == "ENOENT"
 				logger.error "unzip command not found. Please check the unzip command is installed"
 				logger.error "unzip command not found. Please check the unzip command is installed"
 			callback(err)
 			callback(err)
 
 
-		unzip.on "exit", () ->
+		unzip.on "close", (exitCode) ->
 			if error?
 			if error?
 				error = new Error(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")
 			lines = output.split("\n")
 			lastLine = lines[lines.length - 2]?.trim()
 			lastLine = lines[lines.length - 2]?.trim()
 			totalSizeInBytes = lastLine?.split(" ")?[0]
 			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)
 			isTooLarge = totalSizeInBytes > (ONE_MEG * 300)
 
 
 			callback(error, isTooLarge)
 			callback(error, isTooLarge)
 
 
 
 
-
-
 					
 					
 	extractZipArchive: (source, destination, _callback = (err) ->) ->
 	extractZipArchive: (source, destination, _callback = (err) ->) ->
 		callback = (args...) ->
 		callback = (args...) ->
@@ -87,7 +85,7 @@ module.exports = ArchiveManager =
 					logger.error "unzip command not found. Please check the unzip command is installed"
 					logger.error "unzip command not found. Please check the unzip command is installed"
 				callback(err)
 				callback(err)
 
 
-			unzip.on "exit", () ->
+			unzip.on "close", () ->
 				timer.done()
 				timer.done()
 				if error?
 				if error?
 					error = new Error(error)
 					error = new Error(error)

+ 3 - 2
services/web/app/coffee/Features/Uploads/UploadsRouter.coffee

@@ -1,4 +1,4 @@
-SecurityManager = require('../../managers/SecurityManager')
+AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear')
 AuthenticationController = require('../Authentication/AuthenticationController')
 AuthenticationController = require('../Authentication/AuthenticationController')
 ProjectUploadController = require "./ProjectUploadController"
 ProjectUploadController = require "./ProjectUploadController"
 RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear')
 RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear')
@@ -16,6 +16,7 @@ module.exports =
 				maxRequests: 200
 				maxRequests: 200
 				timeInterval: 60 * 30
 				timeInterval: 60 * 30
 			}),
 			}),
-			SecurityManager.requestCanModifyProject,
+			AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanWriteProjectContent,
 			ProjectUploadController.uploadFile
 			ProjectUploadController.uploadFile
 
 

+ 0 - 4
services/web/app/coffee/Features/User/UserInfoController.coffee

@@ -6,10 +6,6 @@ sanitize = require('sanitizer')
 
 
 module.exports = UserController =
 module.exports = UserController =
 	getLoggedInUsersPersonalInfo: (req, res, next = (error) ->) ->
 	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"
 		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?
 		return next(new Error("User is not logged in")) if !req.user?
 		UserGetter.getUser req.user._id, {
 		UserGetter.getUser req.user._id, {

+ 15 - 13
services/web/app/coffee/Features/Wiki/WikiController.coffee

@@ -4,7 +4,7 @@ logger = require("logger-sharelatex")
 ErrorController = require "../Errors/ErrorController"
 ErrorController = require "../Errors/ErrorController"
 _ = require("underscore")
 _ = require("underscore")
 AuthenticationController = require("../Authentication/AuthenticationController")
 AuthenticationController = require("../Authentication/AuthenticationController")
-
+async = require("async")
 other_lngs = ["es"]
 other_lngs = ["es"]
 
 
 module.exports = WikiController = 
 module.exports = WikiController = 
@@ -28,19 +28,22 @@ module.exports = WikiController =
 				lngPage = "#{page}_#{req.lng}"
 				lngPage = "#{page}_#{req.lng}"
 			else
 			else
 				lngPage = page
 				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?
 				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)
 						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 = 
 			result = 
 				content: data?.parse?.text?['*']
 				content: data?.parse?.text?['*']
 				title: data?.parse?.title
 				title: data?.parse?.title
-
 			callback null, result
 			callback null, result
 
 
 
 

+ 6 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -64,6 +64,12 @@ module.exports = (app, webRouter, apiRouter)->
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 			Settings.siteUrl.substring(Settings.siteUrl.indexOf("//")+2)
 		next()
 		next()
 
 
+	webRouter.use (req, res, next)->
+		res.locals.getUserEmail = ->
+			email = req?.session?.user?.email or ""
+			return email
+		next()
+
 	webRouter.use (req, res, next)->
 	webRouter.use (req, res, next)->
 		res.locals.formatProjectPublicAccessLevel = (privilegeLevel)->
 		res.locals.formatProjectPublicAccessLevel = (privilegeLevel)->
 			formatedPrivileges = private:"Private", readOnly:"Public: Read Only", readAndWrite:"Public: Read and Write"
 			formatedPrivileges = private:"Private", readOnly:"Public: Read Only", readAndWrite:"Public: Read and Write"

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

@@ -30,6 +30,8 @@ OldAssetProxy = require("./OldAssetProxy")
 translations = require("translations-sharelatex").setup(Settings.i18n)
 translations = require("translations-sharelatex").setup(Settings.i18n)
 Modules = require "./Modules"
 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/mongojs/node_modules/mongodb"), logger)
 metrics.mongodb.monitor(Path.resolve(__dirname + "/../../../node_modules/mongoose/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
 	, time
 
 
 app.get "/heapdump", (req, res)->
 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
 		res.send filename
 
 
 logger.info ("creating HTTP server").yellow
 logger.info ("creating HTTP server").yellow
@@ -136,6 +138,8 @@ app.use(webRouter)
 
 
 router = new Router(webRouter, apiRouter)
 router = new Router(webRouter, apiRouter)
 
 
+app.use ErrorController.handleError
+
 module.exports =
 module.exports =
 	app: app
 	app: app
 	server: server
 	server: server

+ 0 - 194
services/web/app/coffee/managers/SecurityManager.coffee

@@ -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+''
-
-

+ 1 - 28
services/web/app/coffee/models/Project.coffee

@@ -5,7 +5,7 @@ FolderSchema = require('./Folder.js').FolderSchema
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 sanitize = require('sanitizer')
 sanitize = require('sanitizer')
 concreteObjectId = require('mongoose').Types.ObjectId
 concreteObjectId = require('mongoose').Types.ObjectId
-Errors  = require "../errors"
+Errors  = require "../Features/Errors/Errors"
 
 
 
 
 Schema = mongoose.Schema
 Schema = mongoose.Schema
@@ -43,33 +43,6 @@ ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 			return callback(new Errors.NotFoundError(e.message))
 			return callback(new Errors.NotFoundError(e.message))
 		this.findById project_or_id, fields, callback
 		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)->
 applyToAllFilesRecursivly = ProjectSchema.statics.applyToAllFilesRecursivly = (folder, fun)->
 	_.each folder.fileRefs, (file)->
 	_.each folder.fileRefs, (file)->
 		fun(file)
 		fun(file)

+ 23 - 0
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

+ 4 - 26
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
 					# For example, a user signing up directly for a paid plan
 					# has this set to true, despite never having had a free trial
 					# has this set to true, despite never having had a free trial
 					hadFreeTrial: {type: Boolean, default: false}
 					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)
 conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: 10)
 
 

+ 61 - 45
services/web/app/coffee/router.coffee

@@ -3,8 +3,6 @@ ErrorController = require('./Features/Errors/ErrorController')
 ProjectController = require("./Features/Project/ProjectController")
 ProjectController = require("./Features/Project/ProjectController")
 ProjectApiController = require("./Features/Project/ProjectApiController")
 ProjectApiController = require("./Features/Project/ProjectApiController")
 SpellingController = require('./Features/Spelling/SpellingController')
 SpellingController = require('./Features/Spelling/SpellingController')
-SecurityManager = require('./managers/SecurityManager')
-AuthorizationManager = require('./Features/Security/AuthorizationManager')
 EditorController = require("./Features/Editor/EditorController")
 EditorController = require("./Features/Editor/EditorController")
 EditorRouter = require("./Features/Editor/EditorRouter")
 EditorRouter = require("./Features/Editor/EditorRouter")
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
@@ -39,6 +37,7 @@ RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
 InactiveProjectController = require("./Features/InactiveData/InactiveProjectController")
 InactiveProjectController = require("./Features/InactiveData/InactiveProjectController")
 ContactRouter = require("./Features/Contacts/ContactRouter")
 ContactRouter = require("./Features/Contacts/ContactRouter")
 ReferencesController = require('./Features/References/ReferencesController')
 ReferencesController = require('./Features/References/ReferencesController')
+AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -54,7 +53,7 @@ module.exports = class Router
 
 
 		webRouter.post '/login', AuthenticationController.login
 		webRouter.post '/login', AuthenticationController.login
 		webRouter.get  '/logout', UserController.logout
 		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
 		# Left as a placeholder for implementing a public register page
 		webRouter.get  '/register', UserPagesController.registerPage
 		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/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser
 		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
 		apiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
 
 
 		webRouter.get  '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
 		webRouter.get  '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
@@ -100,12 +98,13 @@ module.exports = class Router
 			params: ["Project_id"]
 			params: ["Project_id"]
 			maxRequests: 10
 			maxRequests: 10
 			timeInterval: 60
 			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\/(.*)$/,
 		webRouter.get  /^\/project\/([^\/]*)\/output\/(.*)$/,
 			((req, res, next) ->
 			((req, res, next) ->
 				params =
 				params =
@@ -113,24 +112,35 @@ module.exports = class Router
 					"file":       req.params[1]
 					"file":       req.params[1]
 				req.params = params
 				req.params = params
 				next()
 				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/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.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', 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.post '/project/:Project_id/rename', SecurityManager.requestIsOwner, ProjectController.renameProject
+		webRouter.post '/project/:Project_id/rename', AuthorizationMiddlewear.ensureUserCanAdminProject, ProjectController.renameProject
 
 
-		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.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', SecurityManager.requestCanAccessProject, ProjectDownloadsController.downloadProject
-		webRouter.get  '/project/download/zip', SecurityManager.requestCanAccessMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
+		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.get    '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags
 		webRouter.post   '/tag', AuthenticationController.requireLogin(), TagsController.createTag
 		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/check", AuthenticationController.requireLogin(), SpellingController.proxyRequestToSpellingApi
 		webRouter.post "/spelling/learn", 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
 		#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)->
 		apiRouter.get '/perfTest', (req,res)->
 			res.send("hello")
 			res.send("hello")
@@ -205,7 +220,7 @@ module.exports = class Router
 		webRouter.get '/health_check', HealthCheckController.check
 		webRouter.get '/health_check', HealthCheckController.check
 		webRouter.get '/health_check/redis', HealthCheckController.checkRedis
 		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)->
 			sendRes = _.once (statusCode, message)->
 				res.writeHead statusCode
 				res.writeHead statusCode
 				res.end message
 				res.end message
@@ -222,9 +237,9 @@ module.exports = class Router
 				headers: req.headers
 				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 {}, () ->
 			require("./models/Project").Project.findOne {}, () ->
 				throw new Error("Test error")
 				throw new Error("Test error")
 
 
@@ -233,7 +248,8 @@ module.exports = class Router
 			res.send()
 			res.send()
 
 
 		webRouter.post '/error/client', (req, res, next) ->
 		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)
 			res.sendStatus(204)
 
 
 		webRouter.get '*', ErrorController.notFound
 		webRouter.get '*', ErrorController.notFound

+ 30 - 0
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()"
+		) &times;
+		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")}

+ 21 - 0
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

+ 8 - 12
services/web/app/views/layout.jade

@@ -23,7 +23,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 
 
 		if settings.i18n.subdomainLang
 		if settings.i18n.subdomainLang
 			each subdomainDetails in 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")
 		meta(itemprop="name", content="ShareLaTeX, the Online LaTeX Editor")
 		
 		
@@ -60,8 +62,8 @@ html(itemscope, itemtype='http://schema.org/Product')
 				sixpackDomain: '#{settings.sixpack.domain}'
 				sixpackDomain: '#{settings.sixpack.domain}'
 			};
 			};
 			window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')};
 			window.systemMessages = !{JSON.stringify(systemMessages).replace(/\//g, '\\/')};
-			window.ab = {}
-			window.user_id = '#{getLoggedInUserId()}'
+			window.ab = {};
+			window.user_id = '#{getLoggedInUserId()}';
 
 
 		- if (typeof(settings.algolia) != "undefined")
 		- if (typeof(settings.algolia) != "undefined")
 			script.
 			script.
@@ -80,6 +82,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 				}
 				}
 				
 				
 	body
 	body
+
 		- if(typeof(suppressSystemMessages) == "undefined")
 		- if(typeof(suppressSystemMessages) == "undefined")
 			.system-messages(
 			.system-messages(
 				ng-cloak
 				ng-cloak
@@ -118,21 +121,14 @@ html(itemscope, itemtype='http://schema.org/Product')
 					"paths" : {
 					"paths" : {
 						"moment": "libs/moment-2.7.0"
 						"moment": "libs/moment-2.7.0"
 					}
 					}
-				};	
+				};
 			script(
 			script(
 				data-main=jsPath+'main.js',
 				data-main=jsPath+'main.js',
 				baseurl=jsPath,
 				baseurl=jsPath,
 				src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
 				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
 
 
 
 
 
 

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

@@ -4,16 +4,41 @@ div.binary-file.full-size(
 	ng-if="openFile"
 	ng-if="openFile"
 )
 )
 	img(
 	img(
+		ng-show="!failedLoad"
 		ng-src="/project/{{ project_id }}/file/{{ openFile.id }}"
 		ng-src="/project/{{ project_id }}/file/{{ openFile.id }}"
 		ng-if="['png', 'jpg', 'jpeg', 'gif'].indexOf(extension(openFile)) > -1"
 		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(
 	img(
+		ng-show="!failedLoad"
 		ng-src="/project/{{ project_id }}/file/{{ openFile.id }}?format=png"
 		ng-src="/project/{{ project_id }}/file/{{ openFile.id }}?format=png"
 		ng-if="['pdf', 'eps'].indexOf(extension(openFile)) > -1"
 		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(
 	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")}
 	) #{translate("no_preview_available")}
+
 	a.btn.btn-info(
 	a.btn.btn-info(
 		ng-href="/project/{{ project_id }}/file/{{ openFile.id }}"
 		ng-href="/project/{{ project_id }}/file/{{ openFile.id }}"
 	) #{translate("download")} {{ openFile.name }}
 	) #{translate("download")} {{ openFile.name }}

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

@@ -10,8 +10,12 @@ div.full-size(
 )
 )
 	.ui-layout-center
 	.ui-layout-center
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
-			i.fa.fa-spin.fa-refresh
-			| &nbsp;&nbsp;#{translate("loading")}...
+			span(ng-show="editor.open_doc_id")
+				i.fa.fa-spin.fa-refresh
+				| &nbsp;&nbsp;#{translate("loading")}...
+			span(ng-show="!editor.open_doc_id")
+				i.fa.fa-arrow-left
+				| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
 
 
 		#editor(
 		#editor(
 			ace-editor="editor",
 			ace-editor="editor",

+ 17 - 18
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']"
 				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')
 script(type='text/ng-template', id='entityListItemTemplate')
 	li(
 	li(
@@ -107,8 +107,7 @@ script(type='text/ng-template', id='entityListItemTemplate')
 				//- Just a spacer to align with folders
 				//- Just a spacer to align with folders
 				i.fa.fa-fw.toggle(ng-if="entity.type != 'folder'")
 				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(
 				span(
 					ng-hide="entity.renaming"
 					ng-hide="entity.renaming"
 				)  {{ entity.name }}
 				)  {{ entity.name }}

+ 8 - 8
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")
 		div(ng-if="!status.loading")
 			.container-fluid
 			.container-fluid
 				.row
 				.row
-					.col-md-4
+					.col-xs-4
 						.pull-right #{translate("total_words")} :
 						.pull-right #{translate("total_words")} :
-					.col-md-6 {{data.textWords}}
+					.col-xs-6 {{data.textWords}}
 				.row 
 				.row 
-					.col-md-4
+					.col-xs-4
 						.pull-right #{translate("headers")} :
 						.pull-right #{translate("headers")} :
-					.col-md-6 {{data.headers}}
+					.col-xs-6 {{data.headers}}
 				.row 
 				.row 
-					.col-md-4 
+					.col-xs-4 
 						.pull-right #{translate("math_inline")} :
 						.pull-right #{translate("math_inline")} :
-					.col-md-6 {{data.mathInline}}
+					.col-xs-6 {{data.mathInline}}
 				.row 
 				.row 
-					.col-md-4 
+					.col-xs-4 
 						.pull-right #{translate("math_display")} :
 						.pull-right #{translate("math_display")} :
-					.col-md-6 {{data.mathDisplay}}
+					.col-xs-6 {{data.mathDisplay}}
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
 			ng-disabled="state.inflight"

+ 95 - 88
services/web/app/views/project/editor/pdf.jade

@@ -32,7 +32,7 @@ div.full-size.pdf(ng-controller="PdfController")
 		a.log-btn(
 		a.log-btn(
 			href
 			href
 			ng-click="toggleLogs()"
 			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="#{translate('logs_and_output_files')}"
 			tooltip-placement="bottom"
 			tooltip-placement="bottom"
 		)
 		)
@@ -74,93 +74,10 @@ div.full-size.pdf(ng-controller="PdfController")
 			)
 			)
 				i.split-screen
 				i.split-screen
 				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")
-		| &nbsp;
-		i.fa.fa-level-up.fa-flip-horizontal.fa-2x
-		| &nbsp;&nbsp;#{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 &nbsp;
-						| #{translate("unlimited_projects")}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("full_doc_history")}
-					
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_dropbox")}
-
-					li
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_github")}
-
-					li
-						i.fa.fa-check &nbsp;
-						|#{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")
 		.alert.alert-success(ng-show="pdf.logEntries.all.length == 0")
 			| #{translate("no_errors_good_job")}
 			| #{translate("no_errors_good_job")}
 
 
@@ -204,7 +121,7 @@ div.full-size.pdf(ng-controller="PdfController")
 					ul.dropdown-menu.dropdown-menu-right
 					ul.dropdown-menu.dropdown-menu-right
 						li(ng-repeat="file in pdf.outputFiles")
 						li(ng-repeat="file in pdf.outputFiles")
 							a(
 							a(
-								href="/project/{{project_id}}/output/{{file.path}}"
+								href="{{file.url}}"
 								target="_blank"
 								target="_blank"
 							) {{ file.name }}
 							) {{ file.name }}
 			a.btn.btn-info.btn-sm(href, ng-click="toggleRawLog()")
 			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")}
 				span(ng-show="pdf.showRawLog") #{translate("hide_raw_logs")}
 
 
 		pre(ng-bind="pdf.rawLog", ng-show="pdf.showRawLog")
 		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")
+			| &nbsp;
+			i.fa.fa-level-up.fa-flip-horizontal.fa-2x
+			| &nbsp;&nbsp;#{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 &nbsp;
+							| #{translate("unlimited_projects")}
+						li
+							i.fa.fa-check &nbsp;
+							| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
+						li
+							i.fa.fa-check &nbsp;
+							| #{translate("full_doc_history")}
+						li
+							i.fa.fa-check &nbsp;
+							| #{translate("sync_to_dropbox")}
+						li
+							i.fa.fa-check &nbsp;
+							| #{translate("sync_to_github")}
+						li
+							i.fa.fa-check &nbsp;
+							|#{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')
 script(type='text/ng-template', id='clearCacheModalTemplate')
 	.modal-header
 	.modal-header

+ 4 - 2
services/web/app/views/project/list/modals.jade

@@ -183,7 +183,9 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate')
 			data-dismiss="modal"
 			data-dismiss="modal"
 			ng-click="cancel()"
 			ng-click="cancel()"
 		) &times;
 		) &times;
-		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
 	.modal-body
 		div(ng-show="projectsToDelete.length > 0")
 		div(ng-show="projectsToDelete.length > 0")
 			p #{translate("about_to_delete_projects")}
 			p #{translate("about_to_delete_projects")}
@@ -201,7 +203,7 @@ script(type='text/ng-template', id='deleteProjectsModalTemplate')
 		) #{translate("cancel")}
 		) #{translate("cancel")}
 		button.btn.btn-danger(
 		button.btn.btn-danger(
 			ng-click="delete()"
 			ng-click="delete()"
-		) {{action}}
+		) #{translate("confirm")}
 
 
 script(type="text/ng-template", id="uploadProjectModalTemplate")
 script(type="text/ng-template", id="uploadProjectModalTemplate")
 	.modal-header
 	.modal-header

+ 9 - 9
services/web/app/views/project/list/project-list.jade

@@ -24,7 +24,7 @@
 			.btn-toolbar(ng-show="filter != 'archived'")
 			.btn-toolbar(ng-show="filter != 'archived'")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(
 					a.btn.btn-default(
-						href='#',
+						href,
 						tooltip="#{translate('download')}",
 						tooltip="#{translate('download')}",
 						tooltip-placement="bottom",
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						tooltip-append-to-body="true",
@@ -32,7 +32,7 @@
 					)
 					)
 						i.fa.fa-cloud-download
 						i.fa.fa-cloud-download
 					a.btn.btn-default(
 					a.btn.btn-default(
-						href='#',
+						href,
 						tooltip="#{translate('delete')}",
 						tooltip="#{translate('delete')}",
 						tooltip-placement="bottom",
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						tooltip-append-to-body="true",
@@ -42,7 +42,7 @@
 
 
 				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
 				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
 					a.btn.btn-default.dropdown-toggle(
 					a.btn.btn-default.dropdown-toggle(
-						href="#",
+						href,
 						data-toggle="dropdown",
 						data-toggle="dropdown",
 						dropdown-toggle,
 						dropdown-toggle,
 						tooltip="#{translate('add_to_folders')}",
 						tooltip="#{translate('add_to_folders')}",
@@ -72,11 +72,11 @@
 								|  {{tag.name}}
 								|  {{tag.name}}
 						li.divider
 						li.divider
 						li
 						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
 				.btn-group(ng-hide="selectedProjects.length != 1", dropdown).dropdown
 					a.btn.btn-default.dropdown-toggle(
 					a.btn.btn-default.dropdown-toggle(
-						href='#',
+						href,
 						data-toggle="dropdown",
 						data-toggle="dropdown",
 						dropdown-toggle
 						dropdown-toggle
 					) #{translate("more")} 
 					) #{translate("more")} 
@@ -84,19 +84,19 @@
 					ul.dropdown-menu.dropdown-menu-right(role="menu")
 					ul.dropdown-menu.dropdown-menu-right(role="menu")
 						li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
 						li(ng-show="getFirstSelectedProject().accessLevel == 'owner'")
 							a(
 							a(
-								href='#',
+								href,
 								ng-click="openRenameProjectModal()"
 								ng-click="openRenameProjectModal()"
 							) #{translate("rename")}
 							) #{translate("rename")}
 						li
 						li
 							a(
 							a(
-								href='#',
+								href,
 								ng-click="openCloneProjectModal()"
 								ng-click="openCloneProjectModal()"
 							) #{translate("make_copy")}
 							) #{translate("make_copy")}
 
 
 			.btn-toolbar(ng-show="filter == 'archived'")
 			.btn-toolbar(ng-show="filter == 'archived'")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-default(
 					a.btn.btn-default(
-						href='#',
+						href,
 						data-original-title="Restore",
 						data-original-title="Restore",
 						data-toggle="tooltip",
 						data-toggle="tooltip",
 						data-placement="bottom",
 						data-placement="bottom",
@@ -105,7 +105,7 @@
 
 
 				.btn-group(ng-hide="selectedProjects.length < 1")
 				.btn-group(ng-hide="selectedProjects.length < 1")
 					a.btn.btn-danger(
 					a.btn.btn-danger(
-						href='#',
+						href,
 						data-original-title="Delete Forever",
 						data-original-title="Delete Forever",
 						data-toggle="tooltip",
 						data-toggle="tooltip",
 						data-placement="bottom",
 						data-placement="bottom",

+ 19 - 4
services/web/app/views/subscriptions/group/invite.jade

@@ -2,7 +2,8 @@ extends ../../layout
 
 
 block scripts
 block scripts
 	script(type='text/javascript').
 	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
 block content
 	.content.content-alt
 	.content.content-alt
@@ -11,12 +12,26 @@ block content
 				.col-md-8.col-md-offset-2
 				.col-md-8.col-md-offset-2
 					-if (query.expired)
 					-if (query.expired)
 						.alert.alert-warning #{translate("email_link_expired")}
 						.alert.alert-warning #{translate("email_link_expired")}
+
+			.row
+				div &nbsp;
 			.row
 			.row
 				.col-md-8.col-md-offset-2(ng-cloak)
 				.col-md-8.col-md-offset-2(ng-cloak)
 					.card(ng-controller="GroupSubscriptionInviteController")
 					.card(ng-controller="GroupSubscriptionInviteController")
 						.page-header
 						.page-header
 							h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
 							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 &nbsp;
+							.row
+								.col-md-12
+									a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
+									span &nbsp;
+									a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
+
+						div(ng-show="view =='groupSubscriptionInvite'").row.text-centered
 							.row
 							.row
 								.col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
 								.col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
 							.row
 							.row
@@ -26,10 +41,10 @@ block content
 									.text-center
 									.text-center
 										a.btn.btn-default(href="/project") #{translate("not_now")}
 										a.btn.btn-default(href="/project") #{translate("not_now")}
 										span &nbsp;
 										span &nbsp;
-										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
 							.row
 								.col-md-12 #{translate("check_email_to_complete_the_upgrade")}
 								.col-md-12 #{translate("check_email_to_complete_the_upgrade")}
 							.row
 							.row

+ 1 - 1
services/web/app/views/subscriptions/new.jade

@@ -108,7 +108,7 @@ block content
 														option(value="10") 10
 														option(value="10") 10
 														option(value="11") 11
 														option(value="11") 11
 														option(value="12") 12
 														option(value="12") 12
-											.col-md-3
+											.col-md-4
 												.form-group(ng-class="validation.correctExpiry == false || validation.errorFields.year ? 'has-error' : ''")
 												.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')
 													select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year')
 														option(value="", disabled, selected) Year
 														option(value="", disabled, selected) Year

+ 58 - 4
services/web/app/views/wiki/page.jade

@@ -1,14 +1,68 @@
 extends ../layout
 extends ../layout
 
 
 block content
 block content
-	.content.content-alt(ng-non-bindable)
+	.content.content-alt(ng-cloak)
 		.container.wiki
 		.container.wiki
+			.row.template-page-header
+				.col-md-8(ng-cloak)
+
 			.row
 			.row
-				.col-xs-3.contents
+				.col-xs-3.contents(ng-non-bindable)
 					| !{contents.content}
 					| !{contents.content}
+
 				.col-xs-9.page
 				.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
 						.page-header
 							h1 #{title}
 							h1 #{title}
 							
 							
-						| !{page.content}
+						| !{page.content}
+
+
+
+
+	script(type="text/ng-template", id="missingWikiPageModal")
+		.modal-header
+			button.close(
+				type="button"
+				data-dismiss="modal"
+				ng-click="close()"
+			) &times;
+			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")}
+

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

@@ -88,8 +88,6 @@ module.exports =
 			url: "http://localhost:3009"
 			url: "http://localhost:3009"
 		clsi:
 		clsi:
 			url: "http://localhost:3013"
 			url: "http://localhost:3013"
-		clsi_priority:
-			url: "http://localhost:3013"
 		templates:
 		templates:
 			url: "http://localhost:3007"
 			url: "http://localhost:3007"
 		githubSync:
 		githubSync:
@@ -126,6 +124,9 @@ module.exports =
 	# cookieDomain: ".sharelatex.dev"
 	# cookieDomain: ".sharelatex.dev"
 	cookieName:"sharelatex.sid"
 	cookieName:"sharelatex.sid"
 
 
+	# this is only used if cookies are used for clsi backend
+	#clsiCookieKey: "clsiserver"
+	
 	# Same, but with http auth credentials.
 	# Same, but with http auth credentials.
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 
 
@@ -259,7 +260,7 @@ module.exports =
 	
 	
 	# Should we allow access to any page without logging in? This includes
 	# Should we allow access to any page without logging in? This includes
 	# public projects, /learn, /templates, about pages, etc.
 	# 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.
 	# Maximum size of text documents in the real-time editing system.
 	max_doc_length: 2 * 1024 * 1024 # 2mb
 	max_doc_length: 2 * 1024 * 1024 # 2mb
@@ -388,11 +389,15 @@ module.exports =
 	# 	filter: "(uid=:userKey)"
 	# 	filter: "(uid=:userKey)"
 	# 	failMessage: 'LDAP User Fail'
 	# 	failMessage: 'LDAP User Fail'
 	# 	fieldName: 'LDAP User'
 	# 	fieldName: 'LDAP User'
-	# 	placeholder: 'LDAP User ID'
+	# 	placeholder: 'email@example.com'
 	# 	emailAtt: 'mail'
 	# 	emailAtt: 'mail'
 	# 	anonymous: false
 	# 	anonymous: false
 	#	adminDN: 'cn=read-only-admin,dc=example,dc=com'	
 	#	adminDN: 'cn=read-only-admin,dc=example,dc=com'	
 	#	adminPW: 'password'
 	#	adminPW: 'password'
+	#	starttls: true
+	#	tlsOptions:
+	#		rejectUnauthorized: false
+	#		ca: ['/etc/ldap/ca_certs.pem']
 	
 	
 	#templateLinks: [{
 	#templateLinks: [{
 	#	name : "CV projects",
 	#	name : "CV projects",

+ 6 - 1
services/web/package.json

@@ -18,11 +18,13 @@
     "body-parser": "^1.13.1",
     "body-parser": "^1.13.1",
     "bufferedstream": "1.6.0",
     "bufferedstream": "1.6.0",
     "connect-redis": "2.3.0",
     "connect-redis": "2.3.0",
+    "cookie": "^0.2.3",
     "cookie-parser": "1.3.5",
     "cookie-parser": "1.3.5",
     "csurf": "^1.8.3",
     "csurf": "^1.8.3",
     "dateformat": "1.0.4-1.2.3",
     "dateformat": "1.0.4-1.2.3",
     "express": "4.13.0",
     "express": "4.13.0",
     "express-session": "1.11.3",
     "express-session": "1.11.3",
+    "grunt": "^0.4.5",
     "heapdump": "^0.3.7",
     "heapdump": "^0.3.7",
     "http-proxy": "^1.8.1",
     "http-proxy": "^1.8.1",
     "jade": "~1.3.1",
     "jade": "~1.3.1",
@@ -39,16 +41,19 @@
     "multer": "^0.1.8",
     "multer": "^0.1.8",
     "node-uuid": "1.4.1",
     "node-uuid": "1.4.1",
     "nodemailer": "2.1.0",
     "nodemailer": "2.1.0",
+    "nodemailer-sendgrid-transport": "^0.2.0",
     "nodemailer-ses-transport": "^1.3.0",
     "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
     "optimist": "0.6.1",
     "redback": "0.4.0",
     "redback": "0.4.0",
     "redis": "0.10.1",
     "redis": "0.10.1",
     "redis-sharelatex": "0.0.9",
     "redis-sharelatex": "0.0.9",
-    "request": "2.67.0",
+    "request": "^2.69.0",
+    "requests": "^0.1.7",
     "rimraf": "2.2.6",
     "rimraf": "2.2.6",
     "sanitizer": "0.1.1",
     "sanitizer": "0.1.1",
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "settings-sharelatex": "git+https://github.com/sharelatex/settings-sharelatex.git#v1.0.0",
     "sixpack-client": "^1.0.0",
     "sixpack-client": "^1.0.0",
+    "temp": "^0.8.3",
     "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
     "translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
     "underscore": "1.6.0",
     "underscore": "1.6.0",
     "underscore.string": "^3.0.2",
     "underscore.string": "^3.0.2",

+ 0 - 6
services/web/public/coffee/analytics/AbTestingManager.coffee

@@ -22,24 +22,20 @@ define [
 
 
 		_buildCookieKey = (testName, bucket)-> 
 		_buildCookieKey = (testName, bucket)-> 
 			key = "sl_abt_#{testName}_#{bucket}"
 			key = "sl_abt_#{testName}_#{bucket}"
-			#console.log key
 			return key
 			return key
 
 
 
 
 		_getTestCookie = (testName, bucket)->
 		_getTestCookie = (testName, bucket)->
 			cookieKey = _buildCookieKey(testName, bucket)
 			cookieKey = _buildCookieKey(testName, bucket)
 			cookie =  ipCookie(cookieKey)
 			cookie =  ipCookie(cookieKey)
-			#console.log cookieKey, cookie
 			return cookie
 			return cookie
 
 
 		_persistCookieStep = (testName, bucket, newStep)->
 		_persistCookieStep = (testName, bucket, newStep)->
 			cookieKey = _buildCookieKey(testName, bucket)
 			cookieKey = _buildCookieKey(testName, bucket)
 			ipCookie(cookieKey, {step:newStep}, {expires:100, path:"/"})
 			ipCookie(cookieKey, {step:newStep}, {expires:100, path:"/"})
-			#console.log("persisting", cookieKey, {step:newStep})
 			ga('send', 'event', 'ab_tests', "#{testName}:#{bucket}", "step-#{newStep}")
 			ga('send', 'event', 'ab_tests', "#{testName}:#{bucket}", "step-#{newStep}")
 
 
 		_checkIfStepIsNext = (cookieStep, newStep)->
 		_checkIfStepIsNext = (cookieStep, newStep)->
-			#console.log cookieStep, newStep, "checking if step is next"
 			if !cookieStep? and newStep != 0
 			if !cookieStep? and newStep != 0
 				return false
 				return false
 			else if newStep == 0
 			else if newStep == 0
@@ -68,8 +64,6 @@ define [
 			bucketIndex = parseInt(hash.toString().slice(0,2), 16) % (buckets?.length or 2)
 			bucketIndex = parseInt(hash.toString().slice(0,2), 16) % (buckets?.length or 2)
 			return buckets[bucketIndex]
 			return buckets[bucketIndex]
 
 
-
-
 	App.controller "AbTestController", ($scope, abTestManager)->
 	App.controller "AbTestController", ($scope, abTestManager)->
 		testKeys = _.keys(window.ab)
 		testKeys = _.keys(window.ab)
 
 

+ 4 - 0
services/web/public/coffee/base.coffee

@@ -23,5 +23,9 @@ define [
 			baseUrl: window.sharelatex.sixpackDomain
 			baseUrl: window.sharelatex.sixpackDomain
 			client_id: window.user_id
 			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
 	return App

+ 16 - 0
services/web/public/coffee/ide.coffee

@@ -112,4 +112,20 @@ define [
 
 
 		ide.localStorage = localStorage
 		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"])
 	angular.bootstrap(document.body, ["SharelatexApp"])

+ 9 - 1
services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee

@@ -9,4 +9,12 @@ define [
 
 
 		openFile: (file) ->
 		openFile: (file) ->
 			@$scope.ui.view = "file"
 			@$scope.ui.view = "file"
-			@$scope.openFile = file
+			@$scope.openFile = null
+			@$scope.$apply()
+			window.setTimeout(
+				() =>
+					@$scope.openFile = file
+					@$scope.$apply()
+				, 0
+				, this
+			)

+ 65 - 2
services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee

@@ -1,7 +1,70 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (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) ->
 		$scope.extension = (file) ->
 			return file.name.split(".").pop()?.toLowerCase()
 			return file.name.split(".").pop()?.toLowerCase()
-	]
+
+		$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()
+
+	]

+ 13 - 0
services/web/public/coffee/ide/connection/ConnectionManager.coffee

@@ -52,6 +52,7 @@ define [], () ->
 				"force new connection": true
 				"force new connection": true
 
 
 			@ide.socket.on "connect", () =>
 			@ide.socket.on "connect", () =>
+				sl_console.log "[socket.io connect] Connected"
 				@connected = true
 				@connected = true
 				@ide.pushEvent("connected")
 				@ide.pushEvent("connected")
 
 
@@ -73,6 +74,7 @@ define [], () ->
 
 
 
 
 			@ide.socket.on 'disconnect', () =>
 			@ide.socket.on 'disconnect', () =>
+				sl_console.log "[socket.io disconnect] Disconnected"
 				@connected = false
 				@connected = false
 				@ide.pushEvent("disconnected")
 				@ide.pushEvent("disconnected")
 
 
@@ -97,9 +99,20 @@ define [], () ->
 				, 10 * 1000
 				, 10 * 1000
 				
 				
 		joinProject: () ->
 		joinProject: () ->
+			sl_console.log "[joinProject] joining..."
 			@ide.socket.emit 'joinProject', {
 			@ide.socket.emit 'joinProject', {
 				project_id: @ide.project_id
 				project_id: @ide.project_id
 			}, (err, project, permissionsLevel, protocolVersion) =>
 			}, (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
 				if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
 					location.reload(true)
 					location.reload(true)
 
 

+ 32 - 7
services/web/public/coffee/ide/editor/Document.coffee

@@ -6,7 +6,10 @@ define [
 		@getDocument: (ide, doc_id) ->
 		@getDocument: (ide, doc_id) ->
 			@openDocs ||= {}
 			@openDocs ||= {}
 			if !@openDocs[doc_id]?
 			if !@openDocs[doc_id]?
+				sl_console.log "[getDocument] Creating new document instance for #{doc_id}"
 				@openDocs[doc_id] = new Document(ide, 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]
 			return @openDocs[doc_id]
 
 
 		@hasUnsavedChanges: () ->
 		@hasUnsavedChanges: () ->
@@ -107,11 +110,14 @@ define [
 			@wantToBeJoined = false
 			@wantToBeJoined = false
 			@_cancelJoin()
 			@_cancelJoin()
 			if (@doc? and @doc.hasBufferedOps())
 			if (@doc? and @doc.hasBufferedOps())
+				sl_console.log "[leave] Doc has buffered ops, pushing callback for later"
 				@_leaveCallbacks ||= []
 				@_leaveCallbacks ||= []
 				@_leaveCallbacks.push callback
 				@_leaveCallbacks.push callback
 			else if !@connected
 			else if !@connected
+				sl_console.log "[leave] Not connected, returning now"
 				callback()
 				callback()
 			else
 			else
+				sl_console.log "[leave] Leaving now"
 				@_leaveDoc(callback)
 				@_leaveDoc(callback)
 
 
 		flush: () ->
 		flush: () ->
@@ -146,12 +152,15 @@ define [
 			if !inflightOp? and !pendingOp?
 			if !inflightOp? and !pendingOp?
 				# there's nothing going on
 				# there's nothing going on
 				saved = true
 				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
 				saved = false
+				sl_console.log "[pollSavedStatus] inflight op is same as before"
 			else
 			else
 				saved = true
 				saved = true
+				sl_console.log "[pollSavedStatus] assuming saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
 
 
 			@oldInflightOp = inflightOp
 			@oldInflightOp = inflightOp
 			return saved
 			return saved
@@ -172,14 +181,14 @@ define [
 				update: update
 				update: update
 
 
 			if window.disconnectOnAck? and Math.random() < window.disconnectOnAck
 			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()
 				window._ide.socket.socket.disconnect()
 				# Pretend we never received the ack
 				# Pretend we never received the ack
 				return
 				return
 
 
 			if window.dropAcks? and Math.random() < window.dropAcks
 			if window.dropAcks? and Math.random() < window.dropAcks
 				if !update.op? # Only drop our own acks, not collaborator updates
 				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
 					return
 
 
 			if update?.doc == @doc_id and @doc?
 			if update?.doc == @doc_id and @doc?
@@ -189,15 +198,18 @@ define [
 					@leave()
 					@leave()
 
 
 		_onDisconnect: () ->
 		_onDisconnect: () ->
+			sl_console.log '[onDisconnect] disconnecting'
 			@connected = false
 			@connected = false
 			@joined = false
 			@joined = false
 			@doc?.updateConnectionState "disconnected"
 			@doc?.updateConnectionState "disconnected"
 
 
 		_onReconnect: () ->
 		_onReconnect: () ->
+			sl_console.log "[onReconnect] reconnected (joined project)"
 			@ide.pushEvent "reconnected:afterJoinProject"
 			@ide.pushEvent "reconnected:afterJoinProject"
 
 
 			@connected = true
 			@connected = true
 			if @wantToBeJoined or @doc?.hasBufferedOps()
 			if @wantToBeJoined or @doc?.hasBufferedOps()
+				sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@doc?.hasBufferedOps()})"
 				@_joinDoc (error) =>
 				@_joinDoc (error) =>
 					return @_onError(error) if error?
 					return @_onError(error) if error?
 					@doc.updateConnectionState "ok"
 					@doc.updateConnectionState "ok"
@@ -225,16 +237,25 @@ define [
 					callback()
 					callback()
 
 
 		_leaveDoc: (callback = (error) ->) ->
 		_leaveDoc: (callback = (error) ->) ->
+			sl_console.log '[_leaveDoc] Sending leaveDoc request'
 			@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
 			@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
 				return callback(error) if error?
 				return callback(error) if error?
 				@joined = false
 				@joined = false
 				for callback in @_leaveCallbacks or []
 				for callback in @_leaveCallbacks or []
+					sl_console.log '[_leaveDoc] Calling buffered callback', callback
 					callback(error)
 					callback(error)
 				delete @_leaveCallbacks
 				delete @_leaveCallbacks
 				callback(error)
 				callback(error)
 
 
 		_cleanUp: () ->
 		_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()
 			@_unBindFromEditorEvents()
 			@_unBindFromSocketEvents()
 			@_unBindFromSocketEvents()
 
 
@@ -276,5 +297,9 @@ define [
 			console.error "ShareJS error", error, meta
 			console.error "ShareJS error", error, meta
 			ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
 			ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
 			@doc?.clearInflightAndPendingOps()
 			@doc?.clearInflightAndPendingOps()
-			@_cleanUp()
 			@trigger "error", error, meta
 			@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()

+ 3 - 0
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -41,6 +41,7 @@ define [
 			@openDoc(doc)
 			@openDoc(doc)
 
 
 		openDoc: (doc, options = {}) ->
 		openDoc: (doc, options = {}) ->
+			sl_console.log "[openDoc] Opening #{doc.id}"
 			@$scope.ui.view = "editor"
 			@$scope.ui.view = "editor"
 
 
 			done = () =>
 			done = () =>
@@ -74,8 +75,10 @@ define [
 					done()
 					done()
 
 
 		_openNewDocument: (doc, callback = (error, sharejs_doc) ->) ->
 		_openNewDocument: (doc, callback = (error, sharejs_doc) ->) ->
+			sl_console.log "[_openNewDocument] Opening..."
 			current_sharejs_doc = @$scope.editor.sharejs_doc
 			current_sharejs_doc = @$scope.editor.sharejs_doc
 			if current_sharejs_doc?
 			if current_sharejs_doc?
+				sl_console.log "[_openNewDocument] Leaving existing open doc..."
 				current_sharejs_doc.leaveAndCleanUp()
 				current_sharejs_doc.leaveAndCleanUp()
 				@_unbindFromDocumentEvents(current_sharejs_doc)
 				@_unbindFromDocumentEvents(current_sharejs_doc)
 
 

+ 20 - 6
services/web/public/coffee/ide/editor/ShareJsDoc.coffee

@@ -29,12 +29,13 @@ define [
 				send: (update) =>
 				send: (update) =>
 					@_startInflightOpTimeout(update)
 					@_startInflightOpTimeout(update)
 					if window.disconnectOnUpdate? and Math.random() < window.disconnectOnUpdate
 					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()
 						window._ide.socket.socket.disconnect()
 					if window.dropUpdates? and Math.random() < window.dropUpdates
 					if window.dropUpdates? and Math.random() < window.dropUpdates
-						console.log "Simulating a lost update", update
+						sl_console.log "Simulating a lost update", update
 						return
 						return
-					@socket.emit "applyOtUpdate", @doc_id, update
+					@socket.emit "applyOtUpdate", @doc_id, update, (error) =>
+						return @_handleError(error) if error?
 				state: "ok"
 				state: "ok"
 				id:    @socket.socket.sessionid
 				id:    @socket.socket.sessionid
 			}
 			}
@@ -95,6 +96,7 @@ define [
 			@_doc.flush()
 			@_doc.flush()
 
 
 		updateConnectionState: (state) ->
 		updateConnectionState: (state) ->
+			sl_console.log "[updateConnectionState] Setting state to #{state}"
 			@connection.state = state
 			@connection.state = state
 			@connection.id = @socket.socket.sessionid
 			@connection.id = @socket.socket.sessionid
 			@_doc.autoOpen = false
 			@_doc.autoOpen = false
@@ -110,12 +112,14 @@ define [
 		detachFromAce: () -> @_doc.detach_ace?()
 		detachFromAce: () -> @_doc.detach_ace?()
 	
 	
 		INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack
 		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) ->
 		_startInflightOpTimeout: (update) ->
 			@_startFatalTimeoutTimer(update)
 			@_startFatalTimeoutTimer(update)
-			timer = setTimeout () =>
+			retryOp = () =>
 				# Only send the update again if inflightOp is still populated
 				# Only send the update again if inflightOp is still populated
 				# This can be cleared when hard reloading the document in which
 				# This can be cleared when hard reloading the document in which
 				# case we don't want to keep trying to send it.
 				# case we don't want to keep trying to send it.
+				sl_console.log "[inflightOpTimeout] Trying op again"
 				if @_doc.inflightOp?
 				if @_doc.inflightOp?
 					# When there is a socket.io disconnect, @_doc.inflightSubmittedIds
 					# When there is a socket.io disconnect, @_doc.inflightSubmittedIds
 					# is updated with the socket.io client id of the current op in flight
 					# 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
 					# 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.
 					# one or more disconnects, or if it was submitted during the current session.
 					update.dupIfSource = [@connection.id, @_doc.inflightSubmittedIds...]
 					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 () =>
 			@_doc.inflightCallbacks.push () =>
 				@_clearFatalTimeoutTimer()
 				@_clearFatalTimeoutTimer()
 				clearTimeout timer
 				clearTimeout timer

+ 14 - 13
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -1,13 +1,13 @@
 define [
 define [
 	"ide/editor/directives/aceEditor/auto-complete/SuggestionManager"
 	"ide/editor/directives/aceEditor/auto-complete/SuggestionManager"
-	"ide/editor/directives/aceEditor/auto-complete/Snippets"
+	"ide/editor/directives/aceEditor/auto-complete/SnippetManager"
 	"ace/ace"
 	"ace/ace"
 	"ace/ext-language_tools"
 	"ace/ext-language_tools"
-], (SuggestionManager, Snippets) ->
+], (SuggestionManager, SnippetManager) ->
 	Range = ace.require("ace/range").Range
 	Range = ace.require("ace/range").Range
 
 
 	getLastCommandFragment = (lineUpToCursor) ->
 	getLastCommandFragment = (lineUpToCursor) ->
-		if m = lineUpToCursor.match(/(\\[^\\ ]+)$/)
+		if m = lineUpToCursor.match(/(\\[^\\]+)$/)
 			return m[1]
 			return m[1]
 		else
 		else
 			return null
 			return null
@@ -38,19 +38,20 @@ define [
 				enableLiveAutocompletion: false
 				enableLiveAutocompletion: false
 			})
 			})
 
 
-			SnippetCompleter =
-				getCompletions: (editor, session, pos, prefix, callback) ->
-					callback null, Snippets
+			SnippetCompleter = new SnippetManager()
 
 
 			references = @$scope.$root._references
 			references = @$scope.$root._references
 			ReferencesCompleter =
 			ReferencesCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
 				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)
 					commandFragment = getLastCommandFragment(lineUpToCursor)
 					if commandFragment
 					if commandFragment
-						citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]?){(.*,)?(\w*)/)
+						citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/)
 						if citeMatch
 						if citeMatch
+							beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
+							lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange)
+							needsClosingBrace = !lineBeyondCursor.match(/^[^{]*}/)
 							commandName = citeMatch[1]
 							commandName = citeMatch[1]
 							previousArgs = citeMatch[2]
 							previousArgs = citeMatch[2]
 							currentArg = citeMatch[3]
 							currentArg = citeMatch[3]
@@ -59,8 +60,8 @@ define [
 							previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
 							previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
 							result = []
 							result = []
 							result.push {
 							result.push {
-								caption: "\\#{commandName}{",
-								snippet: "\\#{commandName}{",
+								caption: "\\#{commandName}{}",
+								snippet: "\\#{commandName}{}",
 								meta: "reference",
 								meta: "reference",
 								score: 11000
 								score: 11000
 							}
 							}
@@ -68,8 +69,8 @@ define [
 								references.keys.forEach (key) ->
 								references.keys.forEach (key) ->
 									if !(key in [null, undefined])
 									if !(key in [null, undefined])
 										result.push({
 										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",
 											meta: "reference",
 											score: 10000
 											score: 10000
 										})
 										})

+ 159 - 0
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

+ 0 - 100
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee

@@ -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

+ 13 - 2
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee

@@ -1,11 +1,23 @@
 define [], () ->
 define [], () ->
+
 	class Parser
 	class Parser
 		constructor: (@doc) ->
 		constructor: (@doc) ->
 
 
 		parse: () ->
 		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 = []
 			commands = []
 			seen = {}
 			seen = {}
+			iterations = 0
 			while command = @nextCommand()
 			while command = @nextCommand()
+				iterations += 1
+				if limit && iterations > limit
+					return commands
+
 				docState = @doc
 				docState = @doc
 
 
 				optionalArgs = 0
 				optionalArgs = 0
@@ -28,7 +40,7 @@ define [], () ->
 
 
 		# Ignore single letter commands since auto complete is moot then.
 		# Ignore single letter commands since auto complete is moot then.
 		commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
 		commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
-		
+
 		nextCommand: () ->
 		nextCommand: () ->
 			i = @doc.search(@commandRegex)
 			i = @doc.search(@commandRegex)
 			if i == -1
 			if i == -1
@@ -123,4 +135,3 @@ define [], () ->
 					completionBeforeCursor: completionBeforeCursor
 					completionBeforeCursor: completionBeforeCursor
 					completionAfterCursor: completionAfterCursor
 					completionAfterCursor: completionAfterCursor
 				}
 				}
-

+ 7 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee

@@ -192,6 +192,13 @@ class Doc
         callback error for callback in @inflightCallbacks
         callback error for callback in @inflightCallbacks
       else
       else
         # The op applied successfully.
         # 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
         throw new Error('Invalid version from server') unless msg.v == @version
 
 
         @serverOps[@version] = oldInflightOp
         @serverOps[@version] = oldInflightOp

+ 1 - 1
services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee

@@ -1,7 +1,7 @@
 define [
 define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
-	App.controller "FileTreeController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
+	App.controller "FileTreeController", ["$scope", "$modal", "ide", "$rootScope", ($scope, $modal, ide, $rootScope) ->
 		$scope.openNewDocModal = () ->
 		$scope.openNewDocModal = () ->
 			$modal.open(
 			$modal.open(
 				templateUrl: "newDocModalTemplate"
 				templateUrl: "newDocModalTemplate"

+ 13 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee

@@ -51,6 +51,19 @@ define [
 
 
 		$scope.$on "delete:selected", () ->
 		$scope.$on "delete:selected", () ->
 			$scope.openDeleteModal() if $scope.entity.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", [
 	App.controller "DeleteEntityModalController", [

+ 139 - 64
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -4,7 +4,13 @@ define [
 	"libs/bib-log-parser"
 	"libs/bib-log-parser"
 ], (App, LogParser, BibLogParser) ->
 ], (App, LogParser, BibLogParser) ->
 	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
 	App.controller "PdfController", ($scope, $http, ide, $modal, synctex, event_tracking, localStorage) ->
+
 		autoCompile = true
 		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", () ->
 		$scope.$on "project:joined", () ->
 			return if !autoCompile
 			return if !autoCompile
 			autoCompile = false
 			autoCompile = false
@@ -12,7 +18,8 @@ define [
 			$scope.hasPremiumCompile = $scope.project.features.compileGroup == "priority"
 			$scope.hasPremiumCompile = $scope.project.features.compileGroup == "priority"
 
 
 		$scope.$on "pdf:error:display", () ->
 		$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.draft = localStorage("draft:#{$scope.project_id}") or false
 		$scope.$watch "draft", (new_value, old_value) ->
 		$scope.$watch "draft", (new_value, old_value) ->
@@ -29,41 +36,72 @@ define [
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}
 			}
 
 
-		parseCompileResponse = (response) ->
+		parseCompileResponse = (response) ->		
+
 			# Reset everything
 			# Reset everything
 			$scope.pdf.error      = false
 			$scope.pdf.error      = false
 			$scope.pdf.timedout   = false
 			$scope.pdf.timedout   = false
 			$scope.pdf.failure    = false
 			$scope.pdf.failure    = false
-			$scope.pdf.uncompiled = false
 			$scope.pdf.projectTooLarge = false
 			$scope.pdf.projectTooLarge = false
 			$scope.pdf.url        = null
 			$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"
 			if response.status == "timedout"
+				$scope.pdf.view = 'errors'
 				$scope.pdf.timedout = true
 				$scope.pdf.timedout = true
 			else if response.status == "autocompile-backoff"
 			else if response.status == "autocompile-backoff"
-				$scope.pdf.uncompiled = true
+				$scope.pdf.view = 'uncompiled'
 			else if response.status == "project-too-large"
 			else if response.status == "project-too-large"
+				$scope.pdf.view = 'errors'
 				$scope.pdf.projectTooLarge = true
 				$scope.pdf.projectTooLarge = true
 			else if response.status == "failure"
 			else if response.status == "failure"
+				$scope.pdf.view = 'errors'
 				$scope.pdf.failure = true
 				$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"
 			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
 				# add a query string parameter for the compile group
 				if response.compileGroup?
 				if response.compileGroup?
 					$scope.pdf.compileGroup = 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"]
 			IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
 			$scope.pdf.outputFiles = []
 			$scope.pdf.outputFiles = []
@@ -77,51 +115,84 @@ define [
 						file.name = "#{file.path.replace(/^output\./, "")} file"
 						file.name = "#{file.path.replace(/^output\./, "")} file"
 					else
 					else
 						file.name = file.path
 						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
 					$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 = () ->
 		getRootDocOverride_id = () ->
 			doc = ide.editorManager.getCurrentDocValue()
 			doc = ide.editorManager.getCurrentDocValue()
@@ -157,7 +228,9 @@ define [
 					parseCompileResponse(data)
 					parseCompileResponse(data)
 				.error () ->
 				.error () ->
 					$scope.pdf.compiling = false
 					$scope.pdf.compiling = false
+					$scope.pdf.renderingError = false
 					$scope.pdf.error = true
 					$scope.pdf.error = true
+					$scope.pdf.view = 'errors'
 
 
 		# This needs to be public.
 		# This needs to be public.
 		ide.$scope.recompile = $scope.recompile
 		ide.$scope.recompile = $scope.recompile
@@ -166,18 +239,18 @@ define [
 			$http {
 			$http {
 				url: "/project/#{$scope.project_id}/output"
 				url: "/project/#{$scope.project_id}/output"
 				method: "DELETE"
 				method: "DELETE"
+				params:
+					clsiserverid:ide.clsiServerId
 				headers:
 				headers:
 					"X-Csrf-Token": window.csrfToken
 					"X-Csrf-Token": window.csrfToken
 			}
 			}
 
 
 		$scope.toggleLogs = () ->
 		$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.showPdf = () ->
 			$scope.pdf.view = "pdf"
 			$scope.pdf.view = "pdf"
+			$scope.shouldShowLogs = false
 
 
 		$scope.toggleRawLog = () ->
 		$scope.toggleRawLog = () ->
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
 			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
@@ -250,6 +323,7 @@ define [
 							file: path
 							file: path
 							line: row + 1
 							line: row + 1
 							column: column
 							column: column
+							clsiserverid:ide.clsiServerId
 						}
 						}
 					})
 					})
 					.success (data) ->
 					.success (data) ->
@@ -277,6 +351,7 @@ define [
 							page: position.page + 1
 							page: position.page + 1
 							h: position.offset.left.toFixed(2)
 							h: position.offset.left.toFixed(2)
 							v: position.offset.top.toFixed(2)
 							v: position.offset.top.toFixed(2)
+							clsiserverid:ide.clsiServerId
 						}
 						}
 					})
 					})
 					.success (data) ->
 					.success (data) ->

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

@@ -16,7 +16,7 @@ define [
 				# PDFJS.disableStream
 				# PDFJS.disableStream
 				# PDFJS.disableRange
 				# PDFJS.disableRange
 				@scale = @options.scale || 1
 				@scale = @options.scale || 1
-				@pdfjs = PDFJS.getDocument @url
+				@pdfjs = PDFJS.getDocument {url: @url, rangeChunkSize: 2*65536}
 				@pdfjs.onProgress = @options.progressCallback
 				@pdfjs.onProgress = @options.progressCallback
 				@document = $q.when(@pdfjs)
 				@document = $q.when(@pdfjs)
 				@navigateFn = @options.navigateFn
 				@navigateFn = @options.navigateFn

+ 7 - 6
services/web/public/coffee/ide/pdfng/directives/pdfViewer.coffee

@@ -290,11 +290,18 @@ define [
 						rescaleTimer = null
 						rescaleTimer = null
 					, 0
 					, 0
 
 
+				spinnerTimer = null
 				doRescale = (scale) ->
 				doRescale = (scale) ->
 					# console.log 'doRescale', scale
 					# console.log 'doRescale', scale
 					return unless scale?
 					return unless scale?
 					origposition = angular.copy scope.position
 					origposition = angular.copy scope.position
 					# console.log 'origposition', origposition
 					# console.log 'origposition', origposition
+					
+					if not spinnerTimer?
+						spinnerTimer = setTimeout () ->
+							spinner.add(element)
+							spinnerTimer = null
+						, 100
 					layoutReady.promise.then (parentSize) ->
 					layoutReady.promise.then (parentSize) ->
 						[h, w] = parentSize
 						[h, w] = parentSize
 						# console.log 'in promise', h, w
 						# console.log 'in promise', h, w
@@ -312,7 +319,6 @@ define [
 							scope.$emit 'pdf:error', error
 							scope.$emit 'pdf:error', error
 
 
 				elementTimer = null
 				elementTimer = null
-				spinnerTimer = null
 				updateLayout = () ->
 				updateLayout = () ->
 					# if element is zero-sized keep checking until it is ready
 					# if element is zero-sized keep checking until it is ready
 					# console.log 'checking element ready', element.height(), element.width()
 					# console.log 'checking element ready', element.height(), element.width()
@@ -329,11 +335,6 @@ define [
 						]
 						]
 						# console.log 'resolving layoutReady with', scope.parentSize
 						# console.log 'resolving layoutReady with', scope.parentSize
 						$timeout () ->
 						$timeout () ->
-							if not spinnerTimer?
-								spinnerTimer = setTimeout () ->
-									spinner.add(element)
-									spinnerTimer = null
-								, 100
 							layoutReady.resolve scope.parentSize
 							layoutReady.resolve scope.parentSize
 							scope.$emit 'flash-controls'
 							scope.$emit 'flash-controls'
 
 

+ 3 - 0
services/web/public/coffee/ide/references/ReferencesManager.coffee

@@ -11,6 +11,9 @@ define [
 					if entity?.name?.match /.*\.bib$/
 					if entity?.name?.match /.*\.bib$/
 						@indexReferences([doc.doc_id], true)
 						@indexReferences([doc.doc_id], true)
 
 
+			@$scope.$on 'references:should-reindex', (e, data) =>
+				@indexAllReferences(true)
+
 			# When we join the project:
 			# When we join the project:
 			#   index all references files
 			#   index all references files
 			#   and don't broadcast to all clients
 			#   and don't broadcast to all clients

+ 5 - 0
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -10,5 +10,10 @@ define [
 			saveProjectSettings: (data) ->
 			saveProjectSettings: (data) ->
 				data._csrf = window.csrfToken
 				data._csrf = window.csrfToken
 				ide.$http.post "/project/#{ide.project_id}/settings", data
 				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
+				
 		}
 		}
 	]
 	]

+ 2 - 2
services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee

@@ -143,7 +143,7 @@ define [
 
 
 		$scope.makePublic = () ->
 		$scope.makePublic = () ->
 			$scope.project.publicAccesLevel = $scope.inputs.privileges
 			$scope.project.publicAccesLevel = $scope.inputs.privileges
-			settings.saveProjectSettings({publicAccessLevel: $scope.inputs.privileges})
+			settings.saveProjectAdminSettings({publicAccessLevel: $scope.inputs.privileges})
 			$modalInstance.close()
 			$modalInstance.close()
 
 
 		$scope.cancel = () ->
 		$scope.cancel = () ->
@@ -153,7 +153,7 @@ define [
 	App.controller "MakePrivateModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
 	App.controller "MakePrivateModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
 		$scope.makePrivate = () ->
 		$scope.makePrivate = () ->
 			$scope.project.publicAccesLevel = "private"
 			$scope.project.publicAccesLevel = "private"
-			settings.saveProjectSettings({publicAccessLevel: "private"})
+			settings.saveProjectAdminSettings({publicAccessLevel: "private"})
 			$modalInstance.close()
 			$modalInstance.close()
 
 
 		$scope.cancel = () ->
 		$scope.cancel = () ->

+ 6 - 2
services/web/public/coffee/ide/wordcount/controllers/WordCountModalController.coffee

@@ -5,11 +5,15 @@ define [
 		$scope.status = 
 		$scope.status = 
 			loading:true
 			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) ->
 			.success (data) ->
 				$scope.status.loading = false
 				$scope.status.loading = false
 				$scope.data = data.texcount
 				$scope.data = data.texcount
-				console.log $scope.data
 			.error () ->
 			.error () ->
 				$scope.status.error = true
 				$scope.status.error = true
 
 

+ 1 - 0
services/web/public/coffee/libs.coffee

@@ -12,6 +12,7 @@ define [
 	"libs/angular-cookies"
 	"libs/angular-cookies"
 	"libs/passfield"
 	"libs/passfield"
 	"libs/sixpack"
 	"libs/sixpack"
+	"libs/groove"
 	"libs/angular-sixpack"
 	"libs/angular-sixpack"
 	"libs/ng-tags-input-3.0.0"
 	"libs/ng-tags-input-3.0.0"
 ], () ->
 ], () ->

+ 5 - 1
services/web/public/coffee/main.coffee

@@ -15,7 +15,8 @@ define [
 	"main/annual-upgrade"
 	"main/annual-upgrade"
 	"main/register-users"
 	"main/register-users"
 	"main/subscription/group-subscription-invite-controller"
 	"main/subscription/group-subscription-invite-controller"
-	"main/universties-site"
+	"main/contact-us"
+	"main/learn"
 	"analytics/AbTestingManager"
 	"analytics/AbTestingManager"
 	"directives/asyncForm"
 	"directives/asyncForm"
 	"directives/stopPropagation"
 	"directives/stopPropagation"
@@ -30,3 +31,6 @@ define [
 	"__MAIN_CLIENTSIDE_INCLUDES__"
 	"__MAIN_CLIENTSIDE_INCLUDES__"
 ], () ->
 ], () ->
 	angular.bootstrap(document.body, ["SharelatexApp"])
 	angular.bootstrap(document.body, ["SharelatexApp"])
+
+	
+

+ 66 - 0
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: "<div>browser: #{platform?.name} #{platform?.version}</div>
+						<div>os: #{platform?.os?.family} #{platform?.os?.version}</div>"
+
+			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()
+
+

+ 92 - 0
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(/<em>/)
+						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()

+ 3 - 3
services/web/public/coffee/main/project-list/modal-controllers.coffee

@@ -65,11 +65,11 @@ define [
 		$scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner"
 		$scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner"
 
 
 		if $scope.projectsToLeave.length > 0 and $scope.projectsToDelete.length > 0
 		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
 		else if $scope.projectsToLeave.length == 0 and $scope.projectsToDelete.length > 0
-			$scope.action = "Delete"
+			$scope.action = "delete"
 		else
 		else
-			$scope.action = "Leave"
+			$scope.action = "leave"
 
 
 		$scope.delete = () ->
 		$scope.delete = () ->
 			$modalInstance.close()
 			$modalInstance.close()

+ 22 - 3
services/web/public/coffee/main/subscription/group-subscription-invite-controller.coffee

@@ -3,12 +3,31 @@ define [
 ], (App) ->
 ], (App) ->
 	App.controller "GroupSubscriptionInviteController", ($scope, $http) ->
 	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.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)->
 			request.success (data, status)->
+				$scope.inflight = false
 				if status != 200 # assume request worked
 				if status != 200 # assume request worked
 					$scope.requestSent = false
 					$scope.requestSent = false
 			request.error (data, status)->
 			request.error (data, status)->

+ 62 - 0
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(/<em>/)
+						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

+ 0 - 26
services/web/public/coffee/main/universties-site.coffee

@@ -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()

+ 1 - 0
services/web/public/coffee/main/user-details.coffee

@@ -16,6 +16,7 @@ define [
 				_csrf : window.csrfToken
 				_csrf : window.csrfToken
 
 
 		$scope.showForm = ->
 		$scope.showForm = ->
+			GrooveWidget.toggle()
 			$scope.formVisable = true
 			$scope.formVisable = true
 
 
 		$scope.getPercentComplete = ->
 		$scope.getPercentComplete = ->

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor