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

Merge branch 'master' into node-4.2

Henry Oswald 10 лет назад
Родитель
Сommit
f4cbcc22ba
100 измененных файлов с 2170 добавлено и 867 удалено
  1. 2 2
      services/web/Gruntfile.coffee
  2. 8 0
      services/web/app.coffee
  3. 10 6
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  4. 0 1
      services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
  5. 1 3
      services/web/app/coffee/Features/Blog/BlogController.coffee
  6. 10 5
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  7. 2 0
      services/web/app/coffee/Features/Compile/CompileController.coffee
  8. 3 3
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  9. 9 4
      services/web/app/coffee/Features/Documents/DocumentController.coffee
  10. 30 10
      services/web/app/coffee/Features/Editor/EditorController.coffee
  11. 13 4
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  12. 0 3
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  13. 20 11
      services/web/app/coffee/Features/Email/EmailSender.coffee
  14. 24 16
      services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee
  15. 16 0
      services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee
  16. 19 0
      services/web/app/coffee/Features/Notifications/NotificationsController.coffee
  17. 52 0
      services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee
  18. 11 4
      services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee
  19. 3 3
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  20. 15 7
      services/web/app/coffee/Features/Project/ProjectController.coffee
  21. 5 1
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  22. 8 5
      services/web/app/coffee/Features/Project/ProjectDeleter.coffee
  23. 2 2
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  24. 79 54
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  25. 7 1
      services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee
  26. 179 71
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  27. 26 3
      services/web/app/coffee/Features/Project/ProjectGetter.coffee
  28. 17 8
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  29. 37 0
      services/web/app/coffee/Features/References/ReferencesController.coffee
  30. 85 0
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  31. 1 1
      services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee
  32. 0 1
      services/web/app/coffee/Features/Spelling/SpellingController.coffee
  33. 1 0
      services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee
  34. 8 2
      services/web/app/coffee/Features/Subscription/LimitationsManager.coffee
  35. 12 0
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  36. 10 4
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  37. 4 27
      services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee
  38. 25 9
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  39. 29 5
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  40. 2 2
      services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee
  41. 1 1
      services/web/app/coffee/Features/Subscription/SubscriptionLocator.coffee
  42. 3 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  43. 7 2
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  44. 1 0
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  45. 1 2
      services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee
  46. 48 15
      services/web/app/coffee/Features/Tags/TagsController.coffee
  47. 69 50
      services/web/app/coffee/Features/Tags/TagsHandler.coffee
  48. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee
  49. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee
  50. 6 0
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee
  51. 4 4
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  52. 85 12
      services/web/app/coffee/Features/Uploads/ArchiveManager.coffee
  53. 94 48
      services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee
  54. 3 1
      services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee
  55. 6 4
      services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee
  56. 2 2
      services/web/app/coffee/Features/Uploads/UploadsRouter.coffee
  57. 17 32
      services/web/app/coffee/Features/User/UserController.coffee
  58. 26 0
      services/web/app/coffee/Features/User/UserHandler.coffee
  59. 25 1
      services/web/app/coffee/Features/User/UserPagesController.coffee
  60. 30 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  61. 3 3
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  62. 2 45
      services/web/app/coffee/models/Project.coffee
  63. 2 0
      services/web/app/coffee/models/User.coffee
  64. 24 10
      services/web/app/coffee/router.coffee
  65. 11 5
      services/web/app/views/project/editor.jade
  66. 2 0
      services/web/app/views/project/editor/editor.jade
  67. 57 35
      services/web/app/views/project/editor/file-tree.jade
  68. 8 2
      services/web/app/views/project/editor/hotkeys.jade
  69. 47 27
      services/web/app/views/project/editor/pdf.jade
  70. 81 43
      services/web/app/views/project/editor/publish-template.jade
  71. 28 25
      services/web/app/views/project/editor/share.jade
  72. 13 57
      services/web/app/views/project/editor/track-changes.jade
  73. 4 1
      services/web/app/views/project/list.jade
  74. 61 2
      services/web/app/views/project/list/modals.jade
  75. 15 0
      services/web/app/views/project/list/notifications.jade
  76. 2 2
      services/web/app/views/project/list/project-list.jade
  77. 62 34
      services/web/app/views/project/list/side-bar.jade
  78. 3 3
      services/web/app/views/sentry.jade
  79. 1 1
      services/web/app/views/subscriptions/custom_account.jade
  80. 63 12
      services/web/app/views/subscriptions/dashboard.jade
  81. 0 1
      services/web/app/views/subscriptions/new.jade
  82. 30 24
      services/web/app/views/subscriptions/plans.jade
  83. 8 4
      services/web/app/views/subscriptions/successful_subscription.jade
  84. 64 0
      services/web/app/views/user/activate.jade
  85. 1 0
      services/web/app/views/user/login.jade
  86. 5 4
      services/web/app/views/user/setPassword.jade
  87. 10 1
      services/web/config/settings.defaults.coffee
  88. 7 2
      services/web/package.json
  89. 17 1
      services/web/public/coffee/directives/fineUpload.coffee
  90. 7 3
      services/web/public/coffee/ide.coffee
  91. 6 2
      services/web/public/coffee/ide/directives/layout.coffee
  92. 21 0
      services/web/public/coffee/ide/editor/Document.coffee
  93. 2 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  94. 20 3
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  95. 40 4
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  96. 20 20
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee
  97. 58 13
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
  98. 65 5
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  99. 55 6
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  100. 30 11
      services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee

+ 2 - 2
services/web/Gruntfile.coffee

@@ -95,10 +95,10 @@ module.exports = (grunt) ->
 					paths:
 					paths:
 						"moment": "libs/moment-2.9.0"
 						"moment": "libs/moment-2.9.0"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
-						"libs/pdf": "libs/pdfjs-1.0.1040/pdf"
+						"libs/pdf": "libs/pdfjs-1.3.91/pdf"
 					shim:
 					shim:
 						"libs/pdf":
 						"libs/pdf":
-							deps: ["libs/pdfjs-1.0.1040/compatibility"]
+							deps: ["libs/pdfjs-1.3.91/compatibility"]
 
 
 					skipDirOptimize: true
 					skipDirOptimize: true
 					modules: [
 					modules: [

+ 8 - 0
services/web/app.coffee

@@ -3,8 +3,12 @@ logger = require 'logger-sharelatex'
 logger.initialize("web-sharelatex")
 logger.initialize("web-sharelatex")
 logger.logger.serializers.user = require("./app/js/infrastructure/LoggerSerializers").user
 logger.logger.serializers.user = require("./app/js/infrastructure/LoggerSerializers").user
 logger.logger.serializers.project = require("./app/js/infrastructure/LoggerSerializers").project
 logger.logger.serializers.project = require("./app/js/infrastructure/LoggerSerializers").project
+if Settings.sentry?.dsn?
+	logger.initializeErrorReporting(Settings.sentry.dsn)
+
 metrics = require("metrics-sharelatex")
 metrics = require("metrics-sharelatex")
 metrics.initialize("web")
 metrics.initialize("web")
+metrics.memory.monitor(logger)
 Server = require("./app/js/infrastructure/Server")
 Server = require("./app/js/infrastructure/Server")
 Errors = require "./app/js/errors"
 Errors = require "./app/js/errors"
 
 
@@ -15,6 +19,10 @@ argv = require("optimist")
 	.argv
 	.argv
 
 
 Server.app.use (error, req, res, next) ->
 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"
 	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
 	res.statusCode = error.status or 500
 	if res.statusCode == 500
 	if res.statusCode == 500

+ 10 - 6
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -8,13 +8,16 @@ querystring = require('querystring')
 Url = require("url")
 Url = require("url")
 Settings = require "settings-sharelatex"
 Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
 basicAuth = require('basic-auth-connect')
-
+UserHandler = require("../User/UserHandler")
 
 
 module.exports = AuthenticationController =
 module.exports = AuthenticationController =
 	login: (req, res, next = (error) ->) ->
 	login: (req, res, next = (error) ->) ->
-		email = req.body?.email?.toLowerCase()
-		password = req.body?.password
-		redir = Url.parse(req.body?.redir or "/project").path
+		AuthenticationController.doLogin req.body, req, res, next
+	
+	doLogin: (options, req, res, next) ->
+		email = options.email?.toLowerCase()
+		password = options.password
+		redir = Url.parse(options.redir or "/project").path
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 		LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
 			if !isAllowed
 			if !isAllowed
 				logger.log email:email, "too many login requests"
 				logger.log email:email, "too many login requests"
@@ -26,17 +29,18 @@ module.exports = AuthenticationController =
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
 				return next(error) if error?
 				return next(error) if error?
 				if user?
 				if user?
+					UserHandler.setupLoginData user, ->
 					LoginRateLimiter.recordSuccessfulLogin email
 					LoginRateLimiter.recordSuccessfulLogin email
 					AuthenticationController._recordSuccessfulLogin user._id
 					AuthenticationController._recordSuccessfulLogin user._id
 					AuthenticationController.establishUserSession req, user, (error) ->
 					AuthenticationController.establishUserSession req, user, (error) ->
 						return next(error) if error?
 						return next(error) if error?
 						req.session.justLoggedIn = true
 						req.session.justLoggedIn = true
 						logger.log email: email, user_id: user._id.toString(), "successful log in"
 						logger.log email: email, user_id: user._id.toString(), "successful log in"
-						res.send redir: redir
+						res.json redir: redir
 				else
 				else
 					AuthenticationController._recordFailedLogin()
 					AuthenticationController._recordFailedLogin()
 					logger.log email: email, "failed log in"
 					logger.log email: email, "failed log in"
-					res.send message:
+					res.json message:
 						text: req.i18n.translate("email_or_password_wrong_try_again"),
 						text: req.i18n.translate("email_or_password_wrong_try_again"),
 						type: 'error'
 						type: 'error'
 
 

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

@@ -1,4 +1,3 @@
-Settings = require 'settings-sharelatex'
 User = require("../../models/User").User
 User = require("../../models/User").User
 {db, ObjectId} = require("../../infrastructure/mongojs")
 {db, ObjectId} = require("../../infrastructure/mongojs")
 crypto = require 'crypto'
 crypto = require 'crypto'

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

@@ -4,15 +4,13 @@ logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
 ErrorController = require "../Errors/ErrorController"
 ErrorController = require "../Errors/ErrorController"
 
 
-extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps"]
-
 module.exports = BlogController = 
 module.exports = BlogController = 
 
 
 	getPage: (req, res, next)->
 	getPage: (req, res, next)->
 		url = req.url?.toLowerCase()
 		url = req.url?.toLowerCase()
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 
 
-		extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps"]
+		extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps", ".gif"]
 
 
 		shouldProxy = _.find extensionsToProxy, (extension)->
 		shouldProxy = _.find extensionsToProxy, (extension)->
 			url.indexOf(extension) != -1
 			url.indexOf(extension) != -1

+ 10 - 5
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -58,8 +58,8 @@ module.exports = ClsiManager =
 		return outputFiles
 		return outputFiles
 
 
 	VALID_COMPILERS: ["pdflatex", "latex", "xelatex", "lualatex"]
 	VALID_COMPILERS: ["pdflatex", "latex", "xelatex", "lualatex"]
-	_buildRequest: (project_id, settingsOverride={}, callback = (error, request) ->) ->
-		Project.findById project_id, {compiler: 1, rootDoc_id: 1}, (error, project) ->
+	_buildRequest: (project_id, options={}, callback = (error, request) ->) ->
+		Project.findById project_id, {compiler: 1, rootDoc_id: 1, imageName: 1}, (error, project) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			return callback(new Errors.NotFoundError("project does not exist: #{project_id}")) if !project?
 			return callback(new Errors.NotFoundError("project does not exist: #{project_id}")) if !project?
 
 
@@ -82,7 +82,7 @@ module.exports = ClsiManager =
 							content: doc.lines.join("\n")
 							content: doc.lines.join("\n")
 						if project.rootDoc_id? and doc._id.toString() == project.rootDoc_id.toString()
 						if project.rootDoc_id? and doc._id.toString() == project.rootDoc_id.toString()
 							rootResourcePath = path
 							rootResourcePath = path
-						if settingsOverride.rootDoc_id? and doc._id.toString() == settingsOverride.rootDoc_id.toString()
+						if options.rootDoc_id? and doc._id.toString() == options.rootDoc_id.toString()
 							rootResourcePathOverride = path
 							rootResourcePathOverride = path
 
 
 					rootResourcePath = rootResourcePathOverride if rootResourcePathOverride?
 					rootResourcePath = rootResourcePathOverride if rootResourcePathOverride?
@@ -101,7 +101,9 @@ module.exports = ClsiManager =
 							compile:
 							compile:
 								options:
 								options:
 									compiler: project.compiler
 									compiler: project.compiler
-									timeout: settingsOverride.timeout
+									timeout: options.timeout
+									imageName: project.imageName
+									draft: !!options.draft
 								rootResourcePath: rootResourcePath
 								rootResourcePath: rootResourcePath
 								resources: resources
 								resources: resources
 						}
 						}
@@ -110,8 +112,11 @@ module.exports = ClsiManager =
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 			compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
 			compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
 			filename = file || req?.compile?.rootResourcePath
 			filename = file || req?.compile?.rootResourcePath
+			wordcount_url = "#{compilerUrl}/project/#{project_id}/wordcount?file=#{encodeURIComponent(filename)}"
+			if req.compile.options.imageName?
+				wordcount_url += "&image=#{encodeURIComponent(req.compile.options.imageName)}"
 			request.get {
 			request.get {
-				url:  "#{compilerUrl}/project/#{project_id}/wordcount?file=#{filename}"
+				url: wordcount_url
 			}, (error, response, body) ->
 			}, (error, response, body) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if 200 <= response.statusCode < 300
 				if 200 <= response.statusCode < 300

+ 2 - 0
services/web/app/coffee/Features/Compile/CompileController.coffee

@@ -25,6 +25,8 @@ module.exports = CompileController =
 				options.rootDoc_id = req.body.settingsOverride.rootDoc_id
 				options.rootDoc_id = req.body.settingsOverride.rootDoc_id
 			if req.body?.compiler
 			if req.body?.compiler
 				options.compiler = req.body.compiler
 				options.compiler = req.body.compiler
+			if 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, output, limits) ->
 				return next(error) if error?
 				return next(error) if error?

+ 3 - 3
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -1,6 +1,5 @@
 request = require 'request'
 request = require 'request'
 request = request.defaults()
 request = request.defaults()
-async = require 'async'
 settings = require 'settings-sharelatex'
 settings = require 'settings-sharelatex'
 _ = require 'underscore'
 _ = require 'underscore'
 async = require 'async'
 async = require 'async'
@@ -116,7 +115,7 @@ module.exports = DocumentUpdaterHandler =
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				logger.error project_id:project_id, doc_id:doc_id, url: url, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 
-	setDocument : (project_id, doc_id, docLines, source, callback = (error) ->)->
+	setDocument : (project_id, doc_id, user_id, docLines, source, callback = (error) ->)->
 		timer = new metrics.Timer("set-document")
 		timer = new metrics.Timer("set-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}"
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}"
 		body =
 		body =
@@ -124,7 +123,8 @@ module.exports = DocumentUpdaterHandler =
 			json:
 			json:
 				lines: docLines
 				lines: docLines
 				source: source
 				source: source
-		logger.log project_id:project_id, doc_id: doc_id, source: source, "setting doc in document updater"
+				user_id: user_id
+		logger.log project_id:project_id, doc_id: doc_id, source: source, user_id: user_id, "setting doc in document updater"
 		request.post body, (error, res, body)->
 		request.post body, (error, res, body)->
 			timer.done()
 			timer.done()
 			if error?
 			if error?

+ 9 - 4
services/web/app/coffee/Features/Documents/DocumentController.coffee

@@ -6,15 +6,20 @@ module.exports =
 	getDocument: (req, res, next = (error) ->) ->
 	getDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		doc_id = req.params.doc_id
 		doc_id = req.params.doc_id
+		plain = req?.query?.plain == 'true'
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
 		logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
 		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
 		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
 			if error?
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				return next(error)
 				return next(error)
-			res.type "json"
-			res.send JSON.stringify {
-				lines: lines
-			}
+			if plain
+				res.type "text/plain"
+				res.send lines.join('\n')
+			else
+				res.type "json"
+				res.send JSON.stringify {
+					lines: lines
+				}
 
 
 	setDocument: (req, res, next = (error) ->) ->
 	setDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id

+ 30 - 10
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -13,8 +13,8 @@ LockManager = require("../../infrastructure/LockManager")
 _ = require('underscore')
 _ = require('underscore')
 
 
 module.exports = EditorController =
 module.exports = EditorController =
-	setDoc: (project_id, doc_id, docLines, source, callback = (err)->)->
-		DocumentUpdaterHandler.setDocument project_id, doc_id, docLines, source, (err)=>
+	setDoc: (project_id, doc_id, user_id, docLines, source, callback = (err)->)->
+		DocumentUpdaterHandler.setDocument project_id, doc_id, user_id, docLines, source, (err)=>
 			logger.log project_id:project_id, doc_id:doc_id, "notifying users that the document has been updated"
 			logger.log project_id:project_id, doc_id:doc_id, "notifying users that the document has been updated"
 			DocumentUpdaterHandler.flushDocToMongo project_id, doc_id, callback
 			DocumentUpdaterHandler.flushDocToMongo project_id, doc_id, callback
 
 
@@ -33,10 +33,12 @@ module.exports = EditorController =
 		logger.log {project_id, folder_id, docName, source}, "sending new doc to project"
 		logger.log {project_id, folder_id, docName, source}, "sending new doc to project"
 		Metrics.inc "editor.add-doc"
 		Metrics.inc "editor.add-doc"
 		ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, (err, doc, folder_id)=>
 		ProjectEntityHandler.addDoc project_id, folder_id, docName, docLines, (err, doc, folder_id)=>
+			if err?
+				logger.err err:err, project_id:project_id, docName:docName, "error adding doc without lock"
+				return callback(err)
 			EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc, source)
 			EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc, source)
 			callback(err, doc)
 			callback(err, doc)
 
 
-
 	addFile: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 	addFile: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 		LockManager.getLock project_id, (err)->
 		LockManager.getLock project_id, (err)->
 			if err?
 			if err?
@@ -46,20 +48,20 @@ module.exports = EditorController =
 				LockManager.releaseLock project_id, ->
 				LockManager.releaseLock project_id, ->
 					callback(error, file)	
 					callback(error, file)	
 
 
-
 	addFileWithoutLock: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 	addFileWithoutLock: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 		fileName = fileName.trim()
 		fileName = fileName.trim()
 		logger.log {project_id, folder_id, fileName, path}, "sending new file to project"
 		logger.log {project_id, folder_id, fileName, path}, "sending new file to project"
 		Metrics.inc "editor.add-file"
 		Metrics.inc "editor.add-file"
 		ProjectEntityHandler.addFile project_id, folder_id, fileName, path, (err, fileRef, folder_id)=>
 		ProjectEntityHandler.addFile project_id, folder_id, fileName, path, (err, fileRef, folder_id)=>
+			if err?
+				logger.err err:err, project_id:project_id, folder_id:folder_id, fileName:fileName, "error adding file without lock"
+				return callback(err)
 			EditorRealTimeController.emitToRoom(project_id, 'reciveNewFile', folder_id, fileRef, source)
 			EditorRealTimeController.emitToRoom(project_id, 'reciveNewFile', folder_id, fileRef, source)
 			callback(err, fileRef)
 			callback(err, fileRef)
 
 
 	replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)->
 	replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)->
 		ProjectEntityHandler.replaceFile project_id, file_id, fsPath, callback
 		ProjectEntityHandler.replaceFile project_id, file_id, fsPath, callback
 
 
-
-
 	addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)->
 	addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)->
 		LockManager.getLock project_id, (err)->
 		LockManager.getLock project_id, (err)->
 			if err?
 			if err?
@@ -74,6 +76,9 @@ module.exports = EditorController =
 		logger.log {project_id, folder_id, folderName, source}, "sending new folder to project"
 		logger.log {project_id, folder_id, folderName, source}, "sending new folder to project"
 		Metrics.inc "editor.add-folder"
 		Metrics.inc "editor.add-folder"
 		ProjectEntityHandler.addFolder project_id, folder_id, folderName, (err, folder, folder_id)=>
 		ProjectEntityHandler.addFolder project_id, folder_id, folderName, (err, folder, folder_id)=>
+			if err?
+				logger.err err:err, project_id:project_id, folder_id:folder_id, folderName:folderName, "error adding folder without lock"
+				return callback(err)
 			@p.notifyProjectUsersOfNewFolder project_id, folder_id, folder, (error) ->
 			@p.notifyProjectUsersOfNewFolder project_id, folder_id, folder, (error) ->
 				callback error, folder
 				callback error, folder
 
 
@@ -90,6 +95,9 @@ module.exports = EditorController =
 	mkdirpWithoutLock: (project_id, path, callback)->
 	mkdirpWithoutLock: (project_id, path, callback)->
 		logger.log project_id:project_id, path:path, "making directories if they don't exist"
 		logger.log project_id:project_id, path:path, "making directories if they don't exist"
 		ProjectEntityHandler.mkdirp project_id, path, (err, newFolders, lastFolder)=>
 		ProjectEntityHandler.mkdirp project_id, path, (err, newFolders, lastFolder)=>
+			if err?
+				logger.err err:err, project_id:project_id, path:path, "error mkdirp without lock"
+				return callback(err)
 			self = @
 			self = @
 			jobs = _.map newFolders, (folder, index)->
 			jobs = _.map newFolders, (folder, index)->
 				return (cb)->
 				return (cb)->
@@ -109,7 +117,10 @@ module.exports = EditorController =
 	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)->
 	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)->
 		logger.log {project_id, entity_id, entityType, source}, "start delete process of entity"
 		logger.log {project_id, entity_id, entityType, source}, "start delete process of entity"
 		Metrics.inc "editor.delete-entity"
 		Metrics.inc "editor.delete-entity"
-		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, =>
+		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)->
+			if err?
+				logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity"
+				return callback(err)
 			logger.log project_id:project_id, entity_id:entity_id, entityType:entityType, "telling users entity has been deleted"
 			logger.log project_id:project_id, entity_id:entity_id, entityType:entityType, "telling users entity has been deleted"
 			EditorRealTimeController.emitToRoom(project_id, 'removeEntity', entity_id, source)
 			EditorRealTimeController.emitToRoom(project_id, 'removeEntity', entity_id, source)
 			if callback?
 			if callback?
@@ -143,19 +154,28 @@ module.exports = EditorController =
 		newName = sanitize.escape(newName)
 		newName = sanitize.escape(newName)
 		Metrics.inc "editor.rename-entity"
 		Metrics.inc "editor.rename-entity"
 		logger.log entity_id:entity_id, entity_id:entity_id, entity_id:entity_id, "reciving new name for entity for project"
 		logger.log entity_id:entity_id, entity_id:entity_id, entity_id:entity_id, "reciving new name for entity for project"
-		ProjectEntityHandler.renameEntity project_id, entity_id, entityType, newName, =>
+		ProjectEntityHandler.renameEntity project_id, entity_id, entityType, newName, ->
+			if err?
+				logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, newName:newName, "error renaming entity"
+				return callback(err)
 			if newName.length > 0
 			if newName.length > 0
 				EditorRealTimeController.emitToRoom project_id, 'reciveEntityRename', entity_id, newName
 				EditorRealTimeController.emitToRoom project_id, 'reciveEntityRename', entity_id, newName
 				callback?()
 				callback?()
-#
+
 	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, =>
 		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)
 			EditorRealTimeController.emitToRoom project_id, 'reciveEntityMove', entity_id, folder_id
 			EditorRealTimeController.emitToRoom project_id, 'reciveEntityMove', entity_id, folder_id
 			callback?()
 			callback?()
 
 
 	renameProject: (project_id, newName, callback = (err) ->) ->
 	renameProject: (project_id, newName, callback = (err) ->) ->
-		ProjectDetailsHandler.renameProject project_id, newName, =>
+		ProjectDetailsHandler.renameProject project_id, newName, ->
+			if err?
+				logger.err err:err, project_id:project_id, newName:newName, "error renaming project"
+				return callback(err)
 			EditorRealTimeController.emitToRoom project_id, 'projectNameUpdated', newName
 			EditorRealTimeController.emitToRoom project_id, 'projectNameUpdated', newName
 			callback()
 			callback()
 
 

+ 13 - 4
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -67,11 +67,16 @@ module.exports = EditorHttpController =
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		name = req.body.name
 		name = req.body.name
 		parent_folder_id = req.body.parent_folder_id
 		parent_folder_id = req.body.parent_folder_id
+		logger.log project_id:project_id, name:name, parent_folder_id:parent_folder_id, "getting request to add doc to project"
 		if !EditorHttpController._nameIsAcceptableLength(name)
 		if !EditorHttpController._nameIsAcceptableLength(name)
 			return res.sendStatus 400
 			return res.sendStatus 400
 		EditorController.addDoc project_id, parent_folder_id, name, [], "editor", (error, doc) ->
 		EditorController.addDoc project_id, parent_folder_id, name, [], "editor", (error, doc) ->
-			return next(error) if error?
-			res.json doc
+			if error == "project_has_to_many_files"
+				res.status(400).json(req.i18n.translate("project_has_to_many_files"))
+			else if error?
+				next(error)
+			else
+				res.json doc
 
 
 	addFolder: (req, res, next) ->
 	addFolder: (req, res, next) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
@@ -80,8 +85,12 @@ module.exports = EditorHttpController =
 		if !EditorHttpController._nameIsAcceptableLength(name)
 		if !EditorHttpController._nameIsAcceptableLength(name)
 			return res.sendStatus 400
 			return res.sendStatus 400
 		EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) ->
 		EditorController.addFolder project_id, parent_folder_id, name, "editor", (error, doc) ->
-			return next(error) if error?
-			res.json doc
+			if error == "project_has_to_many_files"
+				res.status(400).json(req.i18n.translate("project_has_to_many_files"))
+			else if error?
+				next(error)
+			else
+				res.json doc
 
 
 	renameEntity: (req, res, next) ->
 	renameEntity: (req, res, next) ->
 		project_id  = req.params.Project_id
 		project_id  = req.params.Project_id

+ 0 - 3
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -1,5 +1,4 @@
 _ = require('underscore')
 _ = require('underscore')
-
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
@@ -15,8 +14,6 @@ templates.registered =
 
 
 <p><a href="<%= setNewPasswordUrl %>">Click here to set your password and log in.</a></p>
 <p><a href="<%= setNewPasswordUrl %>">Click here to set your password and log in.</a></p>
 
 
-<p>Once you have reset your password you can <a href="#{settings.siteUrl}/login">log in here</a>.</p>
-
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 <p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
 """
 """
 
 

+ 20 - 11
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -1,8 +1,9 @@
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 metrics = require('../../infrastructure/Metrics')
 metrics = require('../../infrastructure/Metrics')
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
-metrics = require("../../infrastructure/Metrics")
 nodemailer = require("nodemailer")
 nodemailer = require("nodemailer")
+sesTransport = require('nodemailer-ses-transport')
+_ = require("underscore")
 
 
 if Settings.email? and Settings.email.fromAddress?
 if Settings.email? and Settings.email.fromAddress?
 	defaultFromAddress = Settings.email.fromAddress
 	defaultFromAddress = Settings.email.fromAddress
@@ -15,15 +16,24 @@ client =
 		logger.log options:options, "Would send email if enabled."
 		logger.log options:options, "Would send email if enabled."
 		callback()
 		callback()
 
 
-if Settings.email?
-	if Settings.email.transport? and Settings.email.parameters?
-		nm_client = nodemailer.createTransport( Settings.email.transport, Settings.email.parameters )
-		if nm_client
-			client = nm_client
-		else
-			logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
-	else
-		logger.warn "Email transport and/or parameters not defined. No emails will be sent."
+if Settings?.email?.parameters?.AWSAccessKeyID?
+	logger.log "using aws ses for email"
+	nm_client = nodemailer.createTransport(sesTransport(Settings.email.parameters))
+else if Settings?.email?.parameters?
+	smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
+
+
+	logger.log "using smtp for email"
+	nm_client = nodemailer.createTransport(smtp)
+else
+	nm_client = client
+	logger.warn "Email transport and/or parameters not defined. No emails will be sent."
+
+if nm_client?
+		client = nm_client
+else
+	logger.warn "Failed to create email transport. Please check your settings. No email will be sent."
+
 
 
 module.exports =
 module.exports =
 	sendEmail : (options, callback = (error) ->)->
 	sendEmail : (options, callback = (error) ->)->
@@ -42,4 +52,3 @@ module.exports =
 			else
 			else
 				logger.log "Message sent to #{options.to}"
 				logger.log "Message sent to #{options.to}"
 			callback(err)
 			callback(err)
-

+ 24 - 16
services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee

@@ -6,24 +6,32 @@ settings = require("settings-sharelatex")
 oneMinInMs = 60 * 1000
 oneMinInMs = 60 * 1000
 fiveMinsInMs = oneMinInMs * 5
 fiveMinsInMs = oneMinInMs * 5
 
 
-module.exports =
+module.exports = FileStoreHandler =
 
 
 	uploadFileFromDisk: (project_id, file_id, fsPath, callback)->
 	uploadFileFromDisk: (project_id, file_id, fsPath, callback)->
-		logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "uploading file from disk"
-		readStream = fs.createReadStream(fsPath)
-		opts =
-			method: "post"
-			uri: @_buildUrl(project_id, file_id)
-			timeout:fiveMinsInMs
-		writeStream = request(opts)
-		readStream.pipe writeStream
-		writeStream.on "end", callback
-		readStream.on "error", (err)->
-			logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk"
-			callback err
-		writeStream.on "error", (err)->
-			logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk"
-			callback err
+		fs.lstat fsPath, (err, stat)->
+			if err?
+				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
+				callback(err)
+			if !stat.isFile()
+				logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining"
+				return callback(new Error("can not upload symlink"))
+
+			logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "uploading file from disk"
+			readStream = fs.createReadStream(fsPath)
+			opts =
+				method: "post"
+				uri: FileStoreHandler._buildUrl(project_id, file_id)
+				timeout:fiveMinsInMs
+			writeStream = request(opts)
+			readStream.pipe writeStream
+			writeStream.on "end", callback
+			readStream.on "error", (err)->
+				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the read stream of uploadFileFromDisk"
+				callback err
+			writeStream.on "error", (err)->
+				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "something went wrong on the write stream of uploadFileFromDisk"
+				callback err
 
 
 	getFileStream: (project_id, file_id, query, callback)->
 	getFileStream: (project_id, file_id, query, callback)->
 		logger.log project_id:project_id, file_id:file_id, query:query, "getting file stream from file store"
 		logger.log project_id:project_id, file_id:file_id, query:query, "getting file stream from file store"

+ 16 - 0
services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee

@@ -0,0 +1,16 @@
+
+NotificationsHandler = require("./NotificationsHandler")
+
+module.exports = 
+
+	groupPlan: (user, licence)->
+		key : "join-sub-#{licence.subscription_id}"
+
+		create: (callback = ->)->
+			messageOpts = 
+				groupName: licence.name
+				subscription_id: licence.subscription_id
+			NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback
+
+		read: (callback = ->)->
+			NotificationsHandler.markAsReadWithKey user._id, @key, callback

+ 19 - 0
services/web/app/coffee/Features/Notifications/NotificationsController.coffee

@@ -0,0 +1,19 @@
+NotificationsHandler = require("./NotificationsHandler")
+logger = require("logger-sharelatex")
+_ = require("underscore")
+
+module.exports =
+
+	getAllUnreadNotifications: (req, res)->
+		NotificationsHandler.getUserNotifications req.session.user._id, (err, unreadNotifications)->
+			unreadNotifications = _.map unreadNotifications, (notification)-> 
+				notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
+				return notification
+			res.send(unreadNotifications)
+
+	markNotificationAsRead: (req, res)->
+		user_id = req.session.user._id
+		notification_id = req.params.notification_id
+		NotificationsHandler.markAsRead user_id, notification_id, ->
+			res.send()
+		logger.log user_id:user_id, notification_id:notification_id, "mark notification as read"

+ 52 - 0
services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee

@@ -0,0 +1,52 @@
+settings = require("settings-sharelatex")
+request = require("request")
+logger = require("logger-sharelatex")
+
+oneSecond = 1000
+module.exports = 
+
+	getUserNotifications: (user_id, callback)->
+		opts = 
+			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			json: true
+			timeout: oneSecond
+		request.get opts, (err, res, unreadNotifications)->
+			statusCode =  if res? then res.statusCode else 500
+			if err? or statusCode != 200
+				e = new Error("something went wrong getting notifications, #{err}, #{statusCode}")
+				logger.err err:err, "something went wrong getting notifications"
+				callback(null, [])
+			else
+				if !unreadNotifications?
+					unreadNotifications = []
+				callback(null, unreadNotifications)
+
+	createNotification: (user_id, key, templateKey, messageOpts, callback)->
+		opts = 
+			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			timeout: oneSecond
+			json: {
+				key:key
+				messageOpts:messageOpts
+				templateKey:templateKey
+			}
+		logger.log opts:opts, "creating notification for user"
+		request.post opts, callback
+
+	markAsReadWithKey: (user_id, key, callback)->
+		opts = 
+			uri: "#{settings.apis.notifications.url}/user/#{user_id}"
+			timeout: oneSecond
+			json: {
+				key:key
+			}
+		logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api"
+		request.del opts, callback
+	
+
+	markAsRead: (user_id, notification_id, callback)->
+		opts =
+			uri: "#{settings.apis.notifications.url}/user/#{user_id}/notification/#{notification_id}"
+			timeout:oneSecond
+		logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api"
+		request.del opts, callback

+ 11 - 4
services/web/app/coffee/Features/PasswordReset/PasswordResetController.coffee

@@ -1,5 +1,7 @@
 PasswordResetHandler = require("./PasswordResetHandler")
 PasswordResetHandler = require("./PasswordResetHandler")
 RateLimiter = require("../../infrastructure/RateLimiter")
 RateLimiter = require("../../infrastructure/RateLimiter")
+AuthenticationController = require("../Authentication/AuthenticationController")
+UserGetter = require("../User/UserGetter")
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 
 
 module.exports =
 module.exports =
@@ -37,14 +39,19 @@ module.exports =
 			title:"set_password"
 			title:"set_password"
 			passwordResetToken: req.session.resetToken
 			passwordResetToken: req.session.resetToken
 
 
-	setNewUserPassword: (req, res)->
+	setNewUserPassword: (req, res, next)->
 		{passwordResetToken, password} = req.body
 		{passwordResetToken, password} = req.body
 		if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
 		if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
 			return res.sendStatus 400
 			return res.sendStatus 400
 		delete req.session.resetToken
 		delete req.session.resetToken
-		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found) ->
+		PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
 			return next(err) if err?
 			return next(err) if err?
 			if found
 			if found
-				res.sendStatus 200
+				if req.body.login_after
+					UserGetter.getUser user_id, {email: 1}, (err, user) ->
+						return next(err) if err?
+						AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
+				else
+					res.sendStatus 200
 			else
 			else
-				res.send 404, {message: req.i18n.translate("password_reset_token_expired")}
+				res.sendStatus 404

+ 3 - 3
services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee

@@ -23,11 +23,11 @@ module.exports =
 					return callback(error) if error?
 					return callback(error) if error?
 					callback null, true
 					callback null, true
 
 
-	setNewUserPassword: (token, password, callback = (error, found) ->)->
+	setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
 			if err then return callback(err)
 			if err then return callback(err)
 			if !user_id?
 			if !user_id?
-				return callback null, false
+				return callback null, false, null
 			AuthenticationManager.setUserPassword user_id, password, (err) ->
 			AuthenticationManager.setUserPassword user_id, password, (err) ->
 				if err then return callback(err)
 				if err then return callback(err)
-				callback null, true
+				callback null, true, user_id

+ 15 - 7
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -9,6 +9,7 @@ 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")
+NotificationsHandler = require("../Notifications/NotificationsHandler")
 LimitationsManager = require("../Subscription/LimitationsManager")
 LimitationsManager = require("../Subscription/LimitationsManager")
 _ = require("underscore")
 _ = require("underscore")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
@@ -51,7 +52,7 @@ module.exports = ProjectController =
 	deleteProject: (req, res) ->
 	deleteProject: (req, res) ->
 		project_id = req.params.Project_id
 		project_id = req.params.Project_id
 		forever    = req.query?.forever?
 		forever    = req.query?.forever?
-		logger.log project_id: project_id, forever: forever, "received request to delete project"
+		logger.log project_id: project_id, forever: forever, "received request to archive project"
 
 
 		if forever
 		if forever
 			doDelete = projectDeleter.deleteProject
 			doDelete = projectDeleter.deleteProject
@@ -125,6 +126,8 @@ module.exports = ProjectController =
 		async.parallel {
 		async.parallel {
 			tags: (cb)->
 			tags: (cb)->
 				TagsHandler.getAllTags user_id, cb
 				TagsHandler.getAllTags user_id, cb
+			notifications: (cb)->
+				NotificationsHandler.getUserNotifications user_id, cb
 			projects: (cb)->
 			projects: (cb)->
 				Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 				Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 			hasSubscription: (cb)->
 			hasSubscription: (cb)->
@@ -137,6 +140,9 @@ module.exports = ProjectController =
 					return next(err)
 					return next(err)
 				logger.log results:results, user_id:user_id, "rendering project list"
 				logger.log results:results, user_id:user_id, "rendering project list"
 				tags = results.tags[0]
 				tags = results.tags[0]
+				notifications = require("underscore").map results.notifications, (notification)-> 
+					notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
+					return notification
 				projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2]
 				projects = ProjectController._buildProjectList results.projects[0], results.projects[1], results.projects[2]
 				user = results.user
 				user = results.user
 				ProjectController._injectProjectOwners projects, (error, projects) ->
 				ProjectController._injectProjectOwners projects, (error, projects) ->
@@ -147,6 +153,7 @@ module.exports = ProjectController =
 						priority_title: true
 						priority_title: true
 						projects: projects
 						projects: projects
 						tags: tags
 						tags: tags
+						notifications: notifications or []
 						user: user
 						user: user
 						hasSubscription: results.hasSubscription[0]
 						hasSubscription: results.hasSubscription[0]
 					}
 					}
@@ -168,15 +175,15 @@ module.exports = ProjectController =
 			return res.render("general/closed", {title:"updating_site"})
 			return res.render("general/closed", {title:"updating_site"})
 
 
 		if req.session.user?
 		if req.session.user?
-			user_id = req.session.user._id 
+			user_id = req.session.user._id
 			anonymous = false
 			anonymous = false
 		else
 		else
 			anonymous = true
 			anonymous = true
 			user_id = 'openUser'
 			user_id = 'openUser'
-		
+
 		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
 				Project.findPopulatedById project_id, cb
@@ -193,7 +200,7 @@ module.exports = ProjectController =
 				SubscriptionLocator.getUsersSubscription user_id, cb
 				SubscriptionLocator.getUsersSubscription user_id, cb
 			activate: (cb)->
 			activate: (cb)->
 				InactiveProjectManager.reactivateProjectIfRequired project_id, cb
 				InactiveProjectManager.reactivateProjectIfRequired project_id, cb
-			markAsOpened: (cb)-> 
+			markAsOpened: (cb)->
 				#don't need to wait for this to complete
 				#don't need to wait for this to complete
 				ProjectUpdateHandler.markAsOpened project_id, ->
 				ProjectUpdateHandler.markAsOpened project_id, ->
 				cb()
 				cb()
@@ -205,6 +212,7 @@ module.exports = ProjectController =
 			user = results.user
 			user = results.user
 			subscription = results.subscription
 			subscription = results.subscription
 
 
+
 			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"
 
 
@@ -214,6 +222,7 @@ module.exports = ProjectController =
 
 
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
 					allowedFreeTrial = !!subscription.freeTrial.allowed || true
 					allowedFreeTrial = !!subscription.freeTrial.allowed || true
+
 				logger.log project_id:project_id, "rendering editor page"
 				logger.log project_id:project_id, "rendering editor page"
 				res.render 'project/editor',
 				res.render 'project/editor',
 					title:  project.name
 					title:  project.name
@@ -310,5 +319,4 @@ do generateThemeList = () ->
 	for file in files
 	for file in files
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 			cleanName = file.slice(0,-3).slice(6)
 			cleanName = file.slice(0,-3).slice(6)
-			THEME_LIST.push cleanName
-
+			THEME_LIST.push cleanName

+ 5 - 1
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -19,6 +19,8 @@ module.exports =
 		project = new Project
 		project = new Project
 			 owner_ref  : new ObjectId(owner_id)
 			 owner_ref  : new ObjectId(owner_id)
 			 name       : projectName
 			 name       : projectName
+		if Settings.currentImageName?
+			project.imageName = Settings.currentImageName
 		project.rootFolder[0] = rootFolder
 		project.rootFolder[0] = rootFolder
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 			project.spellCheckLanguage = user.ace.spellCheckLanguage
 			project.spellCheckLanguage = user.ace.spellCheckLanguage
@@ -33,7 +35,9 @@ module.exports =
 			self._buildTemplate "mainbasic.tex", owner_id, projectName, (error, docLines)->
 			self._buildTemplate "mainbasic.tex", owner_id, projectName, (error, docLines)->
 				return callback(error) if error?
 				return callback(error) if error?
 				ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, (error, doc)->
 				ProjectEntityHandler.addDoc project._id, project.rootFolder[0]._id, "main.tex", docLines, (error, doc)->
-					return callback(error) if error?
+					if error?
+						logger.err err:error, "error adding doc when creating basic project"
+						return callback(error)
 					ProjectEntityHandler.setRootDoc project._id, doc._id, (error) ->
 					ProjectEntityHandler.setRootDoc project._id, doc._id, (error) ->
 						callback(error, project)
 						callback(error, project)
 
 

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

@@ -1,4 +1,5 @@
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
+ProjectGetter = require("./ProjectGetter")
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 tagsHandler = require("../Tags/TagsHandler")
 tagsHandler = require("../Tags/TagsHandler")
@@ -33,10 +34,10 @@ module.exports = ProjectDeleter =
 			Project.remove _id: project_id, callback
 			Project.remove _id: project_id, callback
 
 
 	archiveProject: (project_id, callback = (error) ->)->
 	archiveProject: (project_id, callback = (error) ->)->
-		logger.log project_id:project_id, "deleting project"
-		Project.findById project_id, (err, project)=>
+		logger.log project_id:project_id, "archived project from user request"
+		ProjectGetter.getProject project_id, {owner_ref:true, collaberator_refs:true, readOnly_refs:true}, (err, project)=>
 			if err? or !project?
 			if err? or !project?
-				logger.err err:err, project_id:project_id, "error getting project to delete it"
+				logger.err err:err, project_id:project_id, "error getting project to archived it"
 				callback(err)
 				callback(err)
 			else
 			else
 				async.series [
 				async.series [
@@ -57,8 +58,10 @@ module.exports = ProjectDeleter =
 						Project.update {_id:project_id}, { $set: { archived: true }}, cb
 						Project.update {_id:project_id}, { $set: { archived: true }}, cb
 				], (err)->
 				], (err)->
 					if err?
 					if err?
-						logger.err err:err, "problem deleting project"
-					callback(err)
+						logger.err err:err, "problem archived project"
+						return callback(err)
+					logger.log project_id:project_id, "succesfully archived project from user request"
+					callback()
 
 
 	restoreProject: (project_id, callback = (error) ->) ->
 	restoreProject: (project_id, callback = (error) ->) ->
 		Project.update {_id:project_id}, { $unset: { archived: true }}, callback
 		Project.update {_id:project_id}, { $unset: { archived: true }}, callback

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

@@ -8,7 +8,7 @@ _ = require("underscore")
 module.exports = 
 module.exports = 
 
 
 	getDetails: (project_id, callback)->
 	getDetails: (project_id, callback)->
-		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
+		ProjectGetter.getProject project_id, {name:true, description:true, compiler:true, features:true, owner_ref:true}, (err, project)->
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, "error getting project"
 				logger.err err:err, project_id:project_id, "error getting project"
 				return callback(err)
 				return callback(err)
@@ -37,7 +37,7 @@ module.exports =
 
 
 	renameProject: (project_id, newName, callback = ->)->
 	renameProject: (project_id, newName, callback = ->)->
 		logger.log project_id: project_id, newName:newName, "renaming project"
 		logger.log project_id: project_id, newName:newName, "renaming project"
-		ProjectGetter.getProject project_id, {"name":1}, (err, project)->
+		ProjectGetter.getProject project_id, {name:true}, (err, project)->
 			if err? or !project?
 			if err? or !project?
 				logger.err err:err,  project_id:project_id, "error getting project or could not find it todo project rename"
 				logger.err err:err,  project_id:project_id, "error getting project or could not find it todo project rename"
 				return callback(err)
 				return callback(err)

+ 79 - 54
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -4,61 +4,86 @@ projectLocator = require('./ProjectLocator')
 projectOptionsHandler = require('./ProjectOptionsHandler')
 projectOptionsHandler = require('./ProjectOptionsHandler')
 DocumentUpdaterHandler = require("../DocumentUpdater/DocumentUpdaterHandler")
 DocumentUpdaterHandler = require("../DocumentUpdater/DocumentUpdaterHandler")
 DocstoreManager = require "../Docstore/DocstoreManager"
 DocstoreManager = require "../Docstore/DocstoreManager"
-Project = require("../../models/Project").Project
+ProjectGetter = require("./ProjectGetter")
 _ = require('underscore')
 _ = require('underscore')
 async = require('async')
 async = require('async')
+logger = require("logger-sharelatex")
 
 
-module.exports =
-	duplicate: (owner, originalProjectId, newProjectName, callback)->
-		DocumentUpdaterHandler.flushProjectToMongo originalProjectId, (err) ->
-			return callback(err) if err?
-			Project.findById originalProjectId, (err, originalProject) ->
-				return callback(err) if err?
-				projectCreationHandler.createBlankProject owner._id, newProjectName, (err, newProject)->
-					return callback(err) if err?
-					projectLocator.findRootDoc {project:originalProject}, (err, originalRootDoc)->
-						return callback(err) if err?
-						DocstoreManager.getAllDocs originalProjectId, (err, docContentsArray) ->
-							return callback(err) if err?
-
-							docContents = {}
-							for docContent in docContentsArray
-								docContents[docContent._id] = docContent
-
-							projectOptionsHandler.setCompiler newProject._id, originalProject.compiler
-
-							setRootDoc = _.once (doc_id)->
-								projectEntityHandler.setRootDoc newProject, doc_id
-
-							copyDocs = (originalFolder, newParentFolder, callback)->
-								jobs = originalFolder.docs.map (doc)->
-									return (callback)->
-										content = docContents[doc._id.toString()]
-										return callback(new Error("doc_id not found: #{doc._id}")) if !content?
-										projectEntityHandler.addDoc newProject, newParentFolder._id, doc.name, content.lines, (err, newDoc)->
-											if originalRootDoc? and newDoc.name == originalRootDoc.name
-												setRootDoc newDoc._id
-											callback()
-								async.series jobs, callback
-
-							copyFiles = (originalFolder, newParentFolder, callback)->
-								jobs = originalFolder.fileRefs.map (file)->
-									return (callback)->
-										projectEntityHandler.copyFileFromExistingProject newProject, newParentFolder._id, originalProject._id, file, callback
-								async.parallelLimit jobs, 5, callback
-
-							copyFolder = (folder, desFolder, callback)->
-								jobs = folder.folders.map (childFolder)->
-									return (callback)->
-										projectEntityHandler.addFolder newProject, desFolder._id, childFolder.name, (err, newFolder)->
-											copyFolder childFolder, newFolder, callback
-								jobs.push (cb)->
-									copyDocs folder, desFolder, cb
-								jobs.push (cb)->
-									copyFiles folder, desFolder, cb
-
-								async.series jobs, callback
-
-							copyFolder originalProject.rootFolder[0], newProject.rootFolder[0], ->
-								callback(err, newProject)
 
 
+module.exports = ProjectDuplicator =
+
+	_copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)->
+		setRootDoc = _.once (doc_id)->
+			projectEntityHandler.setRootDoc newProject._id, doc_id
+
+		jobs = originalFolder.docs.map (doc)->
+			return (cb)->
+				content = docContents[doc._id.toString()]
+				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
+					if err?
+						logger.err err:err, "error copying doc"
+						return callback(err)
+					if originalRootDoc? and newDoc.name == originalRootDoc.name
+						setRootDoc newDoc._id
+					cb()
+
+		async.series jobs, callback
+
+	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
+		jobs = originalFolder.fileRefs.map (file)->
+			return (cb)->
+				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
+		async.parallelLimit jobs, 5, callback
+
+
+	_copyFolderRecursivly: (newProject_id, originalProject_id, originalRootDoc, originalFolder, desFolder, docContents, callback)->
+		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
+			if err?
+				logger.err project_id:newProject_id, "could not get project"
+				return cb(err)
+
+			jobs = originalFolder.folders.map (childFolder)->
+				return (cb)->
+					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
+						return cb(err) if err?
+						ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb
+
+			jobs.push (cb)->
+				ProjectDuplicator._copyFiles newProject, originalProject_id, originalFolder, desFolder, cb
+			jobs.push (cb)->
+				ProjectDuplicator._copyDocs newProject, originalRootDoc, originalFolder, desFolder, docContents, cb
+
+			async.series jobs, callback
+
+	duplicate: (owner, originalProject_id, newProjectName, callback)->
+
+		jobs = 
+			flush: (cb)->
+				DocumentUpdaterHandler.flushProjectToMongo originalProject_id, cb
+			originalProject: (cb)->
+				ProjectGetter.getProject originalProject_id, {compiler:true, rootFolder:true, rootDoc_id:true}, cb
+			newProject: (cb)->
+				projectCreationHandler.createBlankProject owner._id, newProjectName, cb
+			originalRootDoc: (cb)->
+				projectLocator.findRootDoc {project_id:originalProject_id}, cb
+			docContentsArray: (cb)-> 
+				DocstoreManager.getAllDocs originalProject_id, cb
+
+		async.series jobs, (err, results)->
+			if err?
+				logger.err err:err, originalProject_id:originalProject_id, "error duplicating project"
+				return callback(err)
+			{originalProject, newProject, originalRootDoc, docContentsArray} = results
+
+			originalRootDoc = originalRootDoc[0]
+
+			docContents = {}
+			for docContent in docContentsArray
+				docContents[docContent._id] = docContent
+			
+			projectOptionsHandler.setCompiler newProject._id, originalProject.compiler, ->
+
+			ProjectDuplicator._copyFolderRecursivly newProject._id, originalProject_id, originalRootDoc, originalProject.rootFolder[0], newProject.rootFolder[0], docContents, ->
+				if err?
+					logger.err err:err, originalProject_id:originalProject_id,  newProjectName:newProjectName, "error cloning project"
+				callback(err, newProject)

+ 7 - 1
services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee

@@ -26,6 +26,8 @@ module.exports = ProjectEditorHandler =
 				dropbox:false
 				dropbox:false
 				compileTimeout: 60
 				compileTimeout: 60
 				compileGroup:"standard"
 				compileGroup:"standard"
+				templates: false
+				references: false
 
 
 			if project.owner_ref.features?
 			if project.owner_ref.features?
 				if project.owner_ref.features.collaborators?
 				if project.owner_ref.features.collaborators?
@@ -37,7 +39,11 @@ module.exports = ProjectEditorHandler =
 				if project.owner_ref.features.compileTimeout?
 				if project.owner_ref.features.compileTimeout?
 					result.features.compileTimeout = project.owner_ref.features.compileTimeout
 					result.features.compileTimeout = project.owner_ref.features.compileTimeout
 				if project.owner_ref.features.compileGroup?
 				if project.owner_ref.features.compileGroup?
-					result.features.compileGroup = 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.owner = @buildUserModelView project.owner_ref, "owner"

+ 179 - 71
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -1,4 +1,5 @@
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
+settings = require "settings-sharelatex"
 Doc = require('../../models/Doc').Doc
 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
@@ -75,23 +76,21 @@ module.exports = ProjectEntityHandler =
 		documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler')
 		documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler')
 		documentUpdaterHandler.flushProjectToMongo project_id, (error) ->
 		documentUpdaterHandler.flushProjectToMongo project_id, (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			Project.findById project_id, (error, project) ->
+			ProjectGetter.getProject project_id, {name:true}, (error, project) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				requests = []
 				requests = []
 				self.getAllDocs project_id, (error, docs) ->
 				self.getAllDocs project_id, (error, docs) ->
 					return callback(error) if error?
 					return callback(error) if error?
 					for docPath, doc of docs
 					for docPath, doc of docs
 						do (docPath, doc) ->
 						do (docPath, doc) ->
-							requests.push (callback) ->
-								tpdsUpdateSender.addDoc {project_id:project_id, doc_id:doc._id, path:docPath, project_name:project.name, rev:doc.rev||0},
-									callback
+							requests.push (cb) ->
+								tpdsUpdateSender.addDoc {project_id:project_id, doc_id:doc._id, path:docPath, project_name:project.name, rev:doc.rev||0}, cb
 					self.getAllFiles project_id, (error, files) ->
 					self.getAllFiles project_id, (error, files) ->
 						return callback(error) if error?
 						return callback(error) if error?
 						for filePath, file of files
 						for filePath, file of files
 							do (filePath, file) ->
 							do (filePath, file) ->
-								requests.push (callback) ->
-									tpdsUpdateSender.addFile {project_id:project_id, file_id:file._id, path:filePath, project_name:project.name, rev:file.rev},
-										callback
+								requests.push (cb) ->
+									tpdsUpdateSender.addFile {project_id:project_id, file_id:file._id, path:filePath, project_name:project.name, rev:file.rev}, cb
 						async.series requests, (err) ->
 						async.series requests, (err) ->
 							logger.log project_id:project_id, "finished flushing project to tpds"
 							logger.log project_id:project_id, "finished flushing project to tpds"
 							callback(err)
 							callback(err)
@@ -110,27 +109,36 @@ module.exports = ProjectEntityHandler =
 			options = {}
 			options = {}
 		DocstoreManager.getDoc project_id, doc_id, options, callback
 		DocstoreManager.getDoc project_id, doc_id, options, callback
 
 
-	addDoc: (project_or_id, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=>
-		Project.getProject project_or_id, "", (err, project) ->
-			logger.log project: project._id, folder_id: folder_id, doc_name: docName, "adding doc"
-			return callback(err) if err?
-			confirmFolder project, folder_id, (folder_id)=>
-				doc = new Doc name: docName
-				# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
-				# which hasn't been created in docstore.
-				DocstoreManager.updateDoc project._id.toString(), doc._id.toString(), docLines, (err, modified, rev) ->
-					return callback(err) if err? 
-					Project.putElement project._id, folder_id, doc, "doc", (err, result)=>
-						return callback(err) if err?
-						tpdsUpdateSender.addDoc {
-							project_id:   project._id,
-							doc_id:		  doc._id	
-							path:         result.path.fileSystem,
-							project_name: project.name,
-							rev:          0
-						}, (err) ->
-							return callback(err) if err?
-							callback(null, doc, folder_id)
+
+	addDoc: (project_id, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=>
+		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
+			if err?
+				logger.err project_id:project_id, err:err, "error getting project for add doc"
+				return callback(err)
+			ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, callback
+
+	addDocWithProject: (project, folder_id, docName, docLines, callback = (error, doc, folder_id) ->)=>
+		project_id = project._id
+		logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project"
+		confirmFolder project, folder_id, (folder_id)=>
+			doc = new Doc name: docName
+			# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
+			# which hasn't been created in docstore.
+			DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, (err, modified, rev) ->
+				return callback(err) if err? 
+
+				ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
+					return callback(err) if err?
+					tpdsUpdateSender.addDoc {
+						project_id:   project_id,
+						doc_id:		  doc?._id
+						path:         result?.path?.fileSystem,
+						project_name: project.name,
+						rev:          0
+					}, (err) ->
+						if err?
+							logger.err err:err, "error adding doc to tpdsworker, contining anyway"
+						callback(null, doc, folder_id)
 
 
 	restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
 	restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
 		# getDoc will return the deleted doc's lines, but we don't actually remove
 		# getDoc will return the deleted doc's lines, but we don't actually remove
@@ -139,22 +147,34 @@ module.exports = ProjectEntityHandler =
 			return callback(error) if error?
 			return callback(error) if error?
 			ProjectEntityHandler.addDoc project_id, null, name, lines, callback
 			ProjectEntityHandler.addDoc project_id, null, name, lines, callback
 
 
-	addFile: (project_or_id, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)->
-		ProjectGetter.getProjectWithOnlyFolders project_or_id, (err, project) ->
-			logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
-			return callback(err) if err?
-			confirmFolder project, folder_id, (folder_id)->
-				fileRef = new File name : fileName
-				FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err)->
+	addFile: (project_id, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)->
+		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
+			if err?
+				logger.err project_id:project_id, err:err, "error getting project for add file"
+				return callback(err)
+			ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, callback
+
+	addFileWithProject: (project, folder_id, fileName, path, callback = (error, fileRef, folder_id) ->)->
+		project_id = project._id
+		logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
+		return callback(err) if err?
+		confirmFolder project, folder_id, (folder_id)->
+			fileRef = new File name : fileName
+			FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err)->
+				if err?
+					logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
+					return callback(err)
+				ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
 					if err?
 					if err?
-						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
+						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project"
 						return callback(err)
 						return callback(err)
-					Project.putElement project._id, folder_id, fileRef, "file", (err, result)=>
-						tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result.path.fileSystem, project_name:project.name, rev:fileRef.rev}, ->
-							callback(err, fileRef, folder_id)
+					tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err)->
+						if err?
+							logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error sending file to tpdsworker"
+						callback(null, fileRef, folder_id)
 
 
-	replaceFile: (project_or_id, file_id, fsPath, callback)->
-		Project.getProject project_or_id, "", (err, project) ->
+	replaceFile: (project_id, file_id, fsPath, callback)->
+		ProjectGetter.getProject project_id, {name:true}, (err, project) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			findOpts = 
 			findOpts = 
 				project_id:project._id
 				project_id:project._id
@@ -182,21 +202,36 @@ module.exports = ProjectEntityHandler =
 						Project.update conditons, update, {}, (err, second)->
 						Project.update conditons, update, {}, (err, second)->
 							callback()
 							callback()
 
 
-	copyFileFromExistingProject: (project_or_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)->
-		Project.getProject project_or_id, "", (err, project) ->
-			logger.log project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3"
-			return callback(err) if err?
-			confirmFolder project, folder_id, (folder_id)=>
-				if !origonalFileRef?
-					logger.err project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "file trying to copy is null"
-					return callback()
-				fileRef = new File name : origonalFileRef.name
-				FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err)->
+	copyFileFromExistingProject: (project_id, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)->
+		logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3"
+		ProjectGetter.getProject project_id, {name:true}, (err, project) ->
+			if err?
+				logger.err project_id:project_id, err:err, "error getting project for copy file from existing project"
+				return callback(err)
+			ProjectEntityHandler.copyFileFromExistingProjectWithProject project, folder_id, originalProject_id, origonalFileRef, callback
+
+
+	copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, callback = (error, fileRef, folder_id) ->)->
+		project_id = project._id
+		logger.log project_id:project_id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "copying file in s3 with project"
+		return callback(err) if err?
+		confirmFolder project, folder_id, (folder_id)=>
+			if !origonalFileRef?
+				logger.err project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "file trying to copy is null"
+				return callback()
+			fileRef = new File name : origonalFileRef.name
+			FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err)->
+				if err?
+					logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error coping file in s3"
+					return callback(err)
+				ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
 					if err?
 					if err?
-						logger.err err:err, project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error coping file in s3"
-					Project.putElement project._id, folder_id, fileRef, "file", (err, result)=>
-						tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result.path.fileSystem, rev:fileRef.rev, project_name:project.name}, (error) ->
-							callback(error, fileRef, folder_id)
+						logger.err err:err, project_id:project._id, folder_id:folder_id, "error putting element as part of copy"
+						return callback(err)
+					tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
+						if err?
+							logger.err err:err,  project_id:project._id, folder_id:folder_id, originalProject_id:originalProject_id, origonalFileRef:origonalFileRef, "error sending file to tpds worker"
+						callback(null, fileRef, folder_id)
 
 
 	mkdirp: (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)->
 	mkdirp: (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)->
 		self = @
 		self = @
@@ -204,7 +239,7 @@ module.exports = ProjectEntityHandler =
 		folders = _.select folders, (folder)->
 		folders = _.select folders, (folder)->
 			return folder.length != 0
 			return folder.length != 0
 
 
-		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)=>
+		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project)=>
 			if path == '/'
 			if path == '/'
 				logger.log project_id: project._id, "mkdir is only trying to make path of / so sending back root folder"
 				logger.log project_id: project._id, "mkdir is only trying to make path of / so sending back root folder"
 				return callback(null, [], project.rootFolder[0])
 				return callback(null, [], project.rootFolder[0])
@@ -217,7 +252,7 @@ module.exports = ProjectEntityHandler =
 				if parentFolder?  
 				if parentFolder?  
 					parentFolder_id = parentFolder._id
 					parentFolder_id = parentFolder._id
 				builtUpPath = "#{builtUpPath}/#{folderName}"
 				builtUpPath = "#{builtUpPath}/#{folderName}"
-				projectLocator.findElementByPath project_id, builtUpPath, (err, foundFolder)=>
+				projectLocator.findElementByPath project, builtUpPath, (err, foundFolder)=>
 					if !foundFolder?
 					if !foundFolder?
 						logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp"
 						logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp"
 						@addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)->
 						@addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)->
@@ -235,16 +270,23 @@ module.exports = ProjectEntityHandler =
 				folders = _.select folders, (folder)->
 				folders = _.select folders, (folder)->
 					!folder.filterOut
 					!folder.filterOut
 				callback(null, folders, lastFolder)
 				callback(null, folders, lastFolder)
-	
-	addFolder: (project_or_id, parentFolder_id, folderName, callback) ->
-		folder = new Folder name: folderName
-		Project.getProject project_or_id, "", (err, project) ->
-			return callback(err) if err?
-			confirmFolder project, parentFolder_id, (parentFolder_id)=>
-				logger.log project: project_or_id, parentFolder_id:parentFolder_id, folderName:folderName, "new folder added"
-				Project.putElement project._id, parentFolder_id, folder, "folder", (err, result)=>
-					if callback?
-						callback(err, folder, parentFolder_id)
+
+	addFolder: (project_id, parentFolder_id, folderName, callback) ->
+		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project)=>
+			if err?
+				logger.err project_id:project_id, err:err, "error getting project for add folder"
+				return callback(err)
+			ProjectEntityHandler.addFolderWithProject project, parentFolder_id, folderName, callback
+
+	addFolderWithProject: (project, parentFolder_id, folderName, callback = (err, folder, parentFolder_id)->) ->
+		confirmFolder project, parentFolder_id, (parentFolder_id)=>
+			folder = new Folder name: folderName
+			logger.log project: project._id, parentFolder_id:parentFolder_id, folderName:folderName, "adding new folder"
+			ProjectEntityHandler._putElement project, parentFolder_id, folder, "folder", (err, result)=>
+				if err?
+					logger.err err:err, project_id:project._id, "error adding folder to project"
+					return callback(err)
+				callback(err, folder, parentFolder_id)
 
 
 	updateDocLines : (project_id, doc_id, lines, callback = (error) ->)->
 	updateDocLines : (project_id, doc_id, lines, callback = (error) ->)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
@@ -281,7 +323,7 @@ module.exports = ProjectEntityHandler =
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			return callback("No entityType set")
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
 		entityType = entityType.toLowerCase()
-		Project.findById project_id, (err, project)=>
+		ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=>
 			return callback(err) if err?
 			return callback(err) if err?
 			projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path)->
 			projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path)->
 				return callback(err) if err?
 				return callback(err) if err?
@@ -302,7 +344,7 @@ 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?
-						Project.putElement project_id, destinationFolder_id, entity, entityType, (err, result)->
+						ProjectEntityHandler._putElement project, destinationFolder_id, entity, entityType, (err, result)->
 							return callback(err) if err?
 							return callback(err) if err?
 							opts = 
 							opts = 
 								project_id:project_id
 								project_id:project_id
@@ -319,7 +361,7 @@ module.exports = ProjectEntityHandler =
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			return callback("No entityType set")
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
 		entityType = entityType.toLowerCase()
-		Project.findById project_id, (err, project)=>
+		ProjectGetter.getProject project_id, {name:true, rootFolder:true}, (err, project)=>
 			return callback(error) if error?
 			return callback(error) if error?
 			projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=>
 			projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=>
 				return callback(error) if error?
 				return callback(error) if error?
@@ -338,7 +380,7 @@ module.exports = ProjectEntityHandler =
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			logger.err err: "No entityType set", project_id: project_id, entity_id: entity_id
 			return callback("No entityType set")
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
 		entityType = entityType.toLowerCase()
-		Project.findById project_id, (err, project)=>
+		ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=>
 			projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path, folder)=>
 			projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path, folder)=>
 				if err?
 				if err?
 					return callback err
 					return callback err
@@ -426,6 +468,72 @@ module.exports = ProjectEntityHandler =
 			}
 			}
 		}, {}, callback
 		}, {}, callback
 
 
+
+	_countElements : (project, callback)->
+	
+		countFolder = (folder, cb = (err, count)->)->
+
+			jobs = _.map folder?.folders, (folder)->
+				(asyncCb)-> countFolder folder, asyncCb
+
+			async.series jobs, (err, subfolderCounts)->
+				total = 0
+
+				if subfolderCounts?.length > 0
+					total = _.reduce subfolderCounts, (a, b)-> return a + b
+				if folder?.folders?.length?
+					total += folder?.folders?.length
+				if folder?.docs?.length?
+					total += folder?.docs?.length
+				if folder?.fileRefs?.length?
+					total += folder?.fileRefs?.length
+				cb(null, total)
+
+		countFolder project.rootFolder[0], callback
+
+	_putElement: (project, folder_id, element, type, callback = (err, path)->)->
+
+		sanitizeTypeOfElement = (elementType)->
+			lastChar = elementType.slice -1
+			if lastChar != "s"
+				elementType +="s"
+			if elementType == "files"
+				elementType = "fileRefs"
+			return elementType
+
+		if !element?
+			e = new Error("no element passed to be inserted")
+			logger.err project_id:project._id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as it was null"
+			return callback(e)
+		type = sanitizeTypeOfElement type
+
+		if !folder_id?
+			folder_id = project.rootFolder[0]._id
+		ProjectEntityHandler._countElements project, (err, count)->
+			if count > settings.maxEntitiesPerProject
+				logger.warn project_id:project._id, "project too big, stopping insertions"
+				return callback("project_has_to_many_files")
+			projectLocator.findElement {project:project, element_id:folder_id, type:"folders"}, (err, folder, path)=>
+				if err?
+					logger.err err:err, project_id:project._id, folder_id:folder_id, type:type, element:element, "error finding folder for _putElement"
+					return callback(err)
+				newPath =
+					fileSystem: "#{path.fileSystem}/#{element.name}"
+					mongo: path.mongo
+				logger.log project_id: project._id, element_id: element._id, fileType: type, folder_id: folder_id, "adding element to project"
+				id = element._id+''
+				element._id = require('mongoose').Types.ObjectId(id)
+				conditions = _id:project._id
+				mongopath = "#{path.mongo}.#{type}"
+				update = "$push":{}
+				update["$push"][mongopath] = element
+				Project.update conditions, update, {}, (err)->
+					if err?
+						logger.err err: err, project_id: project._id, 'error saving in putElement project'
+						return callback(err)
+					callback(err, {path:newPath})
+
+
 confirmFolder = (project, folder_id, callback)->
 confirmFolder = (project, folder_id, callback)->
 	logger.log folder_id:folder_id, project_id:project._id, "confirming folder in project"
 	logger.log folder_id:folder_id, project_id:project._id, "confirming folder in project"
 	if folder_id+'' == 'undefined'
 	if folder_id+'' == 'undefined'

+ 26 - 3
services/web/app/coffee/Features/Project/ProjectGetter.coffee

@@ -2,31 +2,54 @@ 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")
+logger = require("logger-sharelatex")
+
+
 
 
 module.exports = ProjectGetter =
 module.exports = ProjectGetter =
 	EXCLUDE_DEPTH: 8
 	EXCLUDE_DEPTH: 8
 
 
+
 	getProjectWithoutDocLines: (project_id, callback=(error, project) ->) ->
 	getProjectWithoutDocLines: (project_id, callback=(error, project) ->) ->
 		excludes = {}
 		excludes = {}
-		for i in [1..@EXCLUDE_DEPTH]
+		for i in [1..ProjectGetter.EXCLUDE_DEPTH]
 			excludes["rootFolder#{Array(i).join(".folder")}.docs.lines"] = 0
 			excludes["rootFolder#{Array(i).join(".folder")}.docs.lines"] = 0
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 			callback error, projects[0]
 			callback error, projects[0]
 
 
 	getProjectWithOnlyFolders: (project_id, callback=(error, project) ->) ->
 	getProjectWithOnlyFolders: (project_id, callback=(error, project) ->) ->
 		excludes = {}
 		excludes = {}
-		for i in [1..@EXCLUDE_DEPTH]
+		for i in [1..ProjectGetter.EXCLUDE_DEPTH]
 			excludes["rootFolder#{Array(i).join(".folder")}.docs"] = 0
 			excludes["rootFolder#{Array(i).join(".folder")}.docs"] = 0
 			excludes["rootFolder#{Array(i).join(".folder")}.fileRefs"] = 0
 			excludes["rootFolder#{Array(i).join(".folder")}.fileRefs"] = 0
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 			callback error, projects[0]
 			callback error, projects[0]
 
 
+
 	getProject: (query, projection, callback = (error, project) ->) ->
 	getProject: (query, projection, callback = (error, project) ->) ->
+		if !query?
+			return callback("no query provided")
+
+		if typeof(projection) == "function"
+			callback = projection
+
 		if typeof query == "string"
 		if typeof query == "string"
 			query = _id: ObjectId(query)
 			query = _id: ObjectId(query)
 		else if query instanceof ObjectId
 		else if query instanceof ObjectId
 			query = _id: query
 			query = _id: query
-		db.projects.findOne query, projection, callback
+		else if query?.toString().length == 24 # sometimes mongoose ids are hard to identify, this will catch them
+			query = _id:  ObjectId(query.toString())
+		else
+			err = new Error("malformed get request")
+			logger.log query:query, err:err, type:typeof(query), "malformed get request"
+			return callback(err)
+
+		db.projects.find query, projection, (err, project)->
+			if err?
+				logger.err err:err, query:query, projection:projection, "error getting project"
+				return callback(err)
+			callback(null, project?[0])
 
 
 	populateProjectWithUsers: (project, callback=(error, project) ->) ->
 	populateProjectWithUsers: (project, callback=(error, project) ->) ->
 		# eventually this should be in a UserGetter.getUser module
 		# eventually this should be in a UserGetter.getUser module

+ 17 - 8
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -1,11 +1,16 @@
 Project = require('../../models/Project').Project
 Project = require('../../models/Project').Project
+ProjectGetter = require("./ProjectGetter")
 Errors = require "../../errors"
 Errors = require "../../errors"
 _ = require('underscore')
 _ = require('underscore')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 async = require('async')
 async = require('async')
 
 
-module.exports =
-	findElement: (options, callback = (err, element, path, parentFolder)->)->
+module.exports = ProjectLocator =
+	findElement: (options, _callback = (err, element, path, parentFolder)->)->
+		callback = (args...) ->
+			_callback(args...)
+			_callback = () ->
+
 		{project, project_id, element_id, type} = options
 		{project, project_id, element_id, type} = options
 		elementType = sanitizeTypeOfElement type
 		elementType = sanitizeTypeOfElement type
 
 
@@ -46,7 +51,7 @@ module.exports =
 		if project?
 		if project?
 			startSearch(project)
 			startSearch(project)
 		else
 		else
-			Project.findById project_id, (err, project)->
+			ProjectGetter.getProject project_id, {rootFolder:true, rootDoc_id:true}, (err, project)->
 				return callback(err) if err?
 				return callback(err) if err?
 				if !project?
 				if !project?
 					return callback(new Errors.NotFoundError("project not found"))
 					return callback(new Errors.NotFoundError("project not found"))
@@ -62,8 +67,12 @@ module.exports =
 		if project?
 		if project?
 			getRootDoc project
 			getRootDoc project
 		else
 		else
-			Project.findById project_id, (err, project)->
-				getRootDoc project
+			ProjectGetter.getProject project_id, {rootFolder:true, rootDoc_id:true}, (err, project)->
+				if err?
+					logger.err err:err, "error getting project"
+					return callback(err)
+				else
+					getRootDoc project
 
 
 	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)->
 	findElementByPath: (project_or_id, needlePath, callback = (err, foundEntity)->)->
 
 
@@ -122,11 +131,11 @@ module.exports =
 			async.waterfall jobs, callback
 			async.waterfall jobs, callback
 
 
 	findUsersProjectByName: (user_id, projectName, callback)->
 	findUsersProjectByName: (user_id, projectName, callback)->
-		Project.findAllUsersProjects user_id, 'name', (err, projects, collabertions=[])->
+		Project.findAllUsersProjects user_id, 'name archived', (err, projects, collabertions=[])->
 			projects = projects.concat(collabertions)
 			projects = projects.concat(collabertions)
 			projectName = projectName.toLowerCase()
 			projectName = projectName.toLowerCase()
-			project = _.find projects, (project)-> 
-				project.name.toLowerCase() == projectName
+			project = _.find projects, (project)->
+				project.name.toLowerCase() == projectName and project.archived != true
 			logger.log user_id:user_id, projectName:projectName, totalProjects:projects.length, project:project, "looking for project by name"
 			logger.log user_id:user_id, projectName:projectName, totalProjects:projects.length, project:project, "looking for project by name"
 			callback(null, project)
 			callback(null, project)
 
 

+ 37 - 0
services/web/app/coffee/Features/References/ReferencesController.coffee

@@ -0,0 +1,37 @@
+logger = require('logger-sharelatex')
+ReferencesHandler = require('./ReferencesHandler')
+settings = require('settings-sharelatex')
+EditorRealTimeController = require("../Editor/EditorRealTimeController")
+
+module.exports = ReferencesController =
+
+
+	index: (req, res) ->
+		projectId = req.params.Project_id
+		shouldBroadcast = req.body.shouldBroadcast
+		docIds = req.body.docIds
+		if (!docIds or !(docIds instanceof Array))
+			logger.err {projectId, docIds}, "docIds is not valid, should be either Array or String 'ALL'"
+			return res.sendStatus 400
+		logger.log {projectId, docIds: docIds}, "index references for project"
+		ReferencesHandler.index projectId, docIds, (err, data) ->
+			if err
+				logger.err {err, projectId}, "error indexing all references"
+				return res.sendStatus 500
+			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
+
+	indexAll: (req, res) ->
+		projectId = req.params.Project_id
+		shouldBroadcast = req.body.shouldBroadcast
+		logger.log {projectId}, "index all references for project"
+		ReferencesHandler.indexAll projectId, (err, data) ->
+			if err
+				logger.err {err, projectId}, "error indexing all references"
+				return res.sendStatus 500
+			ReferencesController._handleIndexResponse(req, res, projectId, shouldBroadcast, data)
+
+	_handleIndexResponse: (req, res, projectId, shouldBroadcast, data) ->
+		if shouldBroadcast
+			logger.log {projectId}, "emitting new references keys to connected clients"
+			EditorRealTimeController.emitToRoom projectId, 'references:keys:updated', data.keys
+		return res.json data

+ 85 - 0
services/web/app/coffee/Features/References/ReferencesHandler.coffee

@@ -0,0 +1,85 @@
+logger = require("logger-sharelatex")
+request = require("request")
+settings = require("settings-sharelatex")
+Project = require("../../models/Project").Project
+DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
+U = require('underscore')
+Async = require('async')
+
+oneMinInMs = 60 * 1000
+fiveMinsInMs = oneMinInMs * 5
+
+
+module.exports = ReferencesHandler =
+
+	_buildDocUrl: (projectId, docId) ->
+		"#{settings.apis.docstore.url}/project/#{projectId}/doc/#{docId}/raw"
+
+	_findBibDocIds: (project) ->
+		ids = []
+
+		_process = (folder) ->
+			folder.docs.forEach (doc) ->
+				if doc?.name?.match(/^.*\.bib$/)
+					ids.push(doc._id)
+			folder.folders.forEach (folder) ->
+				_process(folder)
+
+		project.rootFolder.forEach (rootFolder) ->
+			_process(rootFolder)
+
+		return ids
+
+	_isFullIndex: (project, callback = (err, result) ->) ->
+		owner = project.owner_ref
+		callback(null, owner.features.references == true)
+
+	indexAll: (projectId, callback=(err, data)->) ->
+		Project.findPopulatedById projectId, (err, project) ->
+			if err
+				logger.err {err, projectId}, "error finding project"
+				return callback(err)
+			logger.log {projectId}, "indexing all bib files in project"
+			docIds = ReferencesHandler._findBibDocIds(project)
+			ReferencesHandler._doIndexOperation(projectId, project, docIds, callback)
+
+	index: (projectId, docIds, callback=(err, data)->) ->
+		Project.findPopulatedById projectId, (err, project) ->
+			if err
+				logger.err {err, projectId}, "error finding project"
+				return callback(err)
+			ReferencesHandler._doIndexOperation(projectId, project, docIds, callback)
+
+	_doIndexOperation: (projectId, project, docIds, callback) ->
+		ReferencesHandler._isFullIndex project, (err, isFullIndex) ->
+			if err
+				logger.err {err, projectId}, "error checking whether to do full index"
+				return callback(err)
+			logger.log {projectId, docIds}, 'flushing docs to mongo before calling references service'
+			Async.series(
+				docIds.map((docId) -> (cb) -> DocumentUpdaterHandler.flushDocToMongo(projectId, docId, cb)),
+				(err) ->
+					# continue
+					if err
+						logger.err {err, projectId, docIds}, "error flushing docs to mongo"
+						return callback(err)
+					bibDocUrls = docIds.map (docId) ->
+						ReferencesHandler._buildDocUrl projectId, docId
+					logger.log {projectId, isFullIndex, docIds, bibDocUrls}, "sending request to references service"
+					request.post {
+						url: "#{settings.apis.references.url}/project/#{projectId}/index"
+						json:
+							docUrls: bibDocUrls
+							fullIndex: isFullIndex
+					}, (err, res, data) ->
+						if err
+							logger.err {err, projectId}, "error communicating with references api"
+							return callback(err)
+						if 200 <= res.statusCode < 300
+							logger.log {projectId}, "got keys from references api"
+							return callback(null, data)
+						else
+							err = new Error("references api responded with non-success code: #{res.statusCode}")
+							logger.log {err, projectId}, "error updating references"
+							return callback(err)
+			)

+ 1 - 1
services/web/app/coffee/Features/Security/RateLimiterMiddlewear.coffee

@@ -15,7 +15,7 @@ module.exports = RateLimiterMiddlewear =
 	###
 	###
 	rateLimit: (opts) ->
 	rateLimit: (opts) ->
 		return (req, res, next) ->
 		return (req, res, next) ->
-			if req.session.user?
+			if req.session?.user?
 				user_id = req.session.user._id
 				user_id = req.session.user._id
 			else
 			else
 				user_id = req.ip
 				user_id = req.ip

+ 0 - 1
services/web/app/coffee/Features/Spelling/SpellingController.coffee

@@ -12,5 +12,4 @@ 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.sendStatus 500
 		.pipe(res)
 		.pipe(res)

+ 1 - 0
services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee

@@ -13,6 +13,7 @@ module.exports =
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide")
 		webRouter.get '/style', HomeController.externalPage("style_guide", "Style Guide")
+		webRouter.get '/jobs', HomeController.externalPage("jobs", "Jobs")
 
 
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 
 

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

@@ -52,11 +52,17 @@ module.exports =
 			return callback(err) if err?
 			return callback(err) if err?
 			callback err, subscriptions.length > 0, subscriptions
 			callback err, subscriptions.length > 0, subscriptions
 
 
-	hasGroupMembersLimitReached: (user_id, callback)->
+	hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)->
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
 		SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
+			if err?
+				logger.err err:err, user_id:user_id, "error getting users subscription"
+				return callback(err)
+			if !subscription?
+				logger.err user_id:user_id, "no subscription found for user"
+				return callback("no subscription found")
 			limitReached = subscription.member_ids.length >= subscription.membersLimit
 			limitReached = subscription.member_ids.length >= subscription.membersLimit
 			logger.log user_id:user_id, limitReached:limitReached, currentTotal: subscription.member_ids.length, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
 			logger.log user_id:user_id, limitReached:limitReached, currentTotal: subscription.member_ids.length, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
-			callback(err, limitReached)
+			callback(err, limitReached, subscription)
 
 
 getOwnerOfProject = (project_id, callback)->
 getOwnerOfProject = (project_id, callback)->
 	Project.findById project_id, 'owner_ref', (error, project) ->
 	Project.findById project_id, 'owner_ref', (error, project) ->

+ 12 - 0
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -247,6 +247,18 @@ module.exports = RecurlyWrapper =
 			callback(error)
 			callback(error)
 		)
 		)
 
 
+	extendTrial: (subscriptionId, daysUntilExpire = 7, callback)->
+		next_renewal_date = new Date()
+		next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire)
+		logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user"
+		@apiRequest({
+			url    : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false"
+			method : "put"
+		}, (error, response, responseBody) =>
+			if error?
+				logger.err err:error,  subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire,  "error exending trial"
+			callback(error)
+		)
 
 
 	_parseSubscriptionXml: (xml, callback) ->
 	_parseSubscriptionXml: (xml, callback) ->
 		@_parseXml xml, (error, data) ->
 		@_parseXml xml, (error, data) ->

+ 10 - 4
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -1,7 +1,6 @@
 SecurityManager     = require '../../managers/SecurityManager'
 SecurityManager     = require '../../managers/SecurityManager'
 SubscriptionHandler  = require './SubscriptionHandler'
 SubscriptionHandler  = require './SubscriptionHandler'
 PlansLocator = require("./PlansLocator")
 PlansLocator = require("./PlansLocator")
-SubscriptionFormatters = require("./SubscriptionFormatters")
 SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
 SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
 LimitationsManager = require("./LimitationsManager")
 LimitationsManager = require("./LimitationsManager")
 RecurlyWrapper = require './RecurlyWrapper'
 RecurlyWrapper = require './RecurlyWrapper'
@@ -9,7 +8,6 @@ Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
-require("../../infrastructure/Sixpack")
 
 
 module.exports = SubscriptionController =
 module.exports = SubscriptionController =
 
 
@@ -98,14 +96,14 @@ module.exports = SubscriptionController =
 				else
 				else
 					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
 					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
 						return next(error) if error?
 						return next(error) if error?
-						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, "showing subscription dashboard"
+						logger.log user: user, subscription:subscription, hasSubOrIsGroupMember:hasSubOrIsGroupMember, groups:groups, "showing subscription dashboard"
 						plans = SubscriptionViewModelBuilder.buildViewModel()
 						plans = SubscriptionViewModelBuilder.buildViewModel()
 						res.render "subscriptions/dashboard",
 						res.render "subscriptions/dashboard",
 							title: "your_subscription"
 							title: "your_subscription"
 							recomendedCurrency: subscription?.currency
 							recomendedCurrency: subscription?.currency
 							taxRate:subscription?.taxRate
 							taxRate:subscription?.taxRate
 							plans: plans
 							plans: plans
-							subscription: subscription
+							subscription: subscription || {}
 							groups: groups
 							groups: groups
 							subscriptionTabActive: true
 							subscriptionTabActive: true
 
 
@@ -226,6 +224,14 @@ module.exports = SubscriptionController =
 				else
 				else
 					res.sendStatus 200
 					res.sendStatus 200
 
 
+	extendTrial: (req, res)->
+		SecurityManager.getCurrentUser req, (error, user) ->
+			LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)->
+				SubscriptionHandler.extendTrial subscription, 14, (err)->
+					if err?
+						res.send 500
+					else
+						res.send 200
 
 
 	recurlyNotificationParser: (req, res, next) ->
 	recurlyNotificationParser: (req, res, next) ->
 		xml = ""
 		xml = ""

+ 4 - 27
services/web/app/coffee/Features/Subscription/SubscriptionDomainHandler.coffee

@@ -1,30 +1,17 @@
 async = require("async")
 async = require("async")
 _ = require("underscore")
 _ = require("underscore")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
-SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
-_s = require("underscore.string")
 
 
 module.exports = SubscriptionDomainHandler =
 module.exports = SubscriptionDomainHandler =
 
 
 
 
-	getLicenceUserCanJoin: (user, callback)->
+	getLicenceUserCanJoin: (user)->
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
-		if licence?
-			callback null, licence
-		else
-			callback()
-
-	attemptToJoinGroup: (user, callback)->
-		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
-		if licence? and user.emailVerified
-			SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback
-		else
-			callback "user not verified"
+		return licence
 
 
 	rejectInvitationToGroup: (user, subscription, callback)->
 	rejectInvitationToGroup: (user, subscription, callback)->
 		removeUserFromGroup(subscription.admin_id, user._id, callback)
 		removeUserFromGroup(subscription.admin_id, user._id, callback)
 
 
-
 	getDomainLicencePage: (user)->
 	getDomainLicencePage: (user)->
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
 		if licence?.verifyEmail
 		if licence?.verifyEmail
@@ -32,20 +19,11 @@ module.exports = SubscriptionDomainHandler =
 		else
 		else
 			return undefined
 			return undefined
 
 
-
-	autoAllocate: (user, callback = ->)->
-		licence = SubscriptionDomainHandler._findDomainLicence(user.email)
-		#
-		if licence?
-			SubscriptionGroupHandler.addUserToGroup licence.adminUser_id, user.email, callback
-		else
-			callback()
-
-
 	_findDomainLicence: (email)->
 	_findDomainLicence: (email)->
 		licence = _.find settings.domainLicences, (licence)->
 		licence = _.find settings.domainLicences, (licence)->
 			_.find licence.domains, (domain)->
 			_.find licence.domains, (domain)->
-				_s.endsWith email, domain
+				regex = "[@\.]#{domain}"
+				return email.match(regex)
 
 
 		return licence
 		return licence
 
 
@@ -53,4 +31,3 @@ module.exports = SubscriptionDomainHandler =
 		licence = _.find settings.domainLicences, (licence)->
 		licence = _.find settings.domainLicences, (licence)->
 			licence?.subscription_id == subscription_id
 			licence?.subscription_id == subscription_id
 		return licence
 		return licence
-

+ 25 - 9
services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee

@@ -1,10 +1,7 @@
 SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
 SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 SubscriptionLocator = require("./SubscriptionLocator")
 SubscriptionLocator = require("./SubscriptionLocator")
-
 ErrorsController = require("../Errors/ErrorController")
 ErrorsController = require("../Errors/ErrorController")
-settings = require("settings-sharelatex")
-
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 _ = require("underscore")
 _ = require("underscore")
 
 
@@ -12,9 +9,12 @@ module.exports =
 
 
 	addUserToGroup: (req, res)->
 	addUserToGroup: (req, res)->
 		adminUserId = req.session.user._id
 		adminUserId = req.session.user._id
-		newEmail = req.body.email
+		newEmail = req.body?.email?.toLowerCase()?.trim()
 		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
 		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group subscription"
 		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
 		SubscriptionGroupHandler.addUserToGroup adminUserId, newEmail, (err, user)->
+			if err?
+				logger.err err:err, newEmail:newEmail, adminUserId:adminUserId, "error adding user from group"
+				return res.sendStatus 500
 			result = 
 			result = 
 				user:user
 				user:user
 			if err and err.limitReached
 			if err and err.limitReached
@@ -25,7 +25,20 @@ module.exports =
 		adminUserId = req.session.user._id
 		adminUserId = req.session.user._id
 		userToRemove_id = req.params.user_id
 		userToRemove_id = req.params.user_id
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
 		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription"
-		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, ->
+		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
+			if err?
+				logger.err err:err, adminUserId:adminUserId, userToRemove_id:userToRemove_id, "error removing user from group"
+				return res.sendStatus 500
+			res.send()
+			
+	removeSelfFromGroup: (req, res)->
+		adminUserId = req.query.admin_user_id
+		userToRemove_id = req.session.user._id
+		logger.log adminUserId:adminUserId, userToRemove_id:userToRemove_id, "removing user from group subscription after self request"
+		SubscriptionGroupHandler.removeUserFromGroup adminUserId, userToRemove_id, (err)->
+			if err?
+				logger.err err:err, userToRemove_id:userToRemove_id, adminUserId:adminUserId, "error removing self from group"
+				return res.sendStatus 500
 			res.send()
 			res.send()
 
 
 	renderSubscriptionGroupAdminPage: (req, res)->
 	renderSubscriptionGroupAdminPage: (req, res)->
@@ -70,13 +83,16 @@ module.exports =
 		subscription_id = req.params.subscription_id
 		subscription_id = req.params.subscription_id
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 			return ErrorsController.notFound(req, res)
 			return ErrorsController.notFound(req, res)
-		SubscriptionGroupHandler.processGroupVerification req.session.user.email, subscription_id, req.query.token, (err)->
+		email = req?.session?.user?.email
+		logger.log subscription_id:subscription_id, user_id:req?.session?.user?._id, email:email, "starting the completion of joining group"
+		SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
 			if err? and err == "token_not_found"
 			if err? and err == "token_not_found"
-				res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
+				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
 			else if err?
 			else if err?
-				res.sendStatus 500
+				return res.sendStatus 500
 			else
 			else
-				res.redirect "/user/subscription/#{subscription_id}/group/successful-join"
+				logger.log subscription_id:subscription_id, email:email, "user successful completed join of group subscription"
+				return res.redirect "/user/subscription/#{subscription_id}/group/successful-join"
 
 
 	renderSuccessfulJoinPage: (req, res)->
 	renderSuccessfulJoinPage: (req, res)->
 		subscription_id = req.params.subscription_id
 		subscription_id = req.params.subscription_id

+ 29 - 5
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -9,15 +9,32 @@ logger = require("logger-sharelatex")
 OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
 OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
 EmailHandler = require("../Email/EmailHandler")
 EmailHandler = require("../Email/EmailHandler")
 settings = require("settings-sharelatex")
 settings = require("settings-sharelatex")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 
 
 module.exports = SubscriptionGroupHandler =
 module.exports = SubscriptionGroupHandler =
 
 
-	addUserToGroup: (adminUser_id, newEmail, callback)->
+	addUserToGroup: (adminUserId, newEmail, callback)->
+		logger.log adminUserId:adminUserId, newEmail:newEmail, "adding user to group"
 		UserCreator.getUserOrCreateHoldingAccount newEmail, (err, user)->
 		UserCreator.getUserOrCreateHoldingAccount newEmail, (err, user)->
-			LimitationsManager.hasGroupMembersLimitReached adminUser_id, (err, limitReached)->
+			if err?
+				logger.err err:err,  adminUserId:adminUserId, newEmail:newEmail, "error creating user for holding account"
+				return callback(err)
+			if !user?
+				msg = "no user returned whenc reating holidng account or getting user"
+				logger.err adminUserId:adminUserId, newEmail:newEmail, msg
+				return callback(msg)
+			LimitationsManager.hasGroupMembersLimitReached adminUserId, (err, limitReached, subscription)->
+				if err?
+					logger.err err:err, adminUserId:adminUserId, newEmail:newEmail, "error checking if limit reached for group plan"
+					return callback(err)
 				if limitReached
 				if limitReached
+					logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 					return callback(limitReached:limitReached)
 					return callback(limitReached:limitReached)
-				SubscriptionUpdater.addUserToGroup adminUser_id, user._id, (err)->
+				SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
+					if err?
+						logger.err err:err, "error adding user to group"
+						return callback(err)
+					NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read()
 					userViewModel = buildUserViewModel(user)
 					userViewModel = buildUserViewModel(user)
 					callback(err, userViewModel)
 					callback(err, userViewModel)
 
 
@@ -60,13 +77,20 @@ module.exports = SubscriptionGroupHandler =
 			EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback
 			EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback
 
 
 	processGroupVerification: (userEmail, subscription_id, token, callback)->
 	processGroupVerification: (userEmail, subscription_id, token, callback)->
+		logger.log userEmail:userEmail, subscription_id:subscription_id, "processing group verification for user"
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)->
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)->
-			
 			if err?  or subscription_id != token_subscription_id
 			if err?  or subscription_id != token_subscription_id
 				logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
 				logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
 				return callback("token_not_found")
 				return callback("token_not_found")
 			SubscriptionLocator.getSubscription subscription_id, (err, subscription)->
 			SubscriptionLocator.getSubscription subscription_id, (err, subscription)->
-				SubscriptionGroupHandler.addUserToGroup subscription.admin_id, userEmail, callback
+				if err?
+					logger.err err:err, subscription:subscription, userEmail:userEmail, subscription_id:subscription_id, "error getting subscription"
+					return callback(err)
+				if !subscription?
+					logger.warn subscription_id:subscription_id, userEmail:userEmail, "no subscription found"
+					return callback()
+				SubscriptionGroupHandler.addUserToGroup subscription?.admin_id, userEmail, callback
+
 
 
 
 
 buildUserViewModel = (user)->
 buildUserViewModel = (user)->

+ 2 - 2
services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee

@@ -74,5 +74,5 @@ module.exports =
 					return callback("no user found")
 					return callback("no user found")
 				SubscriptionUpdater.syncSubscription recurlySubscription, user?._id, callback
 				SubscriptionUpdater.syncSubscription recurlySubscription, user?._id, callback
 
 
-
-
+	extendTrial: (subscription, daysToExend, callback)->
+		RecurlyWrapper.extendTrial subscription.recurlySubscription_id, daysToExend, callback

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

@@ -22,4 +22,4 @@ 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, callback
+		Subscription.findOne member_ids: user_id, _id:subscription_id, {_id:1}, callback

+ 3 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -24,6 +24,8 @@ module.exports =
 		webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(),  SubscriptionGroupController.addUserToGroup
 		webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(),  SubscriptionGroupController.addUserToGroup
 		webRouter.get '/subscription/group/export',  AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
 		webRouter.get '/subscription/group/export',  AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
 		webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup
 		webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup
+		webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup
+
 
 
 		webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage
 		webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage
 		webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup
 		webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup
@@ -39,6 +41,7 @@ module.exports =
 		webRouter.post '/user/subscription/cancel',     AuthenticationController.requireLogin(), SubscriptionController.cancelSubscription
 		webRouter.post '/user/subscription/cancel',     AuthenticationController.requireLogin(), SubscriptionController.cancelSubscription
 		webRouter.post '/user/subscription/reactivate', AuthenticationController.requireLogin(), SubscriptionController.reactivateSubscription
 		webRouter.post '/user/subscription/reactivate', AuthenticationController.requireLogin(), SubscriptionController.reactivateSubscription
 
 
+		webRouter.put '/user/subscription/extend', AuthenticationController.requireLogin(), SubscriptionController.extendTrial
 
 
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
 		webRouter.post "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan
 		webRouter.post "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan

+ 7 - 2
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -32,6 +32,9 @@ module.exports =
 		insertOperation = 
 		insertOperation = 
 			"$addToSet": {member_ids:user_id}
 			"$addToSet": {member_ids:user_id}
 		Subscription.findAndModify searchOps, insertOperation, (err, subscription)->
 		Subscription.findAndModify searchOps, insertOperation, (err, subscription)->
+			if err?
+				logger.err err:err, searchOps:searchOps, insertOperation:insertOperation, "error findy and modify add user to group"
+				return callback(err)
 			UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
 			UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
 
 
 	removeUserFromGroup: (adminUser_id, user_id, callback)->
 	removeUserFromGroup: (adminUser_id, user_id, callback)->
@@ -39,10 +42,12 @@ module.exports =
 			admin_id: adminUser_id
 			admin_id: adminUser_id
 		removeOperation = 
 		removeOperation = 
 			"$pull": {member_ids:user_id}
 			"$pull": {member_ids:user_id}
-		Subscription.update searchOps, removeOperation, ->
+		Subscription.update searchOps, removeOperation, (err)->
+			if err?
+				logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
+				return callback(err)
 			UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, callback
 			UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, 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"
 		subscription = new Subscription(admin_id:adminUser_id)
 		subscription = new Subscription(admin_id:adminUser_id)

+ 1 - 0
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -27,6 +27,7 @@ module.exports =
 							currency:recurlySubscription?.currency
 							currency:recurlySubscription?.currency
 							taxRate:parseFloat(recurlySubscription?.tax_rate?._)
 							taxRate:parseFloat(recurlySubscription?.tax_rate?._)
 							groupPlan: subscription.groupPlan
 							groupPlan: subscription.groupPlan
+							trial_ends_at:recurlySubscription?.trial_ends_at
 						}, memberSubscriptions
 						}, memberSubscriptions
 				else
 				else
 					callback null, null, memberSubscriptions
 					callback null, null, memberSubscriptions

+ 1 - 2
services/web/app/coffee/Features/Subscription/UserFeaturesUpdater.coffee

@@ -1,4 +1,3 @@
-Settings = require "settings-sharelatex"
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 User = require('../../models/User').User
 User = require('../../models/User').User
 PlansLocator = require("./PlansLocator")
 PlansLocator = require("./PlansLocator")
@@ -9,7 +8,7 @@ module.exports =
 		conditions = _id:user_id
 		conditions = _id:user_id
 		update = {}
 		update = {}
 		plan = PlansLocator.findLocalPlanInSettings(plan_code)
 		plan = PlansLocator.findLocalPlanInSettings(plan_code)
-		logger.log user_id:user_id, plan:plan, plan_code:plan_code, "updating users features"
+		logger.log user_id:user_id, features:plan.features, plan_code:plan_code, "updating users features"
 		update["features.#{key}"] = value for key, value of plan.features
 		update["features.#{key}"] = value for key, value of plan.features
 		User.update conditions, update, (err)->
 		User.update conditions, update, (err)->
 			callback err, plan.features
 			callback err, plan.features

+ 48 - 15
services/web/app/coffee/Features/Tags/TagsController.coffee

@@ -2,20 +2,53 @@ TagsHandler = require("./TagsHandler")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
 module.exports =
 module.exports =
-
-	processTagsUpdate: (req, res)->
+	getAllTags: (req, res, next)->
+		user_id = req.session.user._id
+		logger.log {user_id}, "getting tags"
+		TagsHandler.getAllTags user_id, (error, allTags)->
+			return next(error) if error?
+			res.json(allTags)
+	
+	createTag: (req, res, next) ->
+		user_id = req.session.user._id
+		name = req.body.name
+		logger.log {user_id, name}, "creating tag"
+		TagsHandler.createTag user_id, name, (error, tag) ->
+			return next(error) if error?
+			res.json(tag)
+	
+	addProjectToTag: (req, res, next) ->
+		user_id = req.session.user._id
+		{tag_id, project_id} = req.params
+		logger.log {user_id, tag_id, project_id}, "adding tag to project"
+		TagsHandler.addProjectToTag user_id, tag_id, project_id, (error) ->
+			return next(error) if error?
+			res.status(204).end()
+	
+	removeProjectFromTag: (req, res, next) ->
 		user_id = req.session.user._id
 		user_id = req.session.user._id
-		project_id = req.params.project_id
-		if req.body.deletedTag?
-			tag = req.body.deletedTag
-			TagsHandler.deleteTag user_id, project_id, tag, ->
-				res.send()
+		{tag_id, project_id} = req.params
+		logger.log {user_id, tag_id, project_id}, "removing tag from project"
+		TagsHandler.removeProjectFromTag user_id, tag_id, project_id, (error) ->
+			return next(error) if error?
+			res.status(204).end()
+	
+	deleteTag: (req, res, next) ->
+		user_id = req.session.user._id
+		tag_id = req.params.tag_id
+		logger.log {user_id, tag_id}, "deleting tag"
+		TagsHandler.deleteTag user_id, tag_id, (error) ->
+			return next(error) if error?
+			res.status(204).end()
+	
+	renameTag: (req, res, next) ->
+		user_id = req.session.user._id
+		tag_id = req.params.tag_id
+		name = req.body?.name
+		if !name?
+			return res.status(400).end()
 		else
 		else
-			tag = req.body.tag
-			TagsHandler.addTag user_id, project_id, tag, ->
-				res.send()
-		logger.log user_id:user_id, project_id:project_id, body:req.body, "processing tag update"
-
-	getAllTags: (req, res)->
-		TagsHandler.getAllTags req.session.user._id, (err, allTags)->
-			res.send(allTags)
+			logger.log {user_id, tag_id, name}, "renaming tag"
+			TagsHandler.renameTag user_id, tag_id, name, (error) ->
+				return next(error) if error?
+				res.status(204).end()

+ 69 - 50
services/web/app/coffee/Features/Tags/TagsHandler.coffee

@@ -3,61 +3,84 @@ settings = require("settings-sharelatex")
 request = require("request")
 request = require("request")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
-oneSecond = 1000
-module.exports = 
-
-
-	deleteTag: (user_id, project_id, tag, callback)->
-		uri = buildUri(user_id, project_id)
-		opts =
-			uri:uri
+TIMEOUT = 1000
+module.exports = TagsHandler =
+	getAllTags: (user_id, callback)->
+		@_requestTags user_id, (err, allTags)=>
+			if !allTags?
+				allTags = []
+			@_groupTagsByProject allTags, (err, groupedByProject)->
+				logger.log allTags:allTags, user_id:user_id, groupedByProject:groupedByProject, "got all tags from tags api"
+				callback err, allTags, groupedByProject
+	
+	createTag: (user_id, name, callback = (error, tag) ->) ->
+		opts = 
+			url: "#{settings.apis.tags.url}/user/#{user_id}/tag"
 			json:
 			json:
-				name:tag
-			timeout:oneSecond
-		logger.log user_id:user_id, project_id:project_id, tag:tag, "send delete tag to tags api"
-		request.del opts, callback
+				name: name
+			timeout: TIMEOUT
+		request.post opts, (err, res, body)->
+			TagsHandler._handleResponse err, res, {user_id}, (error) ->
+				return callback(error) if error?
+				callback(null, body or {})
 
 
-	addTag: (user_id, project_id, tag, callback)->
-		uri = buildUri(user_id, project_id)
-		opts =
-			uri:uri
+	renameTag: (user_id, tag_id, name, callback = (error) ->) ->
+		url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/rename"
+		request.post {
+			url: url
 			json:
 			json:
-				name:tag
-			timeout:oneSecond
-		logger.log user_id:user_id, project_id:project_id, tag:tag, "send add tag to tags api"
-		request.post opts, callback
+				name: name
+			timeout: TIMEOUT
+		}, (err, res, body) ->
+			TagsHandler._handleResponse err, res, {url, user_id, tag_id, name}, callback
 
 
-	requestTags: (user_id, callback)->
-		opts = 
-			uri: "#{settings.apis.tags.url}/user/#{user_id}/tag"
-			json: true
-			timeout: 2000
-		request.get opts, (err, res, body)->
-			statusCode =  if res? then res.statusCode else 500
-			if err? or statusCode != 200
-				e = new Error("something went wrong getting tags, #{err}, #{statusCode}")
-				logger.err err:err
-				callback(e, [])
-			else
-				callback(null, body)
+	deleteTag: (user_id, tag_id, callback = (error) ->) ->
+		url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}"
+		request.del {url, timeout: TIMEOUT}, (err, res, body) ->
+			TagsHandler._handleResponse err, res, {url, user_id, tag_id}, callback
 
 
-	getAllTags: (user_id, callback)->
-		@requestTags user_id, (err, allTags)=>
-			if !allTags?
-				allTags = []
-			@groupTagsByProject allTags, (err, groupedByProject)->
-				logger.log allTags:allTags, user_id:user_id, groupedByProject:groupedByProject, "getting all tags from tags api"
-				callback err, allTags, groupedByProject
+	removeProjectFromTag: (user_id, tag_id, project_id, callback)->
+		url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/project/#{project_id}"
+		request.del {url, timeout: TIMEOUT}, (err, res, body) ->
+			TagsHandler._handleResponse err, res, {url, user_id, tag_id, project_id}, callback
+
+	addProjectToTag: (user_id, tag_id, project_id, callback)->
+		url = "#{settings.apis.tags.url}/user/#{user_id}/tag/#{tag_id}/project/#{project_id}"
+		request.post {url, timeout: TIMEOUT}, (err, res, body) ->
+			TagsHandler._handleResponse err, res, {url, user_id, tag_id, project_id}, callback
 
 
 	removeProjectFromAllTags: (user_id, project_id, callback)->
 	removeProjectFromAllTags: (user_id, project_id, callback)->
-		uri = buildUri(user_id, project_id)
+		url = "#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}"
 		opts =
 		opts =
-			uri:"#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}"
-			timeout:oneSecond
-		logger.log user_id:user_id, project_id:project_id, "removing project_id from tags"
-		request.del opts, callback
+			url: url
+			timeout:TIMEOUT
+		request.del opts, (err, res, body) ->
+			TagsHandler._handleResponse err, res, {url, user_id, project_id}, callback
+
+	_handleResponse: (err, res, params, callback) ->
+		if err?
+			params.err = err
+			logger.err params, "error in tag api"
+			return callback(err)
+		else if res? and res.statusCode >= 200 and res.statusCode < 300
+			return callback(null)
+		else
+			err = new Error("tags api returned a failure status code: #{res?.statusCode}")
+			params.err = err
+			logger.err params, "tags api returned failure status code: #{res?.statusCode}"
+			return callback(err)
 
 
-	groupTagsByProject: (tags, callback)->
+	_requestTags: (user_id, callback)->
+		opts = 
+			url: "#{settings.apis.tags.url}/user/#{user_id}/tag"
+			json: true
+			timeout: TIMEOUT
+		request.get opts, (err, res, body)->
+			TagsHandler._handleResponse err, res, {user_id}, (error) ->
+				return callback(error, []) if error?
+				callback(null, body or [])
+
+	_groupTagsByProject: (tags, callback)->
 		result = {}
 		result = {}
 		_.each tags, (tag)->
 		_.each tags, (tag)->
 			_.each tag.project_ids, (project_id)->
 			_.each tag.project_ids, (project_id)->
@@ -69,7 +92,3 @@ module.exports =
 				delete clonedTag.project_ids
 				delete clonedTag.project_ids
 				result[project_id].push(clonedTag)
 				result[project_id].push(clonedTag)
 		callback null, result
 		callback null, result
-
-
-buildUri = (user_id, project_id)->
-	uri = "#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}/tag"

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsController.coffee

@@ -45,7 +45,7 @@ module.exports =
 		path = "/" + req.params[0] # UpdateMerger expects leading slash
 		path = "/" + req.params[0] # UpdateMerger expects leading slash
 		source = req.headers["x-sl-update-source"] or "unknown"
 		source = req.headers["x-sl-update-source"] or "unknown"
 		logger.log project_id: project_id, path: path, source: source, "received project contents update"
 		logger.log project_id: project_id, path: path, source: source, "received project contents update"
-		UpdateMerger.mergeUpdate project_id, path, req, source, (error) ->
+		UpdateMerger.mergeUpdate null, project_id, path, req, source, (error) ->
 			return next(error) if error?
 			return next(error) if error?
 			res.sendStatus(200)
 			res.sendStatus(200)
 			
 			

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee

@@ -27,7 +27,7 @@ module.exports =
 			FileTypeManager.shouldIgnore path, (err, shouldIgnore)->
 			FileTypeManager.shouldIgnore path, (err, shouldIgnore)->
 				if shouldIgnore
 				if shouldIgnore
 					return callback()
 					return callback()
-				updateMerger.mergeUpdate project._id, path, updateRequest, source, callback
+				updateMerger.mergeUpdate user_id, project._id, path, updateRequest, source, callback
 
 
 
 
 	deleteUpdate: (user_id, projectName, path, source, callback)->	
 	deleteUpdate: (user_id, projectName, path, source, callback)->	

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

@@ -42,6 +42,9 @@ module.exports = TpdsUpdateSender =
 
 
 	_addEntity: (options, callback = (err)->)->
 	_addEntity: (options, callback = (err)->)->
 		getProjectsUsersIds options.project_id, (err, user_id, allUserIds)->
 		getProjectsUsersIds options.project_id, (err, user_id, allUserIds)->
+			if err?
+				logger.err err:err, options:options, "error getting projects user ids"
+				return callback(err)
 			logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store"
 			logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store"
 			postOptions =
 			postOptions =
 				method : "post"
 				method : "post"
@@ -53,6 +56,9 @@ module.exports = TpdsUpdateSender =
 				title: "addFile"
 				title: "addFile"
 				streamOrigin : options.streamOrigin
 				streamOrigin : options.streamOrigin
 			TpdsUpdateSender._enqueue options.project_id, "pipeStreamFrom", postOptions, (err)->
 			TpdsUpdateSender._enqueue options.project_id, "pipeStreamFrom", postOptions, (err)->
+				if err?
+					logger.err err:err,  project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "error sending file to third party data store queued up for processing"
+					return callback(err)
 				logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store queued up for processing"
 				logger.log project_id: options.project_id, user_id:user_id, path: options.path, uri:options.uri, rev:options.rev, "sending file to third party data store queued up for processing"
 				callback(err)
 				callback(err)
 	
 	

+ 4 - 4
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -8,7 +8,7 @@ uuid = require('node-uuid')
 fs = require('fs')
 fs = require('fs')
 
 
 module.exports =
 module.exports =
-	mergeUpdate: (project_id, path, updateRequest, source, callback = (error) ->)->
+	mergeUpdate: (user_id, project_id, path, updateRequest, source, callback = (error) ->)->
 		self = @
 		self = @
 		logger.log project_id:project_id, path:path, "merging update from tpds"
 		logger.log project_id:project_id, path:path, "merging update from tpds"
 		projectLocator.findElementByPath project_id, path, (err, element)=>
 		projectLocator.findElementByPath project_id, path, (err, element)=>
@@ -30,7 +30,7 @@ module.exports =
 					if isFile
 					if isFile
 						self.p.processFile project_id, elementId, fsPath, path, source, callback
 						self.p.processFile project_id, elementId, fsPath, path, source, callback
 					else
 					else
-						self.p.processDoc project_id, elementId, fsPath, path, source, callback
+						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 
 
 	deleteUpdate: (project_id, path, source, callback)->
 	deleteUpdate: (project_id, path, source, callback)->
 		projectLocator.findElementByPath project_id, path, (err, element)->
 		projectLocator.findElementByPath project_id, path, (err, element)->
@@ -49,14 +49,14 @@ module.exports =
 
 
 	p:
 	p:
 
 
-		processDoc: (project_id, doc_id, fsPath, path, source, callback)->
+		processDoc: (project_id, doc_id, user_id, fsPath, path, source, callback)->
 			readFileIntoTextArray fsPath, (err, docLines)->
 			readFileIntoTextArray fsPath, (err, docLines)->
 				if err?
 				if err?
 					logger.err project_id:project_id, doc_id:doc_id, fsPath:fsPath, "error reading file into text array for process doc update"
 					logger.err project_id:project_id, doc_id:doc_id, fsPath:fsPath, "error reading file into text array for process doc update"
 					return callback(err)
 					return callback(err)
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				if doc_id?
 				if doc_id?
-					editorController.setDoc project_id, doc_id, docLines, source, (err)->
+					editorController.setDoc project_id, doc_id, user_id, docLines, source, (err)->
 						callback()
 						callback()
 				else
 				else
 					setupNewEntity project_id, path, (err, folder, fileName)->
 					setupNewEntity project_id, path, (err, folder, fileName)->

+ 85 - 12
services/web/app/coffee/Features/Uploads/ArchiveManager.coffee

@@ -1,21 +1,23 @@
 child   = require "child_process"
 child   = require "child_process"
 logger  = require "logger-sharelatex"
 logger  = require "logger-sharelatex"
 metrics = require "../../infrastructure/Metrics"
 metrics = require "../../infrastructure/Metrics"
+fs      = require "fs"
+Path    = require "path"
+_ = require("underscore")
+
+ONE_MEG = 1024 * 1024
 
 
 module.exports = ArchiveManager =
 module.exports = ArchiveManager =
-	extractZipArchive: (source, destination, _callback = (err) ->) ->
-		callback = (args...) ->
-			_callback(args...)
-			_callback = () ->
 
 
-		timer = new metrics.Timer("unzipDirectory")
-		logger.log source: source, destination: destination, "unzipping file"
 
 
-		unzip = child.spawn("unzip", [source, "-d", destination])
+	_isZipTooLarge: (source, callback = (err, isTooLarge)->)->
+		callback = _.once callback
+
+		unzip = child.spawn("unzip", ["-l", source])
 
 
-		# don't remove this line, some zips need
-		# us to listen on this for some unknow reason
+		output = ""
 		unzip.stdout.on "data", (d)->
 		unzip.stdout.on "data", (d)->
+			output += d
 
 
 		error = null
 		error = null
 		unzip.stderr.on "data", (chunk) ->
 		unzip.stderr.on "data", (chunk) ->
@@ -29,9 +31,80 @@ module.exports = ArchiveManager =
 			callback(err)
 			callback(err)
 
 
 		unzip.on "exit", () ->
 		unzip.on "exit", () ->
-			timer.done()
 			if error?
 			if error?
 				error = new Error(error)
 				error = new Error(error)
-				logger.error err:error, source: source, destination: destination, "error unzipping file"
-			callback(error)
+				logger.error err:error, source: source, destination: destination, "error checking zip size"
+
+			lines = output.split("\n")
+			lastLine = lines[lines.length - 2]?.trim()
+			totalSizeInBytes = lastLine?.split(" ")?[0]
+
+			totalSizeInBytes = parseInt(totalSizeInBytes)
+
+			if !totalSizeInBytes? or isNaN(totalSizeInBytes)
+				logger.err source:source, "error getting bytes of zip"
+				return callback(new Error("something went wrong"))
+
+			isTooLarge = totalSizeInBytes > (ONE_MEG * 300)
+
+			callback(error, isTooLarge)
+
+
+
+
+					
+	extractZipArchive: (source, destination, _callback = (err) ->) ->
+		callback = (args...) ->
+			_callback(args...)
+			_callback = () ->
+
+		ArchiveManager._isZipTooLarge source, (err, isTooLarge)->
+			if err?
+				logger.err err:err, "error checking size of zip file"
+				return callback(err)
+
+			if isTooLarge
+				return callback(new Error("zip_too_large"))		
+
+
+			timer = new metrics.Timer("unzipDirectory")
+			logger.log source: source, destination: destination, "unzipping file"
+
+			unzip = child.spawn("unzip", [source, "-d", destination])
+
+			# don't remove this line, some zips need
+			# us to listen on this for some unknow reason
+			unzip.stdout.on "data", (d)->
+
+			error = null
+			unzip.stderr.on "data", (chunk) ->
+				error ||= ""
+				error += chunk
+
+			unzip.on "error", (err) ->
+				logger.error {err, source, destination}, "unzip failed"
+				if err.code == "ENOENT"
+					logger.error "unzip command not found. Please check the unzip command is installed"
+				callback(err)
+
+			unzip.on "exit", () ->
+				timer.done()
+				if error?
+					error = new Error(error)
+					logger.error err:error, source: source, destination: destination, "error unzipping file"
+				callback(error)
+	
+	findTopLevelDirectory: (directory, callback = (error, topLevelDir) ->) ->
+		fs.readdir directory, (error, files) ->
+			return callback(error) if error?
+			if files.length == 1
+				childPath = Path.join(directory, files[0])
+				fs.stat childPath, (error, stat) ->
+					return callback(error) if error?
+					if stat.isDirectory()
+						return callback(null, childPath)
+					else
+						return callback(null, directory)
+			else
+				return callback(null, directory)
 
 

+ 94 - 48
services/web/app/coffee/Features/Uploads/FileSystemImportManager.coffee

@@ -4,62 +4,108 @@ _     = require "underscore"
 FileTypeManager  = require "./FileTypeManager"
 FileTypeManager  = require "./FileTypeManager"
 EditorController = require "../Editor/EditorController"
 EditorController = require "../Editor/EditorController"
 ProjectLocator   = require "../Project/ProjectLocator"
 ProjectLocator   = require "../Project/ProjectLocator"
+logger = require("logger-sharelatex")
 
 
 module.exports = FileSystemImportManager =
 module.exports = FileSystemImportManager =
-	addDoc: (project_id, folder_id, name, path, replace, callback = (error, doc)-> )->
-		fs.readFile path, "utf8", (error, content = "") ->
-			return callback(error) if error?
-			content = content.replace(/\r/g, "")
-			lines = content.split("\n")
-			EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback
-
-	addFile: (project_id, folder_id, name, path, replace, callback = (error, file)-> )->
-		if replace
-			ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
+	addDoc: (user_id, project_id, folder_id, name, path, replace, callback = (error, doc)-> )->
+		FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)->
+			if !isSafe
+				logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, name:name, path:path, "add doc is from symlink, stopping process"
+				return callback("path is symlink")
+			fs.readFile path, "utf8", (error, content = "") ->
 				return callback(error) if error?
 				return callback(error) if error?
-				return callback(new Error("Couldn't find folder")) if !folder?
-				existingFile = null
-				for fileRef in folder.fileRefs
-					if fileRef.name == name
-						existingFile = fileRef
-						break
-				if existingFile?
-					EditorController.replaceFile project_id, existingFile._id, path, "upload", callback
+				content = content.replace(/\r/g, "")
+				lines = content.split("\n")
+				if replace
+					ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
+						return callback(error) if error?
+						return callback(new Error("Couldn't find folder")) if !folder?
+						existingDoc = null
+						for doc in folder.docs
+							if doc.name == name
+								existingDoc = doc
+								break
+						if existingDoc?
+							EditorController.setDoc project_id, existingDoc._id, user_id, lines, "upload", callback
+						else
+							EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback
 				else
 				else
-					EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback
-		else
-			EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback
+					EditorController.addDocWithoutLock project_id, folder_id, name, lines, "upload", callback
+
+	addFile: (user_id, project_id, folder_id, name, path, replace, callback = (error, file)-> )->
+		FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)->
+			if !isSafe
+				logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, name:name, path:path, "add file is from symlink, stopping insert"
+				return callback("path is symlink")
+
+			if !replace
+				EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback
+			else
+				ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
+					return callback(error) if error?
+					return callback(new Error("Couldn't find folder")) if !folder?
+					existingFile = null
+					for fileRef in folder.fileRefs
+						if fileRef.name == name
+							existingFile = fileRef
+							break
+					if existingFile?
+						EditorController.replaceFile project_id, existingFile._id, path, "upload", callback
+					else
+						EditorController.addFileWithoutLock project_id, folder_id, name, path, "upload", callback
+
+	addFolder: (user_id, project_id, folder_id, name, path, replace, callback = (error)-> ) ->
+		FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)->
+			if !isSafe
+				logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, path:path, "add folder is from symlink, stopping insert"
+				return callback("path is symlink")
+			EditorController.addFolderWithoutLock project_id, folder_id, name, "upload", (error, new_folder) =>
+				return callback(error) if error?
+				FileSystemImportManager.addFolderContents user_id, project_id, new_folder._id, path, replace, (error) ->
+					return callback(error) if error?
+					callback null, new_folder
 
 
-	addFolder: (project_id, folder_id, name, path, replace, callback = (error)-> ) ->
-		EditorController.addFolderWithoutLock project_id, folder_id, name, "upload", (error, new_folder) =>
-			return callback(error) if error?
-			@addFolderContents project_id, new_folder._id, path, replace, (error) ->
+	addFolderContents: (user_id, project_id, parent_folder_id, folderPath, replace, callback = (error)-> ) ->
+		FileSystemImportManager._isSafeOnFileSystem folderPath, (err, isSafe)->
+			if !isSafe
+				logger.log user_id:user_id, project_id:project_id, parent_folder_id:parent_folder_id, folderPath:folderPath, "add folder contents is from symlink, stopping insert"
+				return callback("path is symlink")
+			fs.readdir folderPath, (error, entries = []) =>
 				return callback(error) if error?
 				return callback(error) if error?
-				callback null, new_folder
+				jobs = _.map entries, (entry) =>
+					(callback) =>
+						FileTypeManager.shouldIgnore entry, (error, ignore) =>
+							return callback(error) if error?
+							if !ignore
+								FileSystemImportManager.addEntity user_id, project_id, parent_folder_id, entry, "#{folderPath}/#{entry}", replace, callback
+							else
+								callback()
+				async.parallelLimit jobs, 5, callback
+
+	addEntity: (user_id, project_id, folder_id, name, path, replace, callback = (error, entity)-> ) ->
+		FileSystemImportManager._isSafeOnFileSystem path, (err, isSafe)->
+			if !isSafe
+				logger.log user_id:user_id, project_id:project_id, folder_id:folder_id, path:path, "add entry is from symlink, stopping insert"
+				return callback("path is symlink")
 
 
-	addFolderContents: (project_id, parent_folder_id, folderPath, replace, callback = (error)-> ) ->
-		fs.readdir folderPath, (error, entries = []) =>
-			return callback(error) if error?
-			jobs = _.map entries, (entry) =>
-				(callback) =>
-					FileTypeManager.shouldIgnore entry, (error, ignore) =>
+			FileTypeManager.isDirectory path, (error, isDirectory) =>
+				return callback(error) if error?
+				if isDirectory
+					FileSystemImportManager.addFolder user_id, project_id, folder_id, name, path, replace, callback
+				else
+					FileTypeManager.isBinary name, path, (error, isBinary) =>
 						return callback(error) if error?
 						return callback(error) if error?
-						if !ignore
-							@addEntity project_id, parent_folder_id, entry, "#{folderPath}/#{entry}", replace, callback
+						if isBinary
+							FileSystemImportManager.addFile user_id, project_id, folder_id, name, path, replace, callback
 						else
 						else
-							callback()
-			async.parallelLimit jobs, 5, callback
+							FileSystemImportManager.addDoc user_id, project_id, folder_id, name, path, replace, callback
 
 
-	addEntity: (project_id, folder_id, name, path, replace, callback = (error, entity)-> ) ->
-		FileTypeManager.isDirectory path, (error, isDirectory) =>
-			return callback(error) if error?
-			if isDirectory
-				@addFolder project_id, folder_id, name, path, replace, callback
-			else
-				FileTypeManager.isBinary name, path, (error, isBinary) =>
-					return callback(error) if error?
-					if isBinary
-						@addFile project_id, folder_id, name, path, replace, callback
-					else
-						@addDoc project_id, folder_id, name, path, replace, callback
+
+	_isSafeOnFileSystem: (path, callback = (err, isSafe)->)->
+		fs.lstat path, (err, stat)->
+			if err?
+				logger.err err:err, "error with path symlink check"
+				return callback(err)
+			isSafe = stat.isFile() or stat.isDirectory()
+			callback(err, isSafe)
 
 

+ 3 - 1
services/web/app/coffee/Features/Uploads/ProjectUploadController.coffee

@@ -34,7 +34,9 @@ module.exports = ProjectUploadController =
 		if !name? or name.length == 0 or name.length > 150
 		if !name? or name.length == 0 or name.length > 150
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			return res.send success: false
 			return res.send success: false
-		FileSystemImportManager.addEntity project_id, folder_id, name, path, true, (error, entity) ->
+		logger.log folder_id:folder_id, project_id:project_id, "getting upload file request"
+		user_id = req.session.user._id
+		FileSystemImportManager.addEntity user_id, project_id, folder_id, name, path, true, (error, entity) ->
 			fs.unlink path, ->
 			fs.unlink path, ->
 			timer.done()
 			timer.done()
 			if error?
 			if error?

+ 6 - 4
services/web/app/coffee/Features/Uploads/ProjectUploadManager.coffee

@@ -9,19 +9,21 @@ module.exports = ProjectUploadHandler =
 	createProjectFromZipArchive: (owner_id, name, zipPath, callback = (error, project) ->) ->
 	createProjectFromZipArchive: (owner_id, name, zipPath, callback = (error, project) ->) ->
 		ProjectCreationHandler.createBlankProject owner_id, name, (error, project) =>
 		ProjectCreationHandler.createBlankProject owner_id, name, (error, project) =>
 			return callback(error) if error?
 			return callback(error) if error?
-			@insertZipArchiveIntoFolder project._id, project.rootFolder[0]._id, zipPath, (error) ->
+			@insertZipArchiveIntoFolder owner_id, project._id, project.rootFolder[0]._id, zipPath, (error) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				ProjectRootDocManager.setRootDocAutomatically project._id, (error) ->
 				ProjectRootDocManager.setRootDocAutomatically project._id, (error) ->
 					return callback(error) if error?
 					return callback(error) if error?
 					callback(error, project)
 					callback(error, project)
 
 
-	insertZipArchiveIntoFolder: (project_id, folder_id, path, callback = (error) ->) ->
+	insertZipArchiveIntoFolder: (owner_id, project_id, folder_id, path, callback = (error) ->) ->
 		destination = @_getDestinationDirectory path
 		destination = @_getDestinationDirectory path
 		ArchiveManager.extractZipArchive path, destination, (error) ->
 		ArchiveManager.extractZipArchive path, destination, (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			FileSystemImportManager.addFolderContents project_id, folder_id, destination, false, (error) ->
+			ArchiveManager.findTopLevelDirectory destination, (error, topLevelDestination) ->
 				return callback(error) if error?
 				return callback(error) if error?
-				rimraf(destination, callback)
+				FileSystemImportManager.addFolderContents owner_id, project_id, folder_id, topLevelDestination, false, (error) ->
+					return callback(error) if error?
+					rimraf(destination, callback)
 
 
 	_getDestinationDirectory: (source) ->
 	_getDestinationDirectory: (source) ->
 		return path.join(path.dirname(source), "#{path.basename(source, ".zip")}-#{Date.now()}")
 		return path.join(path.dirname(source), "#{path.basename(source, ".zip")}-#{Date.now()}")

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

@@ -13,8 +13,8 @@ module.exports =
 			RateLimiterMiddlewear.rateLimit({
 			RateLimiterMiddlewear.rateLimit({
 				endpointName: "file-upload"
 				endpointName: "file-upload"
 				params: ["Project_id"]
 				params: ["Project_id"]
-				maxRequests: 100
-				timeInterval: 60 * 20
+				maxRequests: 200
+				timeInterval: 60 * 30
 			}),
 			}),
 			SecurityManager.requestCanModifyProject,
 			SecurityManager.requestCanModifyProject,
 			ProjectUploadController.uploadFile
 			ProjectUploadController.uploadFile

+ 17 - 32
services/web/app/coffee/Features/User/UserController.coffee

@@ -1,3 +1,4 @@
+UserHandler = require("./UserHandler")
 UserDeleter = require("./UserDeleter")
 UserDeleter = require("./UserDeleter")
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
 User = require("../../models/User").User
 User = require("../../models/User").User
@@ -8,12 +9,9 @@ metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 UserUpdater = require("./UserUpdater")
 UserUpdater = require("./UserUpdater")
-EmailHandler = require("../Email/EmailHandler")
-OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
 settings = require "settings-sharelatex"
 settings = require "settings-sharelatex"
-crypto = require "crypto"
 
 
-module.exports =
+module.exports = UserController =
 
 
 	deleteUser: (req, res)->
 	deleteUser: (req, res)->
 		user_id = req.session.user._id
 		user_id = req.session.user._id
@@ -66,11 +64,18 @@ module.exports =
 						if err?
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
 							if err.message == "alread_exists"
 							if err.message == "alread_exists"
-								message = req.i18n.translate("alread_exists")
+								message = req.i18n.translate("email_already_registered")
 							else
 							else
 								message = req.i18n.translate("problem_changing_email_address")
 								message = req.i18n.translate("problem_changing_email_address")
 							return res.send 500, {message:message}
 							return res.send 500, {message:message}
-						res.sendStatus(200)
+						User.findById user_id, (err, user)->
+							if err?
+								logger.err err:err, user_id:user_id, "error getting user for email update"
+								return res.send 500
+							UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background
+								if err?
+									logger.err err:err, "error populateGroupLicenceInvite"
+								res.sendStatus(200)
 
 
 	logout : (req, res)->
 	logout : (req, res)->
 		metrics.inc "user.logout"
 		metrics.inc "user.logout"
@@ -85,32 +90,12 @@ module.exports =
 		if !email? or email == ""
 		if !email? or email == ""
 			res.sendStatus 422 # Unprocessable Entity
 			res.sendStatus 422 # Unprocessable Entity
 			return
 			return
-		logger.log {email}, "registering new user"
-		UserRegistrationHandler.registerNewUser {
-			email: email
-			password: crypto.randomBytes(32).toString("hex")
-		}, (err, user)->
-			if err? and err?.message != "EmailAlreadyRegistered"
-				return next(err)
-			
-			if err?.message == "EmailAlreadyRegistered"
-				logger.log {email}, "user already exists, resending welcome email"
-
-			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-			OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
-				return next(err) if err?
-				
-				setNewPasswordUrl = "#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}&email=#{encodeURIComponent(email)}"
-
-				EmailHandler.sendEmail "registered", {
-					to: user.email
-					setNewPasswordUrl: setNewPasswordUrl
-				}, () ->
-					
-				res.json {
-					email: user.email
-					setNewPasswordUrl: setNewPasswordUrl
-				}
+		UserRegistrationHandler.registerNewUserAndSendActivationEmail email, (error, user, setNewPasswordUrl) ->
+			return next(error) if error?
+			res.json {
+				email: user.email
+				setNewPasswordUrl: setNewPasswordUrl
+			}
 
 
 	changePassword : (req, res, next = (error) ->)->
 	changePassword : (req, res, next = (error) ->)->
 		metrics.inc "user.password-change"
 		metrics.inc "user.password-change"

+ 26 - 0
services/web/app/coffee/Features/User/UserHandler.coffee

@@ -0,0 +1,26 @@
+SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+SubscriptionGroupHandler = require("../Subscription/SubscriptionGroupHandler")
+logger = require("logger-sharelatex")
+
+
+module.exports = UserHandler =
+
+	populateGroupLicenceInvite: (user, callback)->
+		logger.log user_id:user._id, "populating any potential group licence invites"
+		licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
+		if !licence?
+			return callback()
+
+		SubscriptionGroupHandler.isUserPartOfGroup user._id, licence.subscription_id, (err, alreadyPartOfGroup)->
+			if err?
+				return callback(err)
+			else if alreadyPartOfGroup
+				logger.log user_id:user._id, "user already part of group, not creating notifcation for them"
+				return callback()
+			else
+				NotificationsBuilder.groupPlan(user, licence).create(callback)
+
+	setupLoginData: (user, callback = ->)->
+		@populateGroupLicenceInvite user, callback
+

+ 25 - 1
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -1,4 +1,6 @@
 UserLocator = require("./UserLocator")
 UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
+ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
 fs = require('fs')
@@ -20,11 +22,33 @@ module.exports =
 			sharedProjectData: sharedProjectData
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			newTemplateData: newTemplateData
 			new_email:req.query.new_email || ""
 			new_email:req.query.new_email || ""
+	
+	activateAccountPage: (req, res) ->
+		# An 'activation' is actually just a password reset on an account that
+		# was set with a random password originally.
+		if !req.query?.user_id? or !req.query?.token?
+			return ErrorController.notFound(req, res)
+			
+		UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
+			return next(error) if error?
+			if !user
+				return ErrorController.notFound(req, res)
+			if user.loginCount > 0
+				# Already seen this user, so account must be activate
+				# This lets users keep clicking the 'activate' link in their email
+				# as a way to log in which, if I know our users, they will.
+				res.redirect "/login?email=#{encodeURIComponent(user.email)}"
+			else
+				res.render 'user/activate',
+					title: 'activate_account'
+					email: user.email,
+					token: req.query.token
 
 
 	loginPage : (req, res)->
 	loginPage : (req, res)->
 		res.render 'user/login',
 		res.render 'user/login',
 			title: 'login',
 			title: 'login',
-			redir: req.query.redir
+			redir: req.query.redir,
+			email: req.query.email
 
 
 	settingsPage : (req, res, next)->
 	settingsPage : (req, res, next)->
 		logger.log user: req.session.user, "loading settings page"
 		logger.log user: req.session.user, "loading settings page"

+ 30 - 1
services/web/app/coffee/Features/User/UserRegistrationHandler.coffee

@@ -5,8 +5,12 @@ AuthenticationManager = require("../Authentication/AuthenticationManager")
 NewsLetterManager = require("../Newsletter/NewsletterManager")
 NewsLetterManager = require("../Newsletter/NewsletterManager")
 async = require("async")
 async = require("async")
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
+crypto = require("crypto")
+EmailHandler = require("../Email/EmailHandler")
+OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+settings = require "settings-sharelatex"
 
 
-module.exports =
+module.exports = UserRegistrationHandler =
 	validateEmail : (email) ->
 	validateEmail : (email) ->
 		re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\ ".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA -Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
 		re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\ ".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA -Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
 		return re.test(email)
 		return re.test(email)
@@ -58,6 +62,31 @@ module.exports =
 				], (err)->
 				], (err)->
 					logger.log user: user, "registered"
 					logger.log user: user, "registered"
 					callback(err, user)
 					callback(err, user)
+	
+	registerNewUserAndSendActivationEmail: (email, callback = (error, user, setNewPasswordUrl) ->) ->
+		logger.log {email}, "registering new user"
+		UserRegistrationHandler.registerNewUser {
+			email: email
+			password: crypto.randomBytes(32).toString("hex")
+		}, (err, user)->
+			if err? and err?.message != "EmailAlreadyRegistered"
+				return callback(err)
+			
+			if err?.message == "EmailAlreadyRegistered"
+				logger.log {email}, "user already exists, resending welcome email"
+
+			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
+			OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+				return callback(err) if err?
+				
+				setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}"
+
+				EmailHandler.sendEmail "registered", {
+					to: user.email
+					setNewPasswordUrl: setNewPasswordUrl
+				}, () ->
+				
+				callback null, user, setNewPasswordUrl
 
 
 
 
 
 

+ 3 - 3
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -23,9 +23,9 @@ for path in [
 	"#{jsPath}main.js",
 	"#{jsPath}main.js",
 	"#{jsPath}libs.js",
 	"#{jsPath}libs.js",
 	"#{jsPath}ace/ace.js",
 	"#{jsPath}ace/ace.js",
-	"#{jsPath}libs/pdfjs-1.0.1040/pdf.js",
-	"#{jsPath}libs/pdfjs-1.0.1040/pdf.worker.js",
-	"#{jsPath}libs/pdfjs-1.0.1040/compatibility.js",
+	"#{jsPath}libs/pdfjs-1.3.91/pdf.js",
+	"#{jsPath}libs/pdfjs-1.3.91/pdf.worker.js",
+	"#{jsPath}libs/pdfjs-1.3.91/compatibility.js",
 	"/stylesheets/style.css"
 	"/stylesheets/style.css"
 ]
 ]
 	filePath = Path.join __dirname, "../../../", "public#{path}"
 	filePath = Path.join __dirname, "../../../", "public#{path}"

+ 2 - 45
services/web/app/coffee/models/Project.coffee

@@ -31,6 +31,7 @@ ProjectSchema = new Schema
 	description : {type:String, default:''}
 	description : {type:String, default:''}
 	archived          : { type: Boolean }
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
 	deletedDocs       : [DeletedDocSchema]
+	imageName         : { type: String }
 
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	if project_or_id._id?
 	if project_or_id._id?
@@ -65,51 +66,7 @@ ProjectSchema.statics.findAllUsersProjects = (user_id, requiredFields, callback)
 			this.find {readOnly_refs:user_id}, requiredFields, (err, readOnlyProjects)=>
 			this.find {readOnly_refs:user_id}, requiredFields, (err, readOnlyProjects)=>
 				callback(err, projects, collabertions, readOnlyProjects)
 				callback(err, projects, collabertions, readOnlyProjects)
 
 
-sanitizeTypeOfElement = (elementType)->
-	lastChar = elementType.slice -1
-	if lastChar != "s"
-		elementType +="s"
-	if elementType == "files"
-		elementType = "fileRefs"
-	return elementType
-
-ProjectSchema.statics.putElement = (project_id, folder_id, element, type, callback)->
-	if !element?
-		e = new Error("no element passed to be inserted")
-		logger.err project_id:project_id, folder_id:folder_id, element:element, type:type, "failed trying to insert element as it was null"
-		return callback(e)
-	type = sanitizeTypeOfElement type
-	this.findById project_id, (err, project)=>
-		if err?
-			callback(err)
-		if !folder_id?
-			folder_id = project.rootFolder[0]._id
-		require('../Features/Project/ProjectLocator').findElement {project:project, element_id:folder_id, type:"folders"}, (err, folder, path)=>
-			newPath =
-				fileSystem: "#{path.fileSystem}/#{element.name}"
-				mongo: path.mongo # TODO: This is not correct
-			if err?
-				callback(err)
-			logger.log project_id: project_id, element_id: element._id, fileType: type, folder_id: folder_id, "adding element to project"
-			id = element._id+''
-			element._id = concreteObjectId(id)
-			conditions = _id:project_id
-			mongopath = "#{path.mongo}.#{type}"
-			update = "$push":{}
-			update["$push"][mongopath] = element
-			this.update conditions, update, {}, (err)->
-				if(err)
-					logger.err err: err, project: project, 'error saving in putElement project'
-				if callback?
-					callback(err, {path:newPath})
-
-getIndexOf = (searchEntity, id)->
-	length = searchEntity.length
-	count = 0
-	while(count < length)
-		if searchEntity[count]._id+"" == id+""
-			return count
-		count++
+
 
 
 
 
 
 

+ 2 - 0
services/web/app/coffee/models/User.coffee

@@ -34,6 +34,8 @@ UserSchema = new Schema
 							github:        { type:Boolean, default: Settings.defaultFeatures.github }
 							github:        { type:Boolean, default: Settings.defaultFeatures.github }
 							compileTimeout: { type:Number, default: Settings.defaultFeatures.compileTimeout }
 							compileTimeout: { type:Number, default: Settings.defaultFeatures.compileTimeout }
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
+							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
+							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 						}
 						}
 	featureSwitches	  : {
 	featureSwitches	  : {
 		pdfng: { type: Boolean }
 		pdfng: { type: Boolean }

+ 24 - 10
services/web/app/coffee/router.coffee

@@ -16,6 +16,7 @@ ReferalController = require('./Features/Referal/ReferalController')
 ReferalMiddleware = require('./Features/Referal/ReferalMiddleware')
 ReferalMiddleware = require('./Features/Referal/ReferalMiddleware')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
 TagsController = require("./Features/Tags/TagsController")
+NotificationsController = require("./Features/Notifications/NotificationsController")
 CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter')
 CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter')
 UserInfoController = require('./Features/User/UserInfoController')
 UserInfoController = require('./Features/User/UserInfoController')
 UserController = require("./Features/User/UserController")
 UserController = require("./Features/User/UserController")
@@ -37,6 +38,7 @@ RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
 RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
 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')
 
 
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 _ = require("underscore")
 _ = require("underscore")
@@ -46,7 +48,7 @@ module.exports = class Router
 		if !Settings.allowPublicAccess
 		if !Settings.allowPublicAccess
 			webRouter.all '*', AuthenticationController.requireGlobalLogin
 			webRouter.all '*', AuthenticationController.requireGlobalLogin
 
 
-		
+
 		webRouter.get  '/login', UserPagesController.loginPage
 		webRouter.get  '/login', UserPagesController.loginPage
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 
 
@@ -67,16 +69,18 @@ module.exports = class Router
 		StaticPagesRouter.apply(webRouter, apiRouter)
 		StaticPagesRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
-		
+
 		Modules.applyRouter(webRouter, apiRouter)
 		Modules.applyRouter(webRouter, apiRouter)
 
 
 
 
 		if Settings.enableSubscriptions
 		if Settings.enableSubscriptions
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus
-		
+
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog/*', BlogController.getPage
 		webRouter.get '/blog/*', BlogController.getPage
-		
+
+		webRouter.get '/user/activate', UserPagesController.activateAccountPage
+
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
@@ -128,8 +132,15 @@ module.exports = class Router
 		webRouter.get  '/Project/:Project_id/download/zip', SecurityManager.requestCanAccessProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/Project/:Project_id/download/zip', SecurityManager.requestCanAccessProject, ProjectDownloadsController.downloadProject
 		webRouter.get  '/project/download/zip', SecurityManager.requestCanAccessMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 		webRouter.get  '/project/download/zip', SecurityManager.requestCanAccessMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
 
 
-		webRouter.get '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags
-		webRouter.post '/project/:project_id/tag', AuthenticationController.requireLogin(), TagsController.processTagsUpdate
+		webRouter.get    '/tag', AuthenticationController.requireLogin(), TagsController.getAllTags
+		webRouter.post   '/tag', AuthenticationController.requireLogin(), TagsController.createTag
+		webRouter.post   '/tag/:tag_id/rename', AuthenticationController.requireLogin(), TagsController.renameTag
+		webRouter.delete '/tag/:tag_id', AuthenticationController.requireLogin(), TagsController.deleteTag
+		webRouter.post   '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.addProjectToTag
+		webRouter.delete '/tag/:tag_id/project/:project_id', AuthenticationController.requireLogin(), TagsController.removeProjectFromTag
+
+		webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
+		webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead	
 
 
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		# Deprecated in favour of /internal/project/:project_id but still used by versioning
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
 		apiRouter.get  '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
@@ -156,7 +167,7 @@ module.exports = class Router
 
 
 		apiRouter.post '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.mergeUpdate
 		apiRouter.post '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.mergeUpdate
 		apiRouter.delete '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.deleteUpdate
 		apiRouter.delete '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.deleteUpdate
-		
+
 		apiRouter.post '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.updateProjectContents
 		apiRouter.post '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.updateProjectContents
 		apiRouter.delete '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.deleteProjectContents
 		apiRouter.delete '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.deleteProjectContents
 
 
@@ -165,9 +176,12 @@ module.exports = class Router
 
 
 		webRouter.get  "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.getMessages
 		webRouter.get  "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.getMessages
 		webRouter.post "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.sendMessage
 		webRouter.post "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.sendMessage
-		
+
 		webRouter.get  /learn(\/.*)?/, WikiController.getPage
 		webRouter.get  /learn(\/.*)?/, WikiController.getPage
 
 
+		webRouter.post "/project/:Project_id/references/index", SecurityManager.requestCanAccessProject, ReferencesController.index
+		webRouter.post "/project/:Project_id/references/indexAll", SecurityManager.requestCanAccessProject, ReferencesController.indexAll
+
 		#Admin Stuff
 		#Admin Stuff
 		webRouter.get  '/admin', SecurityManager.requestIsAdmin, AdminController.index
 		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/user', SecurityManager.requestIsAdmin, (req, res)-> res.redirect("/admin/register") #this gets removed by admin-panel addon
@@ -186,7 +200,7 @@ module.exports = class Router
 
 
 		apiRouter.get '/status', (req,res)->
 		apiRouter.get '/status', (req,res)->
 			res.send("websharelatex is up")
 			res.send("websharelatex is up")
-		
+
 
 
 		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
@@ -222,4 +236,4 @@ module.exports = class Router
 			logger.error err: req.body.error, meta: req.body.meta, "client side error"
 			logger.error err: req.body.error, meta: req.body.meta, "client side error"
 			res.sendStatus(204)
 			res.sendStatus(204)
 
 
-		webRouter.get '*', ErrorController.notFound
+		webRouter.get '*', ErrorController.notFound

+ 11 - 5
services/web/app/views/project/editor.jade

@@ -53,7 +53,13 @@ block content
 
 
 				include ./editor/share
 				include ./editor/share
 
 
-				#ide-body(ng-cloak, layout="main", ng-hide="state.loading", resize-on="layout:chat:resize")
+				#ide-body(
+					ng-cloak,
+					layout="main",
+					ng-hide="state.loading",
+					resize-on="layout:chat:resize",
+					minimum-restore-size-west="130"
+				)
 					.ui-layout-west
 					.ui-layout-west
 						include ./editor/file-tree
 						include ./editor/file-tree
 
 
@@ -76,7 +82,7 @@ block content
 				ng-click="done()"
 				ng-click="done()"
 			) &times;
 			) &times;
 			h3 {{ title }}
 			h3 {{ title }}
-		.modal-body {{ message }}
+		.modal-body(ng-bind-html="message")
 		.modal-footer
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
 
@@ -95,13 +101,13 @@ block content
 			"paths" : {
 			"paths" : {
 				"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
 				"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
 				"moment": "libs/moment-2.7.0",
 				"moment": "libs/moment-2.7.0",
-				"libs/pdf": "libs/pdfjs-1.0.1040/pdf"
+				"libs/pdf": "libs/pdfjs-1.3.91/pdf"
 			},
 			},
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"waitSeconds": 0,
 			"waitSeconds": 0,
 			"shim": {
 			"shim": {
 				"libs/pdf": {
 				"libs/pdf": {
-					deps: ["libs/pdfjs-1.0.1040/compatibility"]
+					deps: ["libs/pdfjs-1.3.91/compatibility"]
 				},
 				},
 				"ace/ext-searchbox": {
 				"ace/ext-searchbox": {
 					deps: ["ace/ace"]
 					deps: ["ace/ace"]
@@ -120,7 +126,7 @@ block content
 
 
 	- locals.suppressDefaultJs = true
 	- locals.suppressDefaultJs = true
 
 
-	- var pdfPath = 'libs/pdfjs-1.0.1040/pdf.worker.js'
+	- var pdfPath = 'libs/pdfjs-1.3.91/pdf.worker.js'
 	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
 	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
 	- var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
 	- var pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
 	script(type='text/javascript').
 	script(type='text/javascript').

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

@@ -6,6 +6,7 @@ div.full-size(
 	resize-on="layout:main:resize"
 	resize-on="layout:main:resize"
 	resize-proportionally="true"
 	resize-proportionally="true"
 	initial-size-east="'50%'"
 	initial-size-east="'50%'"
+	minimum-restore-size-east="300"
 )
 )
 	.ui-layout-center
 	.ui-layout-center
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
@@ -19,6 +20,7 @@ div.full-size(
 			keybindings="settings.mode",
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			font-size="settings.fontSize",
 			auto-complete="settings.autoComplete",
 			auto-complete="settings.autoComplete",
+			spell-check="true",
 			spell-check-language="project.spellCheckLanguage",
 			spell-check-language="project.spellCheckLanguage",
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			show-print-margin="false",
 			show-print-margin="false",

+ 57 - 35
services/web/app/views/project/editor/file-tree.jade

@@ -1,4 +1,4 @@
-aside#file-tree(ng-controller="FileTreeController").full-size
+aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected': multiSelectedCount > 0 }").full-size
 	.toolbar.toolbar-small.toolbar-alt(ng-if="permissions.write")
 	.toolbar.toolbar-small.toolbar-alt(ng-if="permissions.write")
 		a(
 		a(
 			href,
 			href,
@@ -27,7 +27,8 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 				href,
 				href,
 				ng-click="startRenamingSelected()",
 				ng-click="startRenamingSelected()",
 				tooltip="#{translate('rename')}",
 				tooltip="#{translate('rename')}",
-				tooltip-placement="bottom"
+				tooltip-placement="bottom",
+				ng-show="multiSelectedCount == 0"
 			)
 			)
 				i.fa.fa-pencil
 				i.fa.fa-pencil
 			a(
 			a(
@@ -45,26 +46,24 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 		ng-controller="FileTreeRootFolderController",
 		ng-controller="FileTreeRootFolderController",
 		ng-class="{ 'no-toolbar': !permissions.write }"
 		ng-class="{ 'no-toolbar': !permissions.write }"
 	)
 	)
-
-		div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')")
-			ul.list-unstyled.file-tree-list
-				li(
-					ng-class="{ 'selected': ui.view == 'pdf' }"
-					ng-controller="PdfViewToggleController"
-				)
-					.entity
-						.entity-name(
-							ng-click="togglePdfView()"
-						)
-							i.fa.fa-fw.toggle
-							i.fa.fa-fw.fa-file-pdf-o
-							|  PDF
-	
 		ul.list-unstyled.file-tree-list(
 		ul.list-unstyled.file-tree-list(
 			droppable="permissions.write"
 			droppable="permissions.write"
 			accept=".entity-name"
 			accept=".entity-name"
 			on-drop-callback="onDrop"
 			on-drop-callback="onDrop"
 		)
 		)
+			li(
+				ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')"
+				ng-class="{ 'selected': ui.view == 'pdf' }"
+				ng-controller="PdfViewToggleController"
+			)
+				.entity
+					.entity-name(
+						ng-click="togglePdfView()"
+					)
+						i.fa.fa-fw.toggle
+						i.fa.fa-fw.fa-file-pdf-o
+						|  PDF
+
 			file-entity(
 			file-entity(
 				entity="entity",
 				entity="entity",
 				permissions="permissions",
 				permissions="permissions",
@@ -81,27 +80,25 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 				)
 				)
 					.entity
 					.entity
 						.entity-name(
 						.entity-name(
-							ng-click="select()"
+							ng-click="select($event)"
 						)
 						)
 							//- Just a spacer to align with folders
 							//- Just a spacer to align with folders
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.fa-file
 							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(
-		ng-class="{ 'selected': entity.selected }",
+		ng-class="{ 'selected': entity.selected, 'multi-selected': entity.multiSelected }",
 		ng-controller="FileTreeEntityController"
 		ng-controller="FileTreeEntityController"
 	)
 	)
 		.entity(ng-if="entity.type != 'folder'")
 		.entity(ng-if="entity.type != 'folder'")
 			.entity-name(
 			.entity-name(
-				ng-click="select()"
+				ng-click="select($event)"
 				ng-dblclick="permissions.write && startRenaming()"
 				ng-dblclick="permissions.write && startRenaming()"
 				draggable="permissions.write"
 				draggable="permissions.write"
+				draggable-helper="draggableHelper"
 				context-menu
 				context-menu
 				data-target="context-menu-{{ entity.id }}"
 				data-target="context-menu-{{ entity.id }}"
 				context-menu-container="body"
 				context-menu-container="body"
@@ -112,7 +109,6 @@ script(type='text/ng-template', id='entityListItemTemplate')
 
 
 				i.fa.fa-fw.fa-file(ng-if="entity.type == 'doc'")
 				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.fa-image(ng-if="entity.type == 'file'")
-
 				span(
 				span(
 					ng-hide="entity.renaming"
 					ng-hide="entity.renaming"
 				)  {{ entity.name }}
 				)  {{ entity.name }}
@@ -126,12 +122,11 @@ script(type='text/ng-template', id='entityListItemTemplate')
 						on-enter="finishRenaming()"
 						on-enter="finishRenaming()"
 					)
 					)
 
 
-				span.dropdown(
+				span.dropdown.entity-menu-toggle(
 					dropdown,
 					dropdown,
-					ng-show="entity.selected",
 					ng-if="permissions.write"
 					ng-if="permissions.write"
 				)
 				)
-					a.dropdown-toggle(href, dropdown-toggle)
+					a.dropdown-toggle(href, dropdown-toggle, stop-propagation="click")
 						i.fa.fa-chevron-down
 						i.fa.fa-chevron-down
 
 
 					ul.dropdown-menu.dropdown-menu-right
 					ul.dropdown-menu.dropdown-menu-right
@@ -140,12 +135,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 								href
 								href
 								ng-click="startRenaming()"
 								ng-click="startRenaming()"
 								right-click="startRenaming()"
 								right-click="startRenaming()"
+								ng-show="!entity.multiSelected"
 							) #{translate("rename")}
 							) #{translate("rename")}
 						li
 						li
 							a(
 							a(
 								href
 								href
 								ng-click="openDeleteModal()"
 								ng-click="openDeleteModal()"
 								right-click="openDeleteModal()"
 								right-click="openDeleteModal()"
+								stop-propagation="click"
 							) #{translate("delete")}
 							) #{translate("delete")}
 
 
 				div.dropdown.context-menu(
 				div.dropdown.context-menu(
@@ -158,20 +155,23 @@ script(type='text/ng-template', id='entityListItemTemplate')
 								href
 								href
 								ng-click="startRenaming()"
 								ng-click="startRenaming()"
 								right-click="startRenaming()"
 								right-click="startRenaming()"
+								ng-show="!entity.multiSelected"
 							) #{translate("rename")}
 							) #{translate("rename")}
 						li
 						li
 							a(
 							a(
 								href
 								href
 								ng-click="openDeleteModal()"
 								ng-click="openDeleteModal()"
 								right-click="openDeleteModal()"
 								right-click="openDeleteModal()"
+								stop-propagation="click"
 							) #{translate("delete")}
 							) #{translate("delete")}
 
 
 
 
 		.entity(ng-if="entity.type == 'folder'", ng-controller="FileTreeFolderController")
 		.entity(ng-if="entity.type == 'folder'", ng-controller="FileTreeFolderController")
 			.entity-name(
 			.entity-name(
-				ng-click="select()"
+				ng-click="select($event)"
 				ng-dblclick="permissions.write && startRenaming()"
 				ng-dblclick="permissions.write && startRenaming()"
 				draggable="permissions.write"
 				draggable="permissions.write"
+				draggable-helper="draggableHelper"
 				droppable="permissions.write"
 				droppable="permissions.write"
 				accept=".entity-name"
 				accept=".entity-name"
 				on-drop-callback="onDrop"
 				on-drop-callback="onDrop"
@@ -194,7 +194,7 @@ script(type='text/ng-template', id='entityListItemTemplate')
 							'fa-folder': !expanded, \
 							'fa-folder': !expanded, \
 							'fa-folder-open': expanded \
 							'fa-folder-open': expanded \
 						}"
 						}"
-						ng-click="select()"
+						ng-click="select($event)"
 					)
 					)
 
 
 					span(
 					span(
@@ -210,12 +210,11 @@ script(type='text/ng-template', id='entityListItemTemplate')
 							on-enter="finishRenaming()"
 							on-enter="finishRenaming()"
 						)
 						)
 
 
-					span.dropdown(
+					span.dropdown.entity-menu-toggle(
 						dropdown,
 						dropdown,
 						ng-if="permissions.write"
 						ng-if="permissions.write"
-						ng-show="entity.selected"
 					)
 					)
-						a.dropdown-toggle(href, dropdown-toggle)
+						a.dropdown-toggle(href, dropdown-toggle, stop-propagation="click")
 							i.fa.fa-chevron-down
 							i.fa.fa-chevron-down
 
 
 						ul.dropdown-menu.dropdown-menu-right
 						ul.dropdown-menu.dropdown-menu-right
@@ -224,12 +223,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 									href
 									href
 									ng-click="startRenaming()"
 									ng-click="startRenaming()"
 									right-click="startRenaming()"
 									right-click="startRenaming()"
+									ng-show="!entity.multiSelected"
 								) #{translate("rename")}
 								) #{translate("rename")}
 							li
 							li
 								a(
 								a(
 									href
 									href
 									ng-click="openDeleteModal()"
 									ng-click="openDeleteModal()"
 									right-click="openDeleteModal()"
 									right-click="openDeleteModal()"
+									stop-propagation="click"
 								) #{translate("delete")}
 								) #{translate("delete")}
 							li.divider
 							li.divider
 							li
 							li
@@ -261,12 +262,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 									href
 									href
 									ng-click="startRenaming()"
 									ng-click="startRenaming()"
 									right-click="startRenaming()"
 									right-click="startRenaming()"
+									ng-show="!entity.multiSelected"
 								) #{translate("rename")}
 								) #{translate("rename")}
 							li
 							li
 								a(
 								a(
 									href
 									href
 									ng-click="openDeleteModal()"
 									ng-click="openDeleteModal()"
 									right-click="openDeleteModal()"
 									right-click="openDeleteModal()"
+									stop-propagation="click"
 								) #{translate("delete")}
 								) #{translate("delete")}
 							li.divider
 							li.divider
 							li
 							li
@@ -306,6 +309,7 @@ script(type='text/ng-template', id='newDocModalTemplate')
 		h3 #{translate("new_file")}
 		h3 #{translate("new_file")}
 	.modal-body
 	.modal-body
 		form(novalidate, name="newDocForm")
 		form(novalidate, name="newDocForm")
+			div.alert.alert-danger(ng-if="error") {{error}}
 			input.form-control(
 			input.form-control(
 				type="text",
 				type="text",
 				placeholder="File Name",
 				placeholder="File Name",
@@ -330,6 +334,7 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 	.modal-header
 	.modal-header
 		h3 #{translate("new_folder")}
 		h3 #{translate("new_folder")}
 	.modal-body
 	.modal-body
+		div.alert.alert-danger(ng-if="error") {{error}}
 		form(novalidate, name="newFolderForm")
 		form(novalidate, name="newFolderForm")
 			input.form-control(
 			input.form-control(
 				type="text",
 				type="text",
@@ -354,9 +359,20 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 script(type="text/ng-template", id="uploadFileModalTemplate")
 script(type="text/ng-template", id="uploadFileModalTemplate")
 	.modal-header
 	.modal-header
 		h3 #{translate("upload_files")}
 		h3 #{translate("upload_files")}
-		span &nbsp;
-		.alert.alert-warning.small(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
-		.alert.alert-warning.small(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
+		.alert.alert-warning.small.modal-alert(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
+		.alert.alert-warning.small.modal-alert(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
+		.alert.alert-warning.small.modal-alert(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
+		.alert.alert-warning.small.modal-alert(ng-if="conflicts.length > 0")
+			p.text-center
+				| The following files already exist in this project:
+			ul.text-center.list-unstyled.row-spaced-small
+				li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
+			p.text-center.row-spaced-small
+				| Do you want to overwrite them?
+			p.text-center
+				a(href, ng-click="doUpload()").btn.btn-primary Overwrite
+				| &nbsp;
+				a(href, ng-click="cancel()").btn.btn-default Cancel
 
 
 	.modal-body(
 	.modal-body(
 		fine-upload
 		fine-upload
@@ -367,10 +383,14 @@ script(type="text/ng-template", id="uploadFileModalTemplate")
 		drag-area-text="{{drag_files}}"
 		drag-area-text="{{drag_files}}"
 		hint-text="{{hint_press_and_hold_control_key}}"
 		hint-text="{{hint_press_and_hold_control_key}}"
 		multiple="true"
 		multiple="true"
+		auto-upload="false"
 		on-complete-callback="onComplete"
 		on-complete-callback="onComplete"
 		on-upload-callback="onUpload"
 		on-upload-callback="onUpload"
 		on-validate-batch="onValidateBatch"
 		on-validate-batch="onValidateBatch"
 		on-error-callback="onError"
 		on-error-callback="onError"
+		on-submit-callback="onSubmit"
+		on-cancel-callback="onCancel"
+		control="control"
 		params="{'folder_id': parent_folder_id}"
 		params="{'folder_id': parent_folder_id}"
 	)
 	)
 		span #{translate("upload_files")}
 		span #{translate("upload_files")}
@@ -382,6 +402,8 @@ script(type='text/ng-template', id='deleteEntityModalTemplate')
 		h3 #{translate("delete")} {{ entity.name }}
 		h3 #{translate("delete")} {{ entity.name }}
 	.modal-body
 	.modal-body
 		p !{translate("sure_you_want_to_delete")}
 		p !{translate("sure_you_want_to_delete")}
+		ul
+			li(ng-repeat="entity in entities") {{entity.name}}
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
 			ng-disabled="state.inflight"

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

@@ -50,16 +50,22 @@ script(type="text/ng-template", id="hotkeysModalTemplate")
 				.hotkey
 				.hotkey
 					span.combination {{ctrl}} + A
 					span.combination {{ctrl}} + A
 					span.description Select All
 					span.description Select All
-			.col-xs-6
 				.hotkey
 				.hotkey
 					span.combination Tab
 					span.combination Tab
 					span.description Indent Selection
 					span.description Indent Selection
+			.col-xs-6
 				.hotkey
 				.hotkey
-					span.combination {{ctrl}} + U
+					span.combination Ctrl + U
 					span.description To Uppercase
 					span.description To Uppercase
 				.hotkey
 				.hotkey
 					span.combination Ctrl + Shift + U
 					span.combination Ctrl + Shift + U
 					span.description To Lowercase
 					span.description To Lowercase
+				.hotkey
+					span.combination {{ctrl}} + B
+					span.description Bold text
+				.hotkey
+					span.combination {{ctrl}} + I
+					span.description Italic Text
 	.modal-footer
 	.modal-footer
 		button.btn.btn-default(
 		button.btn.btn-default(
 			ng-click="cancel()"
 			ng-click="cancel()"

+ 47 - 27
services/web/app/views/project/editor/pdf.jade

@@ -1,16 +1,34 @@
 div.full-size.pdf(ng-controller="PdfController")
 div.full-size.pdf(ng-controller="PdfController")
 	.toolbar.toolbar-tall
 	.toolbar.toolbar-tall
-		a.btn.btn-info(
-			href,
-			ng-disabled="pdf.compiling",
-			ng-click="recompile()"
-		)
-			i.fa.fa-refresh(
-				ng-class="{'fa-spin': pdf.compiling }"
+		.btn-group(dropdown)
+			a.btn.btn-info(
+				href,
+				ng-disabled="pdf.compiling",
+				ng-click="recompile()"
 			)
 			)
-			| &nbsp;&nbsp;
-			span(ng-show="!pdf.compiling") #{translate("recompile")}
-			span(ng-show="pdf.compiling") #{translate("compiling")}...
+				i.fa.fa-refresh(
+					ng-class="{'fa-spin': pdf.compiling }"
+				)
+				| &nbsp;&nbsp;
+				span(ng-show="!pdf.compiling") #{translate("recompile")}
+				span(ng-show="pdf.compiling") #{translate("compiling")}...
+			a.btn.btn-info.dropdown-toggle(
+				href,
+				ng-disabled="pdf.compiling",
+				dropdown-toggle
+			)
+				span.caret
+			ul.dropdown-menu.dropdown-menu-right
+				li.dropdown-header #{translate("compile_mode")}
+				li
+					a(href, ng-click="draft = false")
+						i.fa.fa-fw(ng-class="{'fa-check': !draft}")
+						| &nbsp;#{translate("normal")}
+				li
+					a(href, ng-click="draft = true")
+						i.fa.fa-fw(ng-class="{'fa-check': draft}")
+						| &nbsp;#{translate("fast")}&nbsp;
+						span.subdued [draft]
 		a.log-btn(
 		a.log-btn(
 			href
 			href
 			ng-click="toggleLogs()"
 			ng-click="toggleLogs()"
@@ -94,41 +112,43 @@ div.full-size.pdf(ng-controller="PdfController")
 					| #{translate("learn_how_to_make_documents_compile_quickly")}
 					| #{translate("learn_how_to_make_documents_compile_quickly")}
 
 
 		.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
 		.alert.alert-success(ng-show="pdf.timedout && !hasPremiumCompile")
-					p
-						strong #{translate("upgrade_for_faster_compiles")}
-					h5 #{translate("free_accounts_have_timeout_upgrade_to_increase")}
-					h4 Plus:
-					h4 
+			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;
 						i.fa.fa-check &nbsp;
 						| #{translate("unlimited_projects")}
 						| #{translate("unlimited_projects")}
 					
 					
-					h4
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 					
 					
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("full_doc_history")}
 						| #{translate("full_doc_history")}
 					
 					
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_dropbox")}
 						| #{translate("sync_to_dropbox")}
 
 
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_github")}
 						| #{translate("sync_to_github")}
 
 
-					h4  
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						|#{translate("compile_larger_projects")}
 						|#{translate("compile_larger_projects")}
 
 
-					p(ng-controller="FreeTrialModalController")
-						a.btn.btn-success(
-							href
-							ng-class="buttonClass"
-							sixpack-convert="track_changes_feature_info"
-							ng-click="startFreeTrial('compile-timeout')"
-						) #{translate("start_free_trial")}
+			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")
 	.pdf-errors(ng-show="pdf.projectTooLarge")
 		.alert.alert-danger
 		.alert.alert-danger

+ 81 - 43
services/web/app/views/project/editor/publish-template.jade

@@ -6,46 +6,84 @@ script(type="text/ng-template", id="publishProjectAsTemplateModalTemplate")
 			ng-click="cancel()"
 			ng-click="cancel()"
 		) &times;
 		) &times;
 		h3 #{translate("publish_as_template")}
 		h3 #{translate("publish_as_template")}
-	.modal-body.modal-body-share
-		span(ng-hide="problemTalkingToTemplateApi")
-			form()
-				label(for='Description') #{translate("template_description")}
-				.form-group
-					textarea.form-control(
-					rows=5,
-					name='Description',
-					ng-model="templateDetails.description",
-					value=""
-				)
-			div(ng-show="templateDetails.exists").text-center.templateDetails
-				|  #{translate("project_last_published_at")} 
-				strong {{templateDetails.publishedDate}}. 
-				a(ng-href="{{templateDetails.canonicalUrl}}") #{translate("view_in_template_gallery")}.
-
-		span(ng-show="problemTalkingToTemplateApi") #{translate("problem_talking_to_publishing_service")}.
-
-
-
-
-	.modal-footer(ng-hide="problemTalkingToTemplateApi")
-		button.btn.btn-default(
-			ng-click="cancel()",
-			ng-disabled="state.publishInflight || state.unpublishInflight"
-		)
-			span #{translate("cancel")}
-
-		button.btn.btn-info(
-			ng-click="unpublishTemplate()",
-			ng-disabled="state.publishInflight || state.unpublishInflight"
-			ng-show="templateDetails.exists"
-		) 
-			span(ng-show="!state.unpublishInflight") #{translate("unpublish")}
-			span(ng-show="state.unpublishInflight") #{translate("unpublishing")}...
-
-		button.btn.btn-primary(
-			ng-click="publishTemplate()",
-			ng-disabled="state.publishInflight || state.unpublishInflight"
-		)
-			span(ng-show="!state.publishInflight && !templateDetails.exists") #{translate("publish")}
-			span(ng-show="!state.publishInflight && templateDetails.exists") #{translate("republish")}
-			span(ng-show="state.publishInflight") #{translate("publishing")}...
+	div(ng-if="project.features.templates")
+		.modal-body.modal-body-share
+			span(ng-hide="problemTalkingToTemplateApi")
+				form()
+					label(for='Description') #{translate("template_description")}
+					.form-group
+						textarea.form-control(
+						rows=5,
+						name='Description',
+						ng-model="templateDetails.description",
+						value=""
+					)
+				div(ng-show="templateDetails.exists").text-center.templateDetails
+					|  #{translate("project_last_published_at")} 
+					strong {{templateDetails.publishedDate}}. 
+					a(ng-href="{{templateDetails.canonicalUrl}}") #{translate("view_in_template_gallery")}.
+
+			span(ng-show="problemTalkingToTemplateApi") #{translate("problem_talking_to_publishing_service")}.
+		
+		.modal-footer(ng-hide="problemTalkingToTemplateApi")
+			button.btn.btn-default(
+				ng-click="cancel()",
+				ng-disabled="state.publishInflight || state.unpublishInflight"
+			)
+				span #{translate("cancel")}
+
+			button.btn.btn-info(
+				ng-click="unpublishTemplate()",
+				ng-disabled="state.publishInflight || state.unpublishInflight"
+				ng-show="templateDetails.exists"
+			) 
+				span(ng-show="!state.unpublishInflight") #{translate("unpublish")}
+				span(ng-show="state.unpublishInflight") #{translate("unpublishing")}...
+
+			button.btn.btn-primary(
+				ng-click="publishTemplate()",
+				ng-disabled="state.publishInflight || state.unpublishInflight"
+			)
+				span(ng-show="!state.publishInflight && !templateDetails.exists") #{translate("publish")}
+				span(ng-show="!state.publishInflight && templateDetails.exists") #{translate("republish")}
+				span(ng-show="state.publishInflight") #{translate("publishing")}...
+
+	div(ng-hide="project.features.templates")
+		.modal-body.modal-body-share
+			p #{translate("upgrade_to_get_feature", {feature:"templates"})}
+
+			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")}
+					
+		.modal-footer(ng-controller="FreeTrialModalController")
+			.text-center
+				a.btn.btn-success(
+					href
+					ng-class="buttonClass"
+					ng-click="startFreeTrial('templates')"
+				) #{translate("start_free_trial")}
+
+				.row-spaced-small.small(ng-show="startedFreeTrial")
+					| #{translate("refresh_page_after_starting_free_trial")}

+ 28 - 25
services/web/app/views/project/editor/share.jade

@@ -79,33 +79,36 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 								type="submit"
 								type="submit"
 								ng-mousedown="addMembers()"
 								ng-mousedown="addMembers()"
 							) #{translate("share")}
 							) #{translate("share")}
-				div.text-center(ng-hide="canAddCollaborators")
-					p #{translate("need_to_upgrade_for_more_collabs")}.
-					h4 
-						i.fa.fa-check &nbsp;
-						| #{translate("unlimited_projects")}
-					
-					h4
-						i.fa.fa-check &nbsp;
-						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-					
-					h4 
-						i.fa.fa-check &nbsp;
-						| #{translate("full_doc_history")}
-					
-					h4 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_dropbox")}
+				div(ng-hide="canAddCollaborators")
+					p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also:
+					.row
+						.col-md-8.col-md-offset-2
+							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")}
 
 
-					h4 
-						i.fa.fa-check &nbsp;
-						| #{translate("sync_to_github")}
+								li 
+									i.fa.fa-check &nbsp;
+									| #{translate("sync_to_github")}
 
 
-					h4  
-						i.fa.fa-check &nbsp;
-						|#{translate("compile_larger_projects")}
+								li  
+									i.fa.fa-check &nbsp;
+									|#{translate("compile_larger_projects")}
 
 
-					p(ng-controller="FreeTrialModalController")
+					p.text-center.row-spaced-thin(ng-controller="FreeTrialModalController")
 						a.btn.btn-success(
 						a.btn.btn-success(
 							href
 							href
 							ng-class="buttonClass"
 							ng-class="buttonClass"
@@ -122,7 +125,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
 			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
 			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
 		button.btn.btn-primary(
 		button.btn.btn-primary(
 			ng-click="done()"
 			ng-click="done()"
-		) #{translate("done")}
+		) #{translate("close")}
 
 
 script(type="text/ng-template", id="makePublicModalTemplate")
 script(type="text/ng-template", id="makePublicModalTemplate")
 	.modal-header
 	.modal-header

+ 13 - 57
services/web/app/views/project/editor/track-changes.jade

@@ -1,83 +1,39 @@
 div#trackChanges(ng-show="ui.view == 'track-changes'")
 div#trackChanges(ng-show="ui.view == 'track-changes'")
 	span(ng-controller="TrackChangesPremiumPopup")
 	span(ng-controller="TrackChangesPremiumPopup")
-		span(ng-if="versioningPopupType == 'default'")
-			.upgrade-prompt(ng-show="!project.features.versioning")
-				.message(ng-show="project.owner._id == user.id")
-
-					p #{translate("upgrade_to_get_feature", {feature:"Entire Doc History"})}
-					p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-					h4 
+		.upgrade-prompt(ng-show="!project.features.versioning")
+			.message(ng-show="project.owner._id == user.id")
+				p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
+				p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
+				ul.list-unstyled
+					li 
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("unlimited_projects")}
 						| #{translate("unlimited_projects")}
 					
 					
-					h4
+					li
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 					
 					
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("full_doc_history")}
 						| #{translate("full_doc_history")}
 					
 					
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_dropbox")}
 						| #{translate("sync_to_dropbox")}
 
 
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_github")}
 						| #{translate("sync_to_github")}
 
 
-					h4  
+					li  
 						i.fa.fa-check &nbsp;
 						i.fa.fa-check &nbsp;
 						|#{translate("compile_larger_projects")}
 						|#{translate("compile_larger_projects")}
 
 
-					p(ng-controller="FreeTrialModalController")
-						a.btn.btn-success(
-							href
-							ng-class="buttonClass"
-							ng-click="startFreeTrial('track-changes')"
-						) #{translate("start_free_trial")}
-
-
-				
-				.message(ng-show="project.owner._id != user.id")
-					p #{translate("ask_proj_owner_to_upgrade_for_history")}
-					p
-						a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")}
-						
-		.upgrade-prompt(ng-show="!project.features.versioning", ng-if="versioningPopupType == 'discount'")
-			.message(ng-show="project.owner._id == user.id")
-
-				p #{translate("upgrade_to_get_feature", {feature:"Entire Doc History"})}
-				h2(style="color:#a93529;") 20% Off First 6 Months!
-				p.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
-				h4 
-					i.fa.fa-check &nbsp;
-					| #{translate("unlimited_projects")}
-				
-				h4
-					i.fa.fa-check &nbsp;
-					| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
-				
-				h4 
-					i.fa.fa-check &nbsp;
-					| #{translate("full_doc_history")}
-				
-				h4 
-					i.fa.fa-check &nbsp;
-					| #{translate("sync_to_dropbox")}
-
-				h4 
-					i.fa.fa-check &nbsp;
-					| #{translate("sync_to_github")}
-
-				h4  
-					i.fa.fa-check &nbsp;
-					|#{translate("compile_larger_projects")}
-				p(ng-controller="FreeTrialModalController")
+				p.text-center(ng-controller="FreeTrialModalController")
 					a.btn.btn-success(
 					a.btn.btn-success(
 						href
 						href
 						ng-class="buttonClass"
 						ng-class="buttonClass"
-						ng-click="startFreeTrial('track-changes', 'cf3yutfzu7ztxz')"
+						ng-click="startFreeTrial('track-changes')"
 					) #{translate("start_free_trial")}
 					) #{translate("start_free_trial")}
 
 
 
 

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

@@ -9,7 +9,8 @@ block content
 	script(type="text/javascript").
 	script(type="text/javascript").
 		window.data = {
 		window.data = {
 			projects: !{JSON.stringify(projects).replace(/\//g, '\\/')},
 			projects: !{JSON.stringify(projects).replace(/\//g, '\\/')},
-			tags: !{JSON.stringify(tags).replace(/\//g, '\\/')}
+			tags: !{JSON.stringify(tags).replace(/\//g, '\\/')},
+			notifications: !{JSON.stringify(notifications).replace(/\//g, '\\/')}
 		};
 		};
 		window.algolia = {
 		window.algolia = {
 			institutions: {
 			institutions: {
@@ -20,12 +21,14 @@ block content
 
 
 	.content.content-alt(ng-controller="ProjectPageController")
 	.content.content-alt(ng-controller="ProjectPageController")
 		.container
 		.container
+				
 			.row(ng-cloak)
 			.row(ng-cloak)
 				span(ng-show="first_sign_up == 'default' || projects.length > 0")
 				span(ng-show="first_sign_up == 'default' || projects.length > 0")
 					aside.col-md-2.col-xs-3
 					aside.col-md-2.col-xs-3
 						include ./list/side-bar
 						include ./list/side-bar
 
 
 					.col-md-10.col-xs-9
 					.col-md-10.col-xs-9
+						include ./list/notifications
 						include ./list/project-list
 						include ./list/project-list
 				
 				
 				span(ng-if="first_sign_up == 'minimial' && projects.length == 0")
 				span(ng-if="first_sign_up == 'minimial' && projects.length == 0")

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

@@ -18,6 +18,8 @@ script(type='text/ng-template', id='newTagModalTemplate')
 				stop-propagation="click"
 				stop-propagation="click"
 			)
 			)
 	.modal-footer
 	.modal-footer
+		.modal-footer-left
+			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
 		//- We stop propagation to stop the clicks from closing the
 		//- We stop propagation to stop the clicks from closing the
 		//- 'move to folder' menu.
 		//- 'move to folder' menu.
 		button.btn.btn-default(
 		button.btn.btn-default(
@@ -25,10 +27,67 @@ script(type='text/ng-template', id='newTagModalTemplate')
 			stop-propagation="click"
 			stop-propagation="click"
 		) #{translate("cancel")}
 		) #{translate("cancel")}
 		button.btn.btn-primary(
 		button.btn.btn-primary(
-			ng-disabled="newTagForm.$invalid"
+			ng-disabled="newTagForm.$invalid || state.inflight"
 			ng-click="create()"
 			ng-click="create()"
 			stop-propagation="click"
 			stop-propagation="click"
-		) #{translate("create")}
+		)
+			span(ng-show="!state.inflight") #{translate("create")}
+			span(ng-show="state.inflight") #{translate("creating")}...
+
+script(type='text/ng-template', id='deleteTagModalTemplate')
+	.modal-header
+		button.close(
+			type="button"
+			data-dismiss="modal"
+			ng-click="cancel()"
+		) &times;
+		h3 #{translate("delete_folder")}
+	.modal-body
+		p #{translate("about_to_delete_folder")}
+		ul
+			li
+				strong {{tag.name}}
+	.modal-footer
+		.modal-footer-left
+			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
+		button.btn.btn-default(
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-danger(
+			ng-click="delete()",
+			ng-disabled="state.inflight"
+		)
+			span(ng-show="state.inflight") #{translate("deleting")}...
+			span(ng-show="!state.inflight") #{translate("delete")}
+
+script(type='text/ng-template', id='renameTagModalTemplate')
+	.modal-header
+		button.close(
+			type="button"
+			data-dismiss="modal"
+			ng-click="cancel()"
+		) &times;
+		h3 #{translate("rename_folder")}
+	.modal-body
+		form(name="renameTagForm", novalidate)
+			input.form-control(
+				type="text",
+				placeholder="Tag Name",
+				ng-model="inputs.tagName",
+				required,
+				on-enter="rename()",
+				focus-on="open"
+			)
+	.modal-footer
+		.modal-footer-left
+			span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
+		button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
+		button.btn.btn-primary(
+			ng-click="rename()",
+			ng-disabled="renameTagForm.$invalid || state.inflight"
+		)
+			span(ng-show="!state.inflight") #{translate("rename")}
+			span(ng-show="state.inflight") #{translate("renaming")}...
 
 
 script(type='text/ng-template', id='renameProjectModalTemplate')
 script(type='text/ng-template', id='renameProjectModalTemplate')
 	.modal-header
 	.modal-header

+ 15 - 0
services/web/app/views/project/list/notifications.jade

@@ -0,0 +1,15 @@
+span(ng-controller="NotificationsController").userNotifications
+	ul.list-unstyled.notifications-list(
+		ng-if="notifications.length > 0",
+		ng-cloak
+	)
+		li.notification_entry(
+			ng-repeat="unreadNotification in notifications",
+		)
+			.row(ng-hide="unreadNotification.hide")
+				.col-xs-12
+					.alert.alert-info
+						span(ng-bind-html="unreadNotification.html")
+						button(ng-click="dismiss(unreadNotification)").close.pull-right
+							span(aria-hidden="true") &times;
+							span.sr-only #{translate("close")}

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

@@ -58,7 +58,7 @@
 					)
 					)
 						li.dropdown-header #{translate("add_to_folder")}
 						li.dropdown-header #{translate("add_to_folder")}
 						li(
 						li(
-							ng-repeat="tag in tags | filter:nonEmpty | orderBy:'name'",
+							ng-repeat="tag in tags | orderBy:'name'",
 							ng-controller="TagDropdownItemController"
 							ng-controller="TagDropdownItemController"
 						)
 						)
 							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
 							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
@@ -79,7 +79,7 @@
 						href='#',
 						href='#',
 						data-toggle="dropdown",
 						data-toggle="dropdown",
 						dropdown-toggle
 						dropdown-toggle
-					) #{translate("more")}
+					) #{translate("more")} 
 						span.caret
 						span.caret
 					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'")

+ 62 - 34
services/web/app/views/project/list/side-bar.jade

@@ -37,22 +37,23 @@
 	ul.list-unstyled.folders-menu(
 	ul.list-unstyled.folders-menu(
 		ng-controller="TagListController"
 		ng-controller="TagListController"
 	)
 	)
-		li(ng-class="{active: (filter == 'all')}")
-			a(href, ng-click="filterProjects('all')") #{translate("all_projects")}
-		li(ng-class="{active: (filter == 'owned')}")
-			a(href, ng-click="filterProjects('owned')") #{translate("your_projects")}
-		li(ng-class="{active: (filter == 'shared')}")
-			a(href, ng-click="filterProjects('shared')") #{translate("shared_with_you")}
-		li(ng-class="{active: (filter == 'archived')}")
-			a(href, ng-click="filterProjects('archived')") #{translate("deleted_projects")}
+		li(ng-class="{active: (filter == 'all')}", ng-click="filterProjects('all')")
+			a(href) #{translate("all_projects")}
+		li(ng-class="{active: (filter == 'owned')}", ng-click="filterProjects('owned')")
+			a(href) #{translate("your_projects")}
+		li(ng-class="{active: (filter == 'shared')}", ng-click="filterProjects('shared')")
+			a(href) #{translate("shared_with_you")}
+		li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')")
+			a(href) #{translate("deleted_projects")}
 		li
 		li
 			h2 #{translate("folders")}
 			h2 #{translate("folders")}
-		li(
-			ng-repeat="tag in tags | filter:nonEmpty",
+		li.tag(
+			ng-repeat="tag in tags | orderBy:name",
 			ng-class="{active: tag.selected}",
 			ng-class="{active: tag.selected}",
-			ng-cloak
+			ng-cloak,
+			ng-click="selectTag(tag)"
 		)
 		)
-			a.tag(href, ng-click="selectTag(tag)")
+			a.tag-name(href)
 				i.icon.fa.fa-fw(
 				i.icon.fa.fa-fw(
 					ng-class="{\
 					ng-class="{\
 						'fa-folder-open-o': tag.selected,\
 						'fa-folder-open-o': tag.selected,\
@@ -61,6 +62,23 @@
 				)
 				)
 				span.name {{tag.name}} 
 				span.name {{tag.name}} 
 					span.subdued  ({{tag.project_ids.length}})
 					span.subdued  ({{tag.project_ids.length}})
+			span.dropdown.tag-menu(dropdown)
+				a.dropdown-toggle(
+					href="#",
+					data-toggle="dropdown",
+					dropdown-toggle,
+					stop-propagation="click"
+				)
+					span.caret
+				ul.dropdown-menu.dropdown-menu-right(
+					role="menu"
+				)
+					li
+						a(href, ng-click="renameTag(tag)", stop-propagation="click")
+							| #{translate("rename")}
+					li
+						a(href, ng-click="deleteTag(tag)", stop-propagation="click")
+							| #{translate("delete")}
 		li(ng-cloak)
 		li(ng-cloak)
 			a.tag(href, ng-click="openNewTagModal()")
 			a.tag(href, ng-click="openNewTagModal()")
 				i.fa.fa-fw.fa-plus
 				i.fa.fa-fw.fa-plus
@@ -102,41 +120,51 @@
 					) #{translate("complete")}
 					) #{translate("complete")}
 
 
 
 
-		.row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgrade-reason").text-centered
+		.row-spaced(ng-if="hasProjects && userHasSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
 			span(sixpack-default).text-centered
 			span(sixpack-default).text-centered
 				hr
 				hr
 				p.small #{translate("on_free_sl")}
 				p.small #{translate("on_free_sl")}
 				p
 				p
-					a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")}
+					a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 				p.small.text-centered
 				p.small.text-centered
 					| #{translate("or_unlock_features_bonus")} 
 					| #{translate("or_unlock_features_bonus")} 
 					a(href="/user/bonus") #{translate("sharing_sl")} .
 					a(href="/user/bonus") #{translate("sharing_sl")} .
 
 
-			span(sixpack-when="dropbox").text-centered
-				hr
-				.card.card-thin
-					p
-						span Get Dropbox Sync
+			span(sixpack-when="random").text-centered
+				span(ng-if="randomView == 'default'")
+					hr
+					p.small #{translate("on_free_sl")}
 					p
 					p
-						img(src="/img/dropbox/simple_logo.png")
-					p
-						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")}
+						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
 					p.small.text-centered
 					p.small.text-centered
 						| #{translate("or_unlock_features_bonus")} 
 						| #{translate("or_unlock_features_bonus")} 
 						a(href="/user/bonus") #{translate("sharing_sl")} .
 						a(href="/user/bonus") #{translate("sharing_sl")} .
 
 
-			span(sixpack-when="github").text-centered
-				hr
-				.card.card-thin
-					p
-						span Get Github Sync
-					p
-						img(src="/img/github/octocat.jpg")
-					p
-						a(href="/user/subscription/plans", sixpack-convert="left-menu-upgrade-reason").btn.btn-primary #{translate("upgrade")}
-					p.small.text-centered
-						| #{translate("or_unlock_features_bonus")} 
-						a(href="/user/bonus") #{translate("sharing_sl")} .
+				span(ng-if="randomView == 'dropbox'")
+					hr
+					.card.card-thin
+						p
+							span Get Dropbox Sync
+						p
+							img(src="/img/dropbox/simple_logo.png")
+						p
+							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+						p.small.text-centered
+							| #{translate("or_unlock_features_bonus")} 
+							a(href="/user/bonus") #{translate("sharing_sl")} .
+
+				span(ng-if="randomView == 'github'")
+					hr
+					.card.card-thin
+						p
+							span Get Github Sync
+						p
+							img(src="/img/github/octocat.jpg")
+						p
+							a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
+						p.small.text-centered
+							| #{translate("or_unlock_features_bonus")} 
+							a(href="/user/bonus") #{translate("sharing_sl")} .
 script.
 script.
 	window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
 	window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
 
 

+ 3 - 3
services/web/app/views/sentry.jade

@@ -32,8 +32,8 @@
 				'EBCallBackMessageReceived',
 				'EBCallBackMessageReceived',
 				// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
 				// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
 				'conduitPage',
 				'conduitPage',
-				'NS_ERROR_NOT_CONNECTED:',
-				"TypeError: Cannot read property 'row' of undefined",
+				'/NS_ERROR_NOT_CONNECTED/i',
+				"/Cannot read property 'row' of undefined/i",
 				'TypeError: start is undefined'
 				'TypeError: start is undefined'
 			],
 			],
 			ignoreUrls: [
 			ignoreUrls: [
@@ -57,7 +57,7 @@
 			],
 			],
 			shouldSendCallback: function(data) {
 			shouldSendCallback: function(data) {
 				// only send a fraction of errors
 				// only send a fraction of errors
-				var sampleRate = 1.00;
+				var sampleRate = 0.01;
 				return (Math.random() <= sampleRate);
 				return (Math.random() <= sampleRate);
 			},
 			},
 			dataCallback: function(data) {
 			dataCallback: function(data) {

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

@@ -8,7 +8,7 @@ block content
 					.card
 					.card
 						.page-header
 						.page-header
 							h1 #{translate("your_subscription")}
 							h1 #{translate("your_subscription")}
-						div To make changes to your subscription please contact team@sharelatex.com
+						div To make changes to your subscription please contact accounts@sharelatex.com
 						div &nbsp;
 						div &nbsp;
 						div
 						div
 							-if(subscription.groupPlan)
 							-if(subscription.groupPlan)

+ 63 - 12
services/web/app/views/subscriptions/dashboard.jade

@@ -6,7 +6,7 @@ block scripts
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 		window.taxRate = #{taxRate}
 		window.taxRate = #{taxRate}
-
+		window.subscription = !{JSON.stringify(subscription)}
 
 
 
 
 
 
@@ -36,32 +36,32 @@ mixin printPlans(plans)
 		mixin printPlan(plan)
 		mixin printPlan(plan)
 
 
 block content
 block content
-	.content.content-alt
-		.container
+	.content.content-alt(ng-cloak)
+		.container(ng-controller="UserSubscriptionController")
 			.row
 			.row
 				.col-md-8.col-md-offset-2
 				.col-md-8.col-md-offset-2
-					.card
+					.card(ng-if="view == 'overview'")
 						.page-header
 						.page-header
 							h1 #{translate("your_subscription")}
 							h1 #{translate("your_subscription")}
 						-if (groups.length != 0)
 						-if (groups.length != 0)
 							each groupSubscription in groups
 							each groupSubscription in groups
 								p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
 								p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
+								span 
+									button.btn.btn-danger(ng-click="removeSelfFromGroup('#{groupSubscription.admin_id._id}')") #{translate("leave_group")}
 						- else if (subscription)
 						- else if (subscription)
 							case subscription.state
 							case subscription.state
 								when "free-trial"
 								when "free-trial"
 									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
 									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
-									p !{translate("choose_a_plan_below")}
+									
 								when "active"
 								when "active"
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})} 
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})} 
 										a(href, ng-click="changePlan = true") !{translate("change_plan")}.
 										a(href, ng-click="changePlan = true") !{translate("change_plan")}.
 									p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + subscription.price + "</strong>", collectionDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
 									p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + subscription.price + "</strong>", collectionDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
 									p.pull-right
 									p.pull-right
-
-									p: form(action="/user/subscription/cancel",method="post")
-										input(type="hidden", name="_csrf", value=csrfToken)
+									p
 										a(href="/user/subscription/billing-details/edit").btn.btn-info #{translate("update_your_billing_details")}
 										a(href="/user/subscription/billing-details/edit").btn.btn-info #{translate("update_your_billing_details")}
 										| &nbsp;
 										| &nbsp;
-										input(type="submit", value="Cancel your subscription").btn.btn-primary#cancelSubscription
+										a(href, ng-click="switchToCancelationView()").btn.btn-primary !{translate("cancel_your_subscription")}
 								when "canceled"
 								when "canceled"
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})}
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})}
 									p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
 									p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
@@ -76,7 +76,7 @@ block content
 										p !{translate("problem_with_subscription_contact_us")}
 										p !{translate("problem_with_subscription_contact_us")}
 
 
 							-if(subscription.groupPlan)
 							-if(subscription.groupPlan)
-								a(href="/subscription/group").btn.btn-success !{translate("manage_group")}
+								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
 
 
 								
 								
 
 
@@ -105,17 +105,50 @@ block content
 									mixin printPlans(plans.individualMonthlyPlans)
 									mixin printPlans(plans.individualMonthlyPlans)
 									mixin printPlans(plans.individualAnnualPlans)
 									mixin printPlans(plans.individualAnnualPlans)
 							
 							
+					.card(ng-if="view == 'cancelation'")
+						.page-header
+							h1 #{translate("Cancel Subscription")}
+
+						span(ng-if="sixpackOpt == 'downgrade-options'")
+
+							div(ng-show="showExtendFreeTrial", style="text-align: center")
+								p !{translate("have_more_days_to_try", {days:14})}
+								button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+								p
+									| &nbsp;
+								p
+									a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
 
 
 
 
+							div(ng-show="showDowngradeToStudent", style="text-align: center")
+								span(ng-controller="ChangePlanFormController")
+									p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
+									button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
+									p
+										| &nbsp;
+									p
+										a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
+
+							div(ng-show="showBasicCancel")
+								p #{translate("sure_you_want_to_cancel")}
+								a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
+								| &nbsp;
+								a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+
+						span(ng-if="sixpackOpt == 'basic'")
+							p #{translate("sure_you_want_to_cancel")}
+							a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
+							| &nbsp;
+							a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+
 	script(type="text/javascript").
 	script(type="text/javascript").
 		$('#cancelSubscription').on("click", function() {
 		$('#cancelSubscription').on("click", function() {
 			ga('send', 'event', 'subscription-funnel', 'cancelation')
 			ga('send', 'event', 'subscription-funnel', 'cancelation')
 		})
 		})
 
 
-
 	script(type='text/ng-template', id='confirmChangePlanModalTemplate')
 	script(type='text/ng-template', id='confirmChangePlanModalTemplate')
 		.modal-header
 		.modal-header
-			h3 Change plan?
+			h3 #{translate("change_plan")}
 		.modal-body
 		.modal-body
 			p !{translate("sure_you_want_to_change_plan", {planName:"<strong>{{plan.name}}</strong>"})}
 			p !{translate("sure_you_want_to_change_plan", {planName:"<strong>{{plan.name}}</strong>"})}
 		.modal-footer
 		.modal-footer
@@ -131,4 +164,22 @@ block content
 				span(ng-show="inflight") #{translate("processing")}...
 				span(ng-show="inflight") #{translate("processing")}...
 
 
 
 
+	script(type='text/ng-template', id='LeaveGroupModalTemplate')
+		.modal-header
+			h3 #{translate("leave_group")}
+		.modal-body
+			p #{translate("sure_you_want_to_leave_group")}
+		.modal-footer
+			button.btn.btn-default(
+				ng-disabled="inflight"
+				ng-click="cancel()"
+			) #{translate("cancel")}
+			button.btn.btn-danger(
+				ng-disabled="state.inflight"
+				ng-click="confirmLeaveGroup()"
+			)
+				span(ng-hide="inflight") #{translate("leave_now")}
+				span(ng-show="inflight") #{translate("processing")}...
+
+
 
 

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

@@ -112,7 +112,6 @@ block content
 												.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
-														option(value="2015") 2015
 														option(value="2016") 2016
 														option(value="2016") 2016
 														option(value="2017") 2017
 														option(value="2017") 2017
 														option(value="2018") 2018
 														option(value="2018") 2018

+ 30 - 24
services/web/app/views/subscriptions/plans.jade

@@ -3,6 +3,12 @@ block scripts
 	script(type='text/javascript').
 	script(type='text/javascript').
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.abCurrencyFlag = '#{abCurrencyFlag}'
 		window.abCurrencyFlag = '#{abCurrencyFlag}'
+
+	script(type='text/javascript').
+		(function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true; 
+		s.src=('https:'==document.location.protocol?'https':'http') + '://sharelatex-accounts.groovehq.com/widgets/f5ad3b09-7d99-431b-8af5-c5725e3760ce/ticket/api.js'; 
+		var q = document.getElementsByTagName('script')[0];q.parentNode.insertBefore(s, q);})(); 
+		
 block content
 block content
 	.content-alt
 	.content-alt
 		.content.plans(ng-controller="PlansController")
 		.content.plans(ng-controller="PlansController")
@@ -186,30 +192,30 @@ block content
 						.modal-header
 						.modal-header
 							h3 #{translate("group_plan_enquiry")}
 							h3 #{translate("group_plan_enquiry")}
 						.modal-body
 						.modal-body
-							form(name='form1', autocomplete='off', enctype='multipart/form-data', method='post', novalidate='', action='https://sharelatex.wufoo.com/forms/z7x3p3/#public', _lpchecked='1')
-								.form-group
-									label(for='Field9') #{translate("name")}
-									input.form-control(name='Field9', type='text', value='', maxlength='255', tabindex='1', onkeyup='')
-								
-								.form-group
-									label(for='Field11') #{translate("email")}
-									input.form-control(name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
-								
-								.form-group
-									label(for='Field12') #{translate("university")}
-									input.form-control(name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
-									
-								.form-group
-									label(for='Field13') #{translate("position")}
-									input.form-control(name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
-								
-								.form-group
-									input.btn.btn-primary.btn-large(name='saveForm', type='submit', value='Send')
-								div(style='display: none;')
-									label(for='comment') Do Not Fill This Out
-									textarea#comment(name='comment', rows='1', cols='1')
-									input#idstamp(type='hidden', name='idstamp', value='xkgLkZnS/AQW71jCS1d0XrrFjq26lJryIPVk2rx0YkU=')
-
+							form.text-left.form(ng-controller="UniverstiesContactController", ng-submit="contactUs()", ng-cloak)
+								span(ng-show="sent == false")
+									.form-group
+										label#title9(for='Field9')
+											| Name
+										input#Field9.field.text.medium.span8.form-control(ng-model="form.name", maxlength='255', tabindex='1', onkeyup='')
+									label#title11.desc(for='Field11')
+										| Email
+									.form-group
+										input#Field11.field.text.medium.span8.form-control(ng-model="form.email", name='Field11', type='email', spellcheck='false', value='', maxlength='255', tabindex='2')
+									label#title12.desc(for='Field12')
+										| University / Company
+									.form-group
+										input#Field12.field.text.medium.span8.form-control(ng-model="form.university", name='Field12', type='text', value='', maxlength='255', tabindex='3', onkeyup='')
+									label#title13.desc(for='Field13')
+										| Position
+									.form-group
+										input#Field13.field.text.medium.span8.form-control(ng-model="form.position", name='Field13', type='text', value='', maxlength='255', tabindex='4', onkeyup='')
+									.form-group
+										input(ng-model="form.source", type="hidden", ng-init="form.source = '__ref__'; form.subject = 'ShareLaTeX for Universities';")
+									.form-group.text-center
+										input#saveForm.btn-success.btn.btn-lg(name='saveForm', type='submit', ng-disabled="sending",  value='Request a quote')
+								span(ng-show="sent")
+									p Request Sent, Thank you.
 
 
 				.row
 				.row
 					.col-md-12
 					.col-md-12

+ 8 - 4
services/web/app/views/subscriptions/successful_subscription.jade

@@ -5,14 +5,18 @@ block content
 		.container
 		.container
 			.row
 			.row
 				.col-md-8.col-md-offset-2
 				.col-md-8.col-md-offset-2
-					.card
+					.card(ng-cloak)
 						.page-header
 						.page-header
 							h2 #{translate("thanks_for_subscribing")}
 							h2 #{translate("thanks_for_subscribing")}
 						.alert.alert-success
 						.alert.alert-success
 							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
 							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
-							p #{translate("if_you_dont_want_to_be_charged")}
-								a(href="/user/subscription") #{translate("click_here_to_cancel")}.
-
+							span(sixpack-switch="upgrade-success-message")
+								span(sixpack-default)
+									p #{translate("if_you_dont_want_to_be_charged")}
+										a(href="/user/subscription") #{translate("click_here_to_cancel")}.
+								span(sixpack-when="manage-subscription")
+									p #{translate("to_modify_your_subscription_go_to")} 
+										a(href="/user/subscription")  #{translate("manage_subscription")}.
 						p
 						p
 							- if (subscription.groupPlan == true)
 							- if (subscription.groupPlan == true)
 								a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")}
 								a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")}

+ 64 - 0
services/web/app/views/user/activate.jade

@@ -0,0 +1,64 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+					.alert.alert-success #{translate("nearly_activated")}
+			.row
+				.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+					.card
+						.page-header
+							h1 #{translate("please_set_a_password")}
+						form(
+							async-form="activate",
+							name="activationForm",
+							action="/user/password/set",
+							method="POST",
+							ng-cloak
+						)
+							input(name='_csrf', type='hidden', value=csrfToken)
+							input(
+								type="hidden",
+								name="passwordResetToken",
+								value=token
+							)
+							input(name='login_after', type='hidden', value="true")
+							.alert.alert-danger(ng-show="activationForm.response.error")
+								| #{translate("activation_token_expired")}
+							
+							.form-group
+								label(for='email') #{translate("email")}
+								input.form-control(
+									type='email',
+									name='email',
+									placeholder="email@example.com"
+									required,
+									ng-model="email",
+									ng-init="email = #{JSON.stringify(email)}",
+									ng-model-options="{ updateOn: 'blur' }",
+									disabled
+								)
+							.form-group
+								label(for='password') #{translate("password")}
+								input.form-control#passwordField(
+									type='password',
+									name='password',
+									placeholder="********",
+									required,
+									ng-model="password",
+									complex-password,
+									focus="true"
+								)
+								span.small.text-primary(ng-show="activationForm.password.$error.complexPassword", ng-bind-html="complexPasswordErrorMessage")
+							.actions
+								button.btn-primary.btn(
+									type='submit'
+									ng-disabled="activationForm.inflight || activationForm.password.$error.required|| activationForm.password.$error.complexPassword"
+								)
+									span(ng-show="!activationForm.inflight") #{translate("activate")}
+									span(ng-show="activationForm.inflight") #{translate("activating")}...
+
+	script(type='text/javascript').
+		window.passwordStrengthOptions = !{JSON.stringify(settings.passwordStrengthOptions || {})}

+ 1 - 0
services/web/app/views/user/login.jade

@@ -20,6 +20,7 @@ block content
 									placeholder='email@example.com',
 									placeholder='email@example.com',
 									ng-model="email",
 									ng-model="email",
 									ng-model-options="{ updateOn: 'blur' }",
 									ng-model-options="{ updateOn: 'blur' }",
+									ng-init="email = #{JSON.stringify(email)}",
 									focus="true"
 									focus="true"
 								)
 								)
 								span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty")
 								span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty")

+ 5 - 4
services/web/app/views/user/setPassword.jade

@@ -16,10 +16,11 @@ block content
 							ng-cloak
 							ng-cloak
 						)
 						)
 							input(type="hidden", name="_csrf", value=csrfToken)
 							input(type="hidden", name="_csrf", value=csrfToken)
-							form-messages(for="passwordResetForm")
-								.alert.alert-success(ng-show="passwordResetForm.response.success")
-									| #{translate("password_has_been_reset")}. 
-									a(href='/login') #{translate("login_here")}
+							.alert.alert-success(ng-show="passwordResetForm.response.success")
+								| #{translate("password_has_been_reset")}. 
+								a(href='/login') #{translate("login_here")}
+							.alert.alert-danger(ng-show="passwordResetForm.response.error")
+								| #{translate("password_reset_token_expired")}
 
 
 							.form-group
 							.form-group
 								input.form-control#passwordField(
 								input.form-control#passwordField(

+ 10 - 1
services/web/config/settings.defaults.coffee

@@ -106,6 +106,10 @@ module.exports =
 			url: "http://localhost:3036"
 			url: "http://localhost:3036"
 		sixpack:
 		sixpack:
 			url: ""
 			url: ""
+		references:
+			url: "http://localhost:3040"
+		notifications:
+			url: "http://localhost:3042" 
 			
 			
 	templates:
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@@ -125,6 +129,9 @@ module.exports =
 	# Same, but with http auth credentials.
 	# Same, but with http auth credentials.
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 
 
+
+	maxEntitiesPerProject: 2000
+	
 	# Security
 	# Security
 	# --------
 	# --------
 	security:
 	security:
@@ -143,6 +150,8 @@ module.exports =
 		versioning: true
 		versioning: true
 		compileTimeout: 60
 		compileTimeout: 60
 		compileGroup: "standard"
 		compileGroup: "standard"
+		references: true
+		templates: true
 
 
 	plans: plans = [{
 	plans: plans = [{
 		planCode: "personal"
 		planCode: "personal"
@@ -291,7 +300,7 @@ module.exports =
 		title: "ShareLaTeX Community Edition"
 		title: "ShareLaTeX Community Edition"
 		
 		
 		left_footer: [{
 		left_footer: [{
-			text: "Powered by <a href='https://www.sharelatex.com'>ShareLaTeX</a> © 2015"
+			text: "Powered by <a href='https://www.sharelatex.com'>ShareLaTeX</a> © 2016"
 		}]
 		}]
 
 
 		right_footer: [{
 		right_footer: [{

+ 7 - 2
services/web/package.json

@@ -27,18 +27,23 @@
     "http-proxy": "^1.8.1",
     "http-proxy": "^1.8.1",
     "jade": "~1.3.1",
     "jade": "~1.3.1",
     "ldapjs": "^0.7.1",
     "ldapjs": "^0.7.1",
+<<<<<<< HEAD
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
     "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
+=======
+    "logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#v1.3.1",
+>>>>>>> master
     "lynx": "0.1.1",
     "lynx": "0.1.1",
     "marked": "^0.3.3",
     "marked": "^0.3.3",
     "method-override": "^2.3.3",
     "method-override": "^2.3.3",
-    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.2.0",
+    "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
     "mimelib": "0.2.14",
     "mimelib": "0.2.14",
     "mocha": "1.17.1",
     "mocha": "1.17.1",
     "mongojs": "0.18.2",
     "mongojs": "0.18.2",
     "mongoose": "4.1.0",
     "mongoose": "4.1.0",
     "multer": "^0.1.8",
     "multer": "^0.1.8",
     "node-uuid": "1.4.1",
     "node-uuid": "1.4.1",
-    "nodemailer": "0.6.1",
+    "nodemailer": "2.1.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",

+ 17 - 1
services/web/public/coffee/directives/fineUpload.coffee

@@ -16,7 +16,11 @@ define [
 				onUploadCallback: "="
 				onUploadCallback: "="
 				onValidateBatch: "="
 				onValidateBatch: "="
 				onErrorCallback: "="
 				onErrorCallback: "="
+				onSubmitCallback: "="
+				onCancelCallback: "="
+				autoUpload: "="
 				params: "="
 				params: "="
+				control: "="
 			}
 			}
 			link: (scope, element, attrs) ->
 			link: (scope, element, attrs) ->
 				multiple = scope.multiple or false
 				multiple = scope.multiple or false
@@ -32,19 +36,27 @@ define [
 					uploadButton: scope.uploadButtonText or "Upload"
 					uploadButton: scope.uploadButtonText or "Upload"
 				dragAreaText = scope.dragAreaText or "drag here"
 				dragAreaText = scope.dragAreaText or "drag here"
 				hintText = scope.hintText or ""
 				hintText = scope.hintText or ""
-
+				maxConnections = scope.maxConnections or 1
 				onComplete = scope.onCompleteCallback or () ->
 				onComplete = scope.onCompleteCallback or () ->
 				onUpload   = scope.onUploadCallback or () ->
 				onUpload   = scope.onUploadCallback or () ->
 				onError   = scope.onErrorCallback or () ->
 				onError   = scope.onErrorCallback or () ->
 				onValidateBatch = scope.onValidateBatch or () ->
 				onValidateBatch = scope.onValidateBatch or () ->
+				onSubmit = scope.onSubmitCallback or () ->
+				onCancel = scope.onCancelCallback or () ->
+				if !scope.autoUpload?
+					autoUpload = true
+				else
+					autoUpload = scope.autoUpload
 				params     = scope.params or {}
 				params     = scope.params or {}
 				params._csrf = window.csrfToken
 				params._csrf = window.csrfToken
 
 
 				q = new qq.FineUploader
 				q = new qq.FineUploader
 					element: element[0]
 					element: element[0]
 					multiple: multiple
 					multiple: multiple
+					autoUpload: autoUpload
 					disabledCancelForFormUploads: true
 					disabledCancelForFormUploads: true
 					validation: validation
 					validation: validation
+					maxConnections: maxConnections
 					request:
 					request:
 						endpoint: endpoint
 						endpoint: endpoint
 						forceMultipart: true
 						forceMultipart: true
@@ -55,6 +67,8 @@ define [
 						onUpload:   onUpload
 						onUpload:   onUpload
 						onValidateBatch: onValidateBatch
 						onValidateBatch: onValidateBatch
 						onError: onError
 						onError: onError
+						onSubmit: onSubmit
+						onCancel: onCancel
 					text: text
 					text: text
 					template: """
 					template: """
 						<div class="qq-uploader">
 						<div class="qq-uploader">
@@ -69,5 +83,7 @@ define [
 							<ul class="qq-upload-list"></ul>
 							<ul class="qq-upload-list"></ul>
 						</div>
 						</div>
 					"""
 					"""
+				window.q = q
+				scope.control?.q = q
 				return q
 				return q
 		}
 		}

+ 7 - 3
services/web/public/coffee/ide.coffee

@@ -8,6 +8,7 @@ define [
 	"ide/permissions/PermissionsManager"
 	"ide/permissions/PermissionsManager"
 	"ide/pdf/PdfManager"
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
 	"ide/binary-files/BinaryFilesManager"
+	"ide/references/ReferencesManager"
 	"ide/settings/index"
 	"ide/settings/index"
 	"ide/share/index"
 	"ide/share/index"
 	"ide/chat/index"
 	"ide/chat/index"
@@ -24,6 +25,7 @@ define [
 	"directives/onEnter"
 	"directives/onEnter"
 	"directives/stopPropagation"
 	"directives/stopPropagation"
 	"directives/rightClick"
 	"directives/rightClick"
+	"services/queued-http"
 	"filters/formatDate"
 	"filters/formatDate"
 	"main/event"
 	"main/event"
 	"main/account-upgrade"
 	"main/account-upgrade"
@@ -37,6 +39,7 @@ define [
 	PermissionsManager
 	PermissionsManager
 	PdfManager
 	PdfManager
 	BinaryFilesManager
 	BinaryFilesManager
+	ReferencesManager
 ) ->
 ) ->
 
 
 	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
 	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
@@ -66,12 +69,13 @@ define [
 
 
 		$scope.chat = {}
 		$scope.chat = {}
 
 
-		
+
 		window._ide = ide
 		window._ide = ide
 
 
 		ide.project_id = $scope.project_id = window.project_id
 		ide.project_id = $scope.project_id = window.project_id
 		ide.$scope = $scope
 		ide.$scope = $scope
 
 
+		ide.referencesSearchManager = new ReferencesManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
 		ide.editorManager = new EditorManager(ide, $scope)
 		ide.editorManager = new EditorManager(ide, $scope)
@@ -80,7 +84,7 @@ define [
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
-		
+
 		inited = false
 		inited = false
 		$scope.$on "project:joined", () ->
 		$scope.$on "project:joined", () ->
 			return if inited
 			return if inited
@@ -91,7 +95,7 @@ define [
 					We don't want to delete your data on ShareLaTeX, so this project still contains your history and collaborators.
 					We don't want to delete your data on ShareLaTeX, so this project still contains your history and collaborators.
 					If the project has been renamed please look in your project list for a new project under the new name.
 					If the project has been renamed please look in your project list for a new project under the new name.
 				""")
 				""")
-				
+
 		DARK_THEMES = [
 		DARK_THEMES = [
 			"ambiance", "chaos", "clouds_midnight", "cobalt", "idle_fingers",
 			"ambiance", "chaos", "clouds_midnight", "cobalt", "idle_fingers",
 			"merbivore", "merbivore_soft", "mono_industrial", "monokai",
 			"merbivore", "merbivore_soft", "mono_industrial", "monokai",

+ 6 - 2
services/web/public/coffee/ide/directives/layout.coffee

@@ -37,8 +37,12 @@ define [
 
 
 					# Restore previously recorded state
 					# Restore previously recorded state
 					if (state = ide.localStorage("layout.#{name}"))?
 					if (state = ide.localStorage("layout.#{name}"))?
-						options.west = state.west
-						options.east = state.east
+						if state.east?
+							if !attrs.minimumRestoreSizeEast? or (state.east.size >= attrs.minimumRestoreSizeEast and !state.east.initClosed)
+								options.east = state.east
+						if state.west?
+							if !attrs.minimumRestoreSizeWest? or (state.west.size >= attrs.minimumRestoreSizeWest and !state.west.initClosed)
+								options.west = state.west
 
 
 					repositionControls = () ->
 					repositionControls = () ->
 						state = element.layout().readState()
 						state = element.layout().readState()

+ 21 - 0
services/web/public/coffee/ide/editor/Document.coffee

@@ -36,6 +36,7 @@ define [
 			@doc?.detachFromAce()
 			@doc?.detachFromAce()
 			editorDoc = @ace?.getSession().getDocument()
 			editorDoc = @ace?.getSession().getDocument()
 			editorDoc?.off "change", @_checkConsistency
 			editorDoc?.off "change", @_checkConsistency
+			@ide.$scope.$emit 'document:closed', @doc
 
 
 		_checkConsistency: () ->
 		_checkConsistency: () ->
 			# We've been seeing a lot of errors when I think there shouldn't be
 			# We've been seeing a lot of errors when I think there shouldn't be
@@ -116,6 +117,26 @@ define [
 		flush: () ->
 		flush: () ->
 			@doc?.flushPendingOps()
 			@doc?.flushPendingOps()
 
 
+		chaosMonkey: (line = 0, char = "a") ->
+			orig = char
+			copy = null
+			pos = 0
+			timer = () =>
+				unless copy? and copy.length
+					copy = orig.slice() + ' ' + new Date() + '\n'
+					line += if Math.random() > 0.1 then 1 else -2
+					line = 0 if line < 0
+					pos = 0
+				char = copy[0]
+				copy = copy.slice(1)
+				@ace.session.insert({row: line, column: pos}, char)
+				pos += 1
+				@_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0
+			@_cm = timer()
+
+		clearChaosMonkey: () ->
+			clearTimeout @_cm
+
 		pollSavedStatus: () ->
 		pollSavedStatus: () ->
 			# returns false if doc has ops waiting to be acknowledged or
 			# returns false if doc has ops waiting to be acknowledged or
 			# sent that haven't changed since the last time we checked.
 			# sent that haven't changed since the last time we checked.

+ 2 - 1
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -86,6 +86,7 @@ define [
 				@_bindToDocumentEvents(doc, new_sharejs_doc)
 				@_bindToDocumentEvents(doc, new_sharejs_doc)
 				callback null, new_sharejs_doc
 				callback null, new_sharejs_doc
 
 
+
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 			sharejs_doc.on "error", (error, meta) =>
 			sharejs_doc.on "error", (error, meta) =>
 				if error?.message?.match "maxDocLength"
 				if error?.message?.match "maxDocLength"
@@ -98,7 +99,7 @@ define [
 					@ide.reportError(error, meta)
 					@ide.reportError(error, meta)
 					@ide.showGenericMessageModal(
 					@ide.showGenericMessageModal(
 						"Out of sync"
 						"Out of sync"
-						"Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
+						"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='http://sharelatex.tenderapp.com/help/kb/browsers/editor-out-of-sync-problems'>Please see this help guide for more information</a>"
 					)
 					)
 				@openDoc(doc, forceReopen: true)
 				@openDoc(doc, forceReopen: true)
 
 

+ 20 - 3
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -18,7 +18,7 @@ define [
 			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
 			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
 			return url
 			return url
 
 
-	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage) ->
+	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory) ->
 		monkeyPatchSearch($rootScope, $compile)
 		monkeyPatchSearch($rootScope, $compile)
 
 
 		return  {
 		return  {
@@ -29,6 +29,7 @@ define [
 				fontSize: "="
 				fontSize: "="
 				autoComplete: "="
 				autoComplete: "="
 				sharejsDoc: "="
 				sharejsDoc: "="
+				spellCheck: "="
 				spellCheckLanguage: "="
 				spellCheckLanguage: "="
 				highlights: "="
 				highlights: "="
 				text: "="
 				text: "="
@@ -55,7 +56,9 @@ define [
 				scope.name = attrs.aceEditor
 				scope.name = attrs.aceEditor
 
 
 				autoCompleteManager   = new AutoCompleteManager(scope, editor, element)
 				autoCompleteManager   = new AutoCompleteManager(scope, editor, element)
-				spellCheckManager     = new SpellCheckManager(scope, editor, element)
+				if scope.spellCheck # only enable spellcheck when explicitly required
+					spellCheckCache =  $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
+					spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache)
 				undoManager           = new UndoManager(scope, editor, element)
 				undoManager           = new UndoManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@@ -69,6 +72,21 @@ define [
 				editor.commands.removeCommand "transposeletters"
 				editor.commands.removeCommand "transposeletters"
 				editor.commands.removeCommand "showSettingsMenu"
 				editor.commands.removeCommand "showSettingsMenu"
 				editor.commands.removeCommand "foldall"
 				editor.commands.removeCommand "foldall"
+				
+				# For European keyboards, the / is above 7 so needs Shift pressing.
+				# This comes through as Ctrl-Shift-/ which is mapped to toggleBlockComment.
+				# This doesn't do anything for LaTeX, so remap this to togglecomment to
+				# work for European keyboards as normal.
+				editor.commands.removeCommand "toggleBlockComment"
+				editor.commands.removeCommand "togglecomment"
+				
+				editor.commands.addCommand {
+					name: "togglecomment",
+					bindKey: { win: "Ctrl-/|Ctrl-Shift-/", mac: "Command-/|Command-Shift-/" },
+					exec: (editor) -> editor.toggleCommentLines(),
+					multiSelectAction: "forEachLine",
+					scrollIntoView: "selectionPart"
+				}
 
 
 				# Trigger search AND replace on CMD+F
 				# Trigger search AND replace on CMD+F
 				editor.commands.addCommand
 				editor.commands.addCommand
@@ -77,7 +95,6 @@ define [
 					exec: (editor) ->
 					exec: (editor) ->
 						ace.require("ace/ext/searchbox").Search(editor, true)
 						ace.require("ace/ext/searchbox").Search(editor, true)
 					readOnly: true
 					readOnly: true
-				editor.commands.removeCommand "replace"
 				
 				
 				# Bold text on CMD+B
 				# Bold text on CMD+B
 				editor.commands.addCommand
 				editor.commands.addCommand

+ 40 - 4
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -16,7 +16,7 @@ define [
 		constructor: (@$scope, @editor) ->
 		constructor: (@$scope, @editor) ->
 			@suggestionManager = new SuggestionManager()
 			@suggestionManager = new SuggestionManager()
 
 
-			@monkeyPatchAutocomplete()	
+			@monkeyPatchAutocomplete()
 
 
 			@$scope.$watch "autoComplete", (autocomplete) =>
 			@$scope.$watch "autoComplete", (autocomplete) =>
 				if autocomplete
 				if autocomplete
@@ -37,11 +37,47 @@ define [
 				enableSnippets: true,
 				enableSnippets: true,
 				enableLiveAutocompletion: false
 				enableLiveAutocompletion: false
 			})
 			})
-			
+
 			SnippetCompleter =
 			SnippetCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
 				getCompletions: (editor, session, pos, prefix, callback) ->
 					callback null, Snippets
 					callback null, Snippets
-			@editor.completers = [@suggestionManager, SnippetCompleter]
+
+			references = @$scope.$root._references
+			ReferencesCompleter =
+				getCompletions: (editor, session, pos, prefix, callback) ->
+					range = new Range(pos.row, 0, pos.row, pos.column)
+					lineUpToCursor = editor.getSession().getTextRange(range)
+					commandFragment = getLastCommandFragment(lineUpToCursor)
+					if commandFragment
+						citeMatch = commandFragment.match(/^~?\\([a-z]*cite[a-z]?){(.*,)?(\w*)/)
+						if citeMatch
+							commandName = citeMatch[1]
+							previousArgs = citeMatch[2]
+							currentArg = citeMatch[3]
+							if previousArgs == undefined
+								previousArgs = ""
+							previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
+							result = []
+							result.push {
+								caption: "\\#{commandName}{",
+								snippet: "\\#{commandName}{",
+								meta: "reference",
+								score: 11000
+							}
+							if references.keys and references.keys.length > 0
+								references.keys.forEach (key) ->
+									if !(key in [null, undefined])
+										result.push({
+											caption: "\\#{commandName}{#{previousArgsCaption}#{key}",
+											value: "\\#{commandName}{#{previousArgs}#{key}",
+											meta: "reference",
+											score: 10000
+										})
+								callback null, result
+							else
+								callback null, result
+
+			@editor.completers = [@suggestionManager, SnippetCompleter, ReferencesCompleter]
 
 
 		disable: () ->
 		disable: () ->
 			@editor.setOptions({
 			@editor.setOptions({
@@ -83,7 +119,7 @@ define [
 					# since it will be adding in with the autocomplete of \begin{item}...
 					# since it will be adding in with the autocomplete of \begin{item}...
 					if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
 					if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
 						editor.session.remove(range)
 						editor.session.remove(range)
-					
+
 					Autocomplete::_insertMatch.call this, data
 					Autocomplete::_insertMatch.call this, data
 
 
 				# Overwrite this to set autoInsert = false and set font size
 				# Overwrite this to set autoInsert = false and set font size

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

@@ -14,7 +14,7 @@ define () ->
 			caption: "\\begin{#{env}}..."
 			caption: "\\begin{#{env}}..."
 			snippet: """
 			snippet: """
 				\\begin{#{env}}
 				\\begin{#{env}}
-				$1
+				\t$1
 				\\end{#{env}}
 				\\end{#{env}}
 			"""
 			"""
 			meta: "env"
 			meta: "env"
@@ -24,8 +24,8 @@ define () ->
 		caption: "\\begin{array}..."
 		caption: "\\begin{array}..."
 		snippet: """
 		snippet: """
 			\\begin{array}{${1:cc}}
 			\\begin{array}{${1:cc}}
-			$2 & $3 \\\\\\\\
-			$4 & $5
+			\t$2 & $3 \\\\\\\\
+			\t$4 & $5
 			\\end{array}
 			\\end{array}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -33,10 +33,10 @@ define () ->
 		caption: "\\begin{figure}..."
 		caption: "\\begin{figure}..."
 		snippet: """
 		snippet: """
 			\\begin{figure}
 			\\begin{figure}
-			\\centering
-			\\includegraphics{$1}
-			\\caption{${2:Caption}}
-			\\label{${3:fig:my_label}}
+			\t\\centering
+			\t\\includegraphics{$1}
+			\t\\caption{${2:Caption}}
+			\t\\label{${3:fig:my_label}}
 			\\end{figure}
 			\\end{figure}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -44,8 +44,8 @@ define () ->
 		caption: "\\begin{tabular}..."
 		caption: "\\begin{tabular}..."
 		snippet: """
 		snippet: """
 			\\begin{tabular}{${1:c|c}}
 			\\begin{tabular}{${1:c|c}}
-			$2 & $3 \\\\\\\\
-			$4 & $5
+			\t$2 & $3 \\\\\\\\
+			\t$4 & $5
 			\\end{tabular}
 			\\end{tabular}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -53,13 +53,13 @@ define () ->
 		caption: "\\begin{table}..."
 		caption: "\\begin{table}..."
 		snippet: """
 		snippet: """
 			\\begin{table}[$1]
 			\\begin{table}[$1]
-			\\centering
-			\\begin{tabular}{${2:c|c}}
-			$3 & $4 \\\\\\\\
-			$5 & $6
-			\\end{tabular}
-			\\caption{${7:Caption}}
-			\\label{${8:tab:my_label}}
+			\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}
 			\\end{table}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -67,7 +67,7 @@ define () ->
 		caption: "\\begin{list}..."
 		caption: "\\begin{list}..."
 		snippet: """
 		snippet: """
 			\\begin{list}
 			\\begin{list}
-			\\item $1
+			\t\\item $1
 			\\end{list}
 			\\end{list}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -75,7 +75,7 @@ define () ->
 		caption: "\\begin{enumerate}..."
 		caption: "\\begin{enumerate}..."
 		snippet: """
 		snippet: """
 			\\begin{enumerate}
 			\\begin{enumerate}
-			\\item $1
+			\t\\item $1
 			\\end{enumerate}
 			\\end{enumerate}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -83,7 +83,7 @@ define () ->
 		caption: "\\begin{itemize}..."
 		caption: "\\begin{itemize}..."
 		snippet: """
 		snippet: """
 			\\begin{itemize}
 			\\begin{itemize}
-			\\item $1
+			\t\\item $1
 			\\end{itemize}
 			\\end{itemize}
 		"""
 		"""
 		meta: "env"
 		meta: "env"
@@ -91,7 +91,7 @@ define () ->
 		caption: "\\begin{frame}..."
 		caption: "\\begin{frame}..."
 		snippet: """
 		snippet: """
 			\\begin{frame}{${1:Frame Title}}
 			\\begin{frame}{${1:Frame Title}}
-			$2
+			\t$2
 			\\end{frame}
 			\\end{frame}
 		"""
 		"""
 		meta: "env"
 		meta: "env"

+ 58 - 13
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee

@@ -5,7 +5,7 @@ define [
 	Range = ace.require("ace/range").Range
 	Range = ace.require("ace/range").Range
 
 
 	class SpellCheckManager
 	class SpellCheckManager
-		constructor: (@$scope, @editor, @element) ->
+		constructor: (@$scope, @editor, @element, @cache) ->
 			$(document.body).append @element.find(".spell-check-menu")
 			$(document.body).append @element.find(".spell-check-menu")
 			
 			
 			@updatedLines = []
 			@updatedLines = []
@@ -102,6 +102,8 @@ define [
 		learnWord: (highlight) ->
 		learnWord: (highlight) ->
 			@apiRequest "/learn", word: highlight.word
 			@apiRequest "/learn", word: highlight.word
 			@highlightedWordManager.removeWord highlight.word
 			@highlightedWordManager.removeWord highlight.word
+			language = @$scope.spellCheckLanguage
+			@cache?.put("#{language}:#{highlight.word}", true)
 
 
 		getHighlightedWordAtCursor: () ->
 		getHighlightedWordAtCursor: () ->
 			cursor = @editor.getCursorPosition()
 			cursor = @editor.getCursorPosition()
@@ -143,24 +145,67 @@ define [
 		runSpellCheck: (linesToProcess) ->
 		runSpellCheck: (linesToProcess) ->
 			{words, positions} = @getWords(linesToProcess)
 			{words, positions} = @getWords(linesToProcess)
 			language = @$scope.spellCheckLanguage
 			language = @$scope.spellCheckLanguage
-			@apiRequest "/check", {language: language, words: words}, (error, result) =>
-				if error? or !result? or !result.misspellings?
-					return null
 
 
+			highlights = []
+			seen = {}
+			newWords = []
+			newPositions = []
+
+			# iterate through all words, building up a list of
+			# newWords/newPositions not in the cache
+			for word, i in words
+				key = "#{language}:#{word}"
+				seen[key] ?= @cache.get(key) # avoid hitting the cache unnecessarily
+				cached = seen[key]
+				if not cached?
+					newWords.push words[i]
+					newPositions.push positions[i]
+				else if cached is true
+					# word is correct
+				else
+					highlights.push
+						column: positions[i].column
+						row: positions[i].row
+						word: word
+						suggestions: cached
+			words = newWords
+			positions = newPositions
+
+			displayResult = (highlights) =>
 				if linesToProcess?
 				if linesToProcess?
 					for shouldProcess, row in linesToProcess
 					for shouldProcess, row in linesToProcess
 						@highlightedWordManager.clearRows(row, row) if shouldProcess
 						@highlightedWordManager.clearRows(row, row) if shouldProcess
 				else
 				else
 					@highlightedWordManager.clearRows()
 					@highlightedWordManager.clearRows()
-
-				for misspelling in result.misspellings
-					word = words[misspelling.index]
-					position = positions[misspelling.index]
-					@highlightedWordManager.addHighlight
-						column: position.column
-						row: position.row
-						word: word
-						suggestions: misspelling.suggestions
+				for highlight in highlights
+					@highlightedWordManager.addHighlight highlight
+
+			if not words.length
+				displayResult highlights
+			else
+				@apiRequest "/check", {language: language, words: words}, (error, result) =>
+					if error? or !result? or !result.misspellings?
+						return null
+					mispelled = []
+					for misspelling in result.misspellings
+						word = words[misspelling.index]
+						position = positions[misspelling.index]
+						mispelled[misspelling.index] = true
+						highlights.push
+							column: position.column
+							row: position.row
+							word: word
+							suggestions: misspelling.suggestions
+						key = "#{language}:#{word}"
+						if not seen[key]
+							@cache.put key, misspelling.suggestions
+							seen[key] = true
+					for word, i in words when not mispelled[i]
+						key = "#{language}:#{word}"
+						if not seen[key]
+							@cache.put(key, true)
+							seen[key] = true
+					displayResult highlights
 
 
 		getWords: (linesToProcess) ->
 		getWords: (linesToProcess) ->
 			lines = @editor.getValue().split("\n")
 			lines = @editor.getValue().split("\n")

+ 65 - 5
services/web/public/coffee/ide/file-tree/FileTreeManager.coffee

@@ -19,6 +19,12 @@ define [
 					@recalculateDocList()
 					@recalculateDocList()
 
 
 			@_bindToSocketEvents()
 			@_bindToSocketEvents()
+			
+			@$scope.multiSelectedCount = 0
+			
+			$(document).on "click", =>
+				@clearMultiSelectedEntities()
+				$scope.$digest()
 
 
 		_bindToSocketEvents: () ->
 		_bindToSocketEvents: () ->
 			@ide.socket.on "reciveNewDoc", (parent_folder_id, doc) =>
 			@ide.socket.on "reciveNewDoc", (parent_folder_id, doc) =>
@@ -65,6 +71,7 @@ define [
 				@$scope.$apply () =>
 				@$scope.$apply () =>
 					@_deleteEntityFromScope entity
 					@_deleteEntityFromScope entity
 					@recalculateDocList()
 					@recalculateDocList()
+				@$scope.$emit "entity:deleted", entity
 
 
 			@ide.socket.on "reciveEntityMove", (entity_id, folder_id) =>
 			@ide.socket.on "reciveEntityMove", (entity_id, folder_id) =>
 				entity = @findEntityById(entity_id)
 				entity = @findEntityById(entity_id)
@@ -78,7 +85,62 @@ define [
 			@ide.fileTreeManager.forEachEntity (entity) ->
 			@ide.fileTreeManager.forEachEntity (entity) ->
 				entity.selected = false
 				entity.selected = false
 			entity.selected = true
 			entity.selected = true
-
+		
+		toggleMultiSelectEntity: (entity) ->
+			entity.multiSelected = !entity.multiSelected
+			@$scope.multiSelectedCount = @multiSelectedCount()
+		
+		multiSelectedCount: () ->
+			count = 0
+			@forEachEntity (entity) ->
+				if entity.multiSelected
+					count++
+			return count
+		
+		getMultiSelectedEntities: () ->
+			entities = []
+			@forEachEntity (e) ->
+				if e.multiSelected
+					entities.push e
+			return entities
+		
+		getMultiSelectedEntityChildNodes: () ->
+			entities = @getMultiSelectedEntities()
+			paths = {}
+			for entity in entities
+				paths[@getEntityPath(entity)] = entity
+			prefixes = {}
+			for path, entity of paths
+				parts = path.split("/")
+				if parts.length <= 1
+					continue
+				else
+					# Record prefixes a/b/c.tex -> 'a' and 'a/b'
+					for i in [1..(parts.length - 1)]
+						prefixes[parts.slice(0,i).join("/")] = true
+			child_entities = []
+			for path, entity of paths
+				# If the path is in the prefixes, then it's a parent folder and
+				# should be ignore
+				if !prefixes[path]?
+					child_entities.push entity
+			return child_entities
+		
+		clearMultiSelectedEntities: () ->
+			return if @$scope.multiSelectedCount == 0 # Be efficient, this is called a lot on 'click'
+			@forEachEntity (entity) ->
+				entity.multiSelected = false
+			@$scope.multiSelectedCount = 0
+		
+		multiSelectSelectedEntity: () ->
+			@findSelectedEntity()?.multiSelected = true
+
+		existsInFolder: (folder_id, name) ->
+			folder = @findEntityById(folder_id)
+			return false if !folder?
+			entity = @_findEntityByPathInFolder(folder, name)
+			return entity?
+			
 		findSelectedEntity: () ->
 		findSelectedEntity: () ->
 			selected = null
 			selected = null
 			@forEachEntity (entity) ->
 			@forEachEntity (entity) ->
@@ -277,7 +339,7 @@ define [
 		deleteEntity: (entity, callback = (error) ->) ->
 		deleteEntity: (entity, callback = (error) ->) ->
 			# We'll wait for the socket.io notification to 
 			# We'll wait for the socket.io notification to 
 			# delete from scope.
 			# delete from scope.
-			return @ide.$http {
+			return @ide.queuedHttp {
 				method: "DELETE"
 				method: "DELETE"
 				url:    "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}"
 				url:    "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}"
 				headers:
 				headers:
@@ -289,7 +351,7 @@ define [
 			# since that would break the tree structure.
 			# since that would break the tree structure.
 			return if @_isChildFolder(entity, parent_folder)
 			return if @_isChildFolder(entity, parent_folder)
 			@_moveEntityInScope(entity, parent_folder)
 			@_moveEntityInScope(entity, parent_folder)
-			return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", {
+			return @ide.queuedHttp.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", {
 				folder_id: parent_folder.id
 				folder_id: parent_folder.id
 				_csrf: window.csrfToken
 				_csrf: window.csrfToken
 			}
 			}
@@ -316,8 +378,6 @@ define [
 				entity.deleted = true
 				entity.deleted = true
 				@$scope.deletedDocs.push entity
 				@$scope.deletedDocs.push entity
 
 
-			@$scope.$emit "entity:deleted", entity
-
 		_moveEntityInScope: (entity, parent_folder) ->
 		_moveEntityInScope: (entity, parent_folder) ->
 			return if entity in parent_folder.children
 			return if entity in parent_folder.children
 			@_deleteEntityFromScope(entity, moveToDeleted: false)
 			@_deleteEntityFromScope(entity, moveToDeleted: false)

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

@@ -61,6 +61,8 @@ define [
 				$scope.state.inflight = true
 				$scope.state.inflight = true
 				ide.fileTreeManager
 				ide.fileTreeManager
 					.createDoc(name, parent_folder)
 					.createDoc(name, parent_folder)
+					.error (e)->
+						$scope.error = e
 					.success () ->
 					.success () ->
 						$scope.state.inflight = false
 						$scope.state.inflight = false
 						$modalInstance.close()
 						$modalInstance.close()
@@ -90,6 +92,8 @@ define [
 				$scope.state.inflight = true
 				$scope.state.inflight = true
 				ide.fileTreeManager
 				ide.fileTreeManager
 					.createFolder(name, parent_folder)
 					.createFolder(name, parent_folder)
+					.error (e)->
+						$scope.error = e
 					.success () ->
 					.success () ->
 						$scope.state.inflight = false
 						$scope.state.inflight = false
 						$modalInstance.close()
 						$modalInstance.close()
@@ -99,17 +103,30 @@ define [
 	]
 	]
 
 
 	App.controller "UploadFileModalController", [
 	App.controller "UploadFileModalController", [
-		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
-		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder", "$window"
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder, $window) ->
 			$scope.parent_folder_id = parent_folder?.id
 			$scope.parent_folder_id = parent_folder?.id
 			$scope.tooManyFiles = false
 			$scope.tooManyFiles = false
 			$scope.rateLimitHit = false
 			$scope.rateLimitHit = false
+			$scope.secondsToRedirect = 10
+			$scope.notLoggedIn = false
+			$scope.conflicts = []
+			$scope.control = {}
+
+			needToLogBackIn = ->
+				$scope.notLoggedIn = true
+				decreseTimeout = ->
+					$timeout (() ->
+						if $scope.secondsToRedirect == 0
+							$window.location.href = "/login?redir=/project/#{ide.project_id}"
+						else
+							decreseTimeout()
+							$scope.secondsToRedirect = $scope.secondsToRedirect - 1
+					), 1000
 
 
-			uploadCount = 0
-			$scope.onUpload = () ->
-				uploadCount++
+				decreseTimeout()
 
 
-			$scope.max_files = 20
+			$scope.max_files = 40
 			$scope.onComplete = (error, name, response) ->
 			$scope.onComplete = (error, name, response) ->
 				$timeout (() ->
 				$timeout (() ->
 					uploadCount--
 					uploadCount--
@@ -127,8 +144,40 @@ define [
 					return true
 					return true
 
 
 			$scope.onError = (id, name, reason)->
 			$scope.onError = (id, name, reason)->
+				console.log(id, name, reason)
 				if reason.indexOf("429") != -1
 				if reason.indexOf("429") != -1
 					$scope.rateLimitHit = true
 					$scope.rateLimitHit = true
+				else if reason.indexOf("403") != -1
+					needToLogBackIn()
+
+			_uploadTimer = null
+			uploadIfNoConflicts = () ->
+				if $scope.conflicts.length == 0
+					$scope.doUpload()
+
+			uploadCount = 0
+			$scope.onSubmit = (id, name) ->
+				uploadCount++
+				if ide.fileTreeManager.existsInFolder($scope.parent_folder_id, name)
+					$scope.conflicts.push name
+					$scope.$apply()
+				if !_uploadTimer?
+					_uploadTimer = setTimeout () ->
+						_uploadTimer = null
+						uploadIfNoConflicts()
+					, 0
+				return true
+			
+			$scope.onCancel = (id, name) ->
+				uploadCount--
+				index = $scope.conflicts.indexOf(name)
+				if index > -1
+					$scope.conflicts.splice(index, 1)
+				$scope.$apply()
+				uploadIfNoConflicts()
+
+			$scope.doUpload = () ->
+				$scope.control?.q?.uploadStoredFiles()
 
 
 			$scope.cancel = () ->
 			$scope.cancel = () ->
 				$modalInstance.dismiss('cancel')
 				$modalInstance.dismiss('cancel')

+ 30 - 11
services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee

@@ -2,9 +2,23 @@ define [
 	"base"
 	"base"
 ], (App) ->
 ], (App) ->
 	App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
 	App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
-		$scope.select = () ->
-			ide.fileTreeManager.selectEntity($scope.entity)
-			$scope.$emit "entity:selected", $scope.entity
+		$scope.select = (e) ->
+			if e.ctrlKey or e.metaKey
+				e.stopPropagation()
+				initialMultiSelectCount = ide.fileTreeManager.multiSelectedCount()
+				ide.fileTreeManager.toggleMultiSelectEntity($scope.entity) == 0
+				if initialMultiSelectCount == 0
+					# On first multi selection, also include the current active/open file.
+					ide.fileTreeManager.multiSelectSelectedEntity()
+			else
+				ide.fileTreeManager.selectEntity($scope.entity)
+				$scope.$emit "entity:selected", $scope.entity
+		
+		$scope.draggableHelper = () ->
+			if ide.fileTreeManager.multiSelectedCount() > 0
+				return $("<strong style='z-index:100'>#{ide.fileTreeManager.multiSelectedCount()} Files</strong>")
+			else
+				return $("<strong style='z-index:100'>#{$scope.entity.name}</strong>")
 
 
 		$scope.inputs =
 		$scope.inputs =
 			name: $scope.entity.name
 			name: $scope.entity.name
@@ -24,10 +38,15 @@ define [
 			$scope.startRenaming() if $scope.entity.selected
 			$scope.startRenaming() if $scope.entity.selected
 
 
 		$scope.openDeleteModal = () ->
 		$scope.openDeleteModal = () ->
+			if ide.fileTreeManager.multiSelectedCount() > 0
+				entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
+			else
+				entities = [$scope.entity]
 			$modal.open(
 			$modal.open(
 				templateUrl: "deleteEntityModalTemplate"
 				templateUrl: "deleteEntityModalTemplate"
 				controller:  "DeleteEntityModalController"
 				controller:  "DeleteEntityModalController"
-				scope: $scope
+				resolve:
+					entities: () -> entities
 			)
 			)
 
 
 		$scope.$on "delete:selected", () ->
 		$scope.$on "delete:selected", () ->
@@ -35,18 +54,18 @@ define [
 	]
 	]
 
 
 	App.controller "DeleteEntityModalController", [
 	App.controller "DeleteEntityModalController", [
-		"$scope", "ide", "$modalInstance",
-		($scope,   ide,   $modalInstance) ->
+		"$scope", "ide", "$modalInstance", "entities"
+		($scope,   ide,   $modalInstance, entities) ->
 			$scope.state =
 			$scope.state =
 				inflight: false
 				inflight: false
+				
+			$scope.entities = entities
 
 
 			$scope.delete = () ->
 			$scope.delete = () ->
 				$scope.state.inflight = true
 				$scope.state.inflight = true
-				ide.fileTreeManager
-					.deleteEntity($scope.entity)
-					.success () ->
-						$scope.state.inflight = false
-						$modalInstance.close()
+				for entity in $scope.entities
+					ide.fileTreeManager.deleteEntity(entity)
+				$modalInstance.close()
 
 
 			$scope.cancel = () ->
 			$scope.cancel = () ->
 				$modalInstance.dismiss('cancel')
 				$modalInstance.dismiss('cancel')

Некоторые файлы не были показаны из-за большого количества измененных файлов