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

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:
 						"moment": "libs/moment-2.9.0"
 						"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:
 						"libs/pdf":
-							deps: ["libs/pdfjs-1.0.1040/compatibility"]
+							deps: ["libs/pdfjs-1.3.91/compatibility"]
 
 					skipDirOptimize: true
 					modules: [

+ 8 - 0
services/web/app.coffee

@@ -3,8 +3,12 @@ logger = require 'logger-sharelatex'
 logger.initialize("web-sharelatex")
 logger.logger.serializers.user = require("./app/js/infrastructure/LoggerSerializers").user
 logger.logger.serializers.project = require("./app/js/infrastructure/LoggerSerializers").project
+if Settings.sentry?.dsn?
+	logger.initializeErrorReporting(Settings.sentry.dsn)
+
 metrics = require("metrics-sharelatex")
 metrics.initialize("web")
+metrics.memory.monitor(logger)
 Server = require("./app/js/infrastructure/Server")
 Errors = require "./app/js/errors"
 
@@ -15,6 +19,10 @@ argv = require("optimist")
 	.argv
 
 Server.app.use (error, req, res, next) ->
+	if error?.code is 'EBADCSRFTOKEN'
+		logger.log err: error,url:req.url, method:req.method, user:req?.sesson?.user, "invalid csrf"
+		res.sendStatus(403)
+		return
 	logger.error err: error, url:req.url, method:req.method, user:req?.sesson?.user, "error passed to top level next middlewear"
 	res.statusCode = error.status or 500
 	if res.statusCode == 500

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

@@ -8,13 +8,16 @@ querystring = require('querystring')
 Url = require("url")
 Settings = require "settings-sharelatex"
 basicAuth = require('basic-auth-connect')
-
+UserHandler = require("../User/UserHandler")
 
 module.exports = AuthenticationController =
 	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)->
 			if !isAllowed
 				logger.log email:email, "too many login requests"
@@ -26,17 +29,18 @@ module.exports = AuthenticationController =
 			AuthenticationManager.authenticate email: email, password, (error, user) ->
 				return next(error) if error?
 				if user?
+					UserHandler.setupLoginData user, ->
 					LoginRateLimiter.recordSuccessfulLogin email
 					AuthenticationController._recordSuccessfulLogin user._id
 					AuthenticationController.establishUserSession req, user, (error) ->
 						return next(error) if error?
 						req.session.justLoggedIn = true
 						logger.log email: email, user_id: user._id.toString(), "successful log in"
-						res.send redir: redir
+						res.json redir: redir
 				else
 					AuthenticationController._recordFailedLogin()
 					logger.log email: email, "failed log in"
-					res.send message:
+					res.json message:
 						text: req.i18n.translate("email_or_password_wrong_try_again"),
 						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
 {db, ObjectId} = require("../../infrastructure/mongojs")
 crypto = require 'crypto'

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

@@ -4,15 +4,13 @@ logger = require("logger-sharelatex")
 _ = require("underscore")
 ErrorController = require "../Errors/ErrorController"
 
-extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps"]
-
 module.exports = BlogController = 
 
 	getPage: (req, res, next)->
 		url = req.url?.toLowerCase()
 		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)->
 			url.indexOf(extension) != -1

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

@@ -58,8 +58,8 @@ module.exports = ClsiManager =
 		return outputFiles
 
 	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(new Errors.NotFoundError("project does not exist: #{project_id}")) if !project?
 
@@ -82,7 +82,7 @@ module.exports = ClsiManager =
 							content: doc.lines.join("\n")
 						if project.rootDoc_id? and doc._id.toString() == project.rootDoc_id.toString()
 							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
 
 					rootResourcePath = rootResourcePathOverride if rootResourcePathOverride?
@@ -101,7 +101,9 @@ module.exports = ClsiManager =
 							compile:
 								options:
 									compiler: project.compiler
-									timeout: settingsOverride.timeout
+									timeout: options.timeout
+									imageName: project.imageName
+									draft: !!options.draft
 								rootResourcePath: rootResourcePath
 								resources: resources
 						}
@@ -110,8 +112,11 @@ module.exports = ClsiManager =
 		ClsiManager._buildRequest project_id, options, (error, req) ->
 			compilerUrl = ClsiManager._getCompilerUrl(options?.compileGroup)
 			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 {
-				url:  "#{compilerUrl}/project/#{project_id}/wordcount?file=#{filename}"
+				url: wordcount_url
 			}, (error, response, body) ->
 				return callback(error) if error?
 				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
 			if 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"
 			CompileManager.compile project_id, user_id, options, (error, status, outputFiles, output, limits) ->
 				return next(error) if error?

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

@@ -1,6 +1,5 @@
 request = require 'request'
 request = request.defaults()
-async = require 'async'
 settings = require 'settings-sharelatex'
 _ = require 'underscore'
 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}"
 				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")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}"
 		body =
@@ -124,7 +123,8 @@ module.exports = DocumentUpdaterHandler =
 			json:
 				lines: docLines
 				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)->
 			timer.done()
 			if error?

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

@@ -6,15 +6,20 @@ module.exports =
 	getDocument: (req, res, next = (error) ->) ->
 		project_id = req.params.Project_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)"
 		ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
 			if error?
 				logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
 				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) ->) ->
 		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')
 
 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"
 			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"
 		Metrics.inc "editor.add-doc"
 		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)
 			callback(err, doc)
 
-
 	addFile: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 		LockManager.getLock project_id, (err)->
 			if err?
@@ -46,20 +48,20 @@ module.exports = EditorController =
 				LockManager.releaseLock project_id, ->
 					callback(error, file)	
 
-
 	addFileWithoutLock: (project_id, folder_id, fileName, path, source, callback = (error, file)->)->
 		fileName = fileName.trim()
 		logger.log {project_id, folder_id, fileName, path}, "sending new file to project"
 		Metrics.inc "editor.add-file"
 		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)
 			callback(err, fileRef)
 
 	replaceFile: (project_id, file_id, fsPath, source, callback = (error) ->)->
 		ProjectEntityHandler.replaceFile project_id, file_id, fsPath, callback
 
-
-
 	addFolder : (project_id, folder_id, folderName, source, callback = (error, folder)->)->
 		LockManager.getLock project_id, (err)->
 			if err?
@@ -74,6 +76,9 @@ module.exports = EditorController =
 		logger.log {project_id, folder_id, folderName, source}, "sending new folder to project"
 		Metrics.inc "editor.add-folder"
 		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) ->
 				callback error, folder
 
@@ -90,6 +95,9 @@ module.exports = EditorController =
 	mkdirpWithoutLock: (project_id, path, callback)->
 		logger.log project_id:project_id, path:path, "making directories if they don't exist"
 		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 = @
 			jobs = _.map newFolders, (folder, index)->
 				return (cb)->
@@ -109,7 +117,10 @@ module.exports = EditorController =
 	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)->
 		logger.log {project_id, entity_id, entityType, source}, "start delete process of 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"
 			EditorRealTimeController.emitToRoom(project_id, 'removeEntity', entity_id, source)
 			if callback?
@@ -143,19 +154,28 @@ module.exports = EditorController =
 		newName = sanitize.escape(newName)
 		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"
-		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
 				EditorRealTimeController.emitToRoom project_id, 'reciveEntityRename', entity_id, newName
 				callback?()
-#
+
 	moveEntity: (project_id, entity_id, folder_id, entityType, callback)->
 		Metrics.inc "editor.move-entity"
 		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
 			callback?()
 
 	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
 			callback()
 

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

@@ -67,11 +67,16 @@ module.exports = EditorHttpController =
 		project_id = req.params.Project_id
 		name = req.body.name
 		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)
 			return res.sendStatus 400
 		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) ->
 		project_id = req.params.Project_id
@@ -80,8 +85,12 @@ module.exports = EditorHttpController =
 		if !EditorHttpController._nameIsAcceptableLength(name)
 			return res.sendStatus 400
 		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) ->
 		project_id  = req.params.Project_id

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

@@ -1,5 +1,4 @@
 _ = require('underscore')
-
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
 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>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>
 """
 

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

@@ -1,8 +1,9 @@
 logger = require('logger-sharelatex')
 metrics = require('../../infrastructure/Metrics')
 Settings = require('settings-sharelatex')
-metrics = require("../../infrastructure/Metrics")
 nodemailer = require("nodemailer")
+sesTransport = require('nodemailer-ses-transport')
+_ = require("underscore")
 
 if Settings.email? and Settings.email.fromAddress?
 	defaultFromAddress = Settings.email.fromAddress
@@ -15,15 +16,24 @@ client =
 		logger.log options:options, "Would send email if enabled."
 		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 =
 	sendEmail : (options, callback = (error) ->)->
@@ -42,4 +52,3 @@ module.exports =
 			else
 				logger.log "Message sent to #{options.to}"
 			callback(err)
-

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

@@ -6,24 +6,32 @@ settings = require("settings-sharelatex")
 oneMinInMs = 60 * 1000
 fiveMinsInMs = oneMinInMs * 5
 
-module.exports =
+module.exports = FileStoreHandler =
 
 	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)->
 		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")
 RateLimiter = require("../../infrastructure/RateLimiter")
+AuthenticationController = require("../Authentication/AuthenticationController")
+UserGetter = require("../User/UserGetter")
 logger = require "logger-sharelatex"
 
 module.exports =
@@ -37,14 +39,19 @@ module.exports =
 			title:"set_password"
 			passwordResetToken: req.session.resetToken
 
-	setNewUserPassword: (req, res)->
+	setNewUserPassword: (req, res, next)->
 		{passwordResetToken, password} = req.body
 		if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
 			return res.sendStatus 400
 		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?
 			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
-				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?
 					callback null, true
 
-	setNewUserPassword: (token, password, callback = (error, found) ->)->
+	setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
 		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
 			if err then return callback(err)
 			if !user_id?
-				return callback null, false
+				return callback null, false, null
 			AuthenticationManager.setUserPassword user_id, password, (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
 TagsHandler = require("../Tags/TagsHandler")
 SubscriptionLocator = require("../Subscription/SubscriptionLocator")
+NotificationsHandler = require("../Notifications/NotificationsHandler")
 LimitationsManager = require("../Subscription/LimitationsManager")
 _ = require("underscore")
 Settings = require("settings-sharelatex")
@@ -51,7 +52,7 @@ module.exports = ProjectController =
 	deleteProject: (req, res) ->
 		project_id = req.params.Project_id
 		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
 			doDelete = projectDeleter.deleteProject
@@ -125,6 +126,8 @@ module.exports = ProjectController =
 		async.parallel {
 			tags: (cb)->
 				TagsHandler.getAllTags user_id, cb
+			notifications: (cb)->
+				NotificationsHandler.getUserNotifications user_id, cb
 			projects: (cb)->
 				Project.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref', cb
 			hasSubscription: (cb)->
@@ -137,6 +140,9 @@ module.exports = ProjectController =
 					return next(err)
 				logger.log results:results, user_id:user_id, "rendering project list"
 				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]
 				user = results.user
 				ProjectController._injectProjectOwners projects, (error, projects) ->
@@ -147,6 +153,7 @@ module.exports = ProjectController =
 						priority_title: true
 						projects: projects
 						tags: tags
+						notifications: notifications or []
 						user: user
 						hasSubscription: results.hasSubscription[0]
 					}
@@ -168,15 +175,15 @@ module.exports = ProjectController =
 			return res.render("general/closed", {title:"updating_site"})
 
 		if req.session.user?
-			user_id = req.session.user._id 
+			user_id = req.session.user._id
 			anonymous = false
 		else
 			anonymous = true
 			user_id = 'openUser'
-		
+
 		project_id = req.params.Project_id
 		logger.log project_id:project_id, "loading editor"
-	
+
 		async.parallel {
 			project: (cb)->
 				Project.findPopulatedById project_id, cb
@@ -193,7 +200,7 @@ module.exports = ProjectController =
 				SubscriptionLocator.getUsersSubscription user_id, cb
 			activate: (cb)->
 				InactiveProjectManager.reactivateProjectIfRequired project_id, cb
-			markAsOpened: (cb)-> 
+			markAsOpened: (cb)->
 				#don't need to wait for this to complete
 				ProjectUpdateHandler.markAsOpened project_id, ->
 				cb()
@@ -205,6 +212,7 @@ module.exports = ProjectController =
 			user = results.user
 			subscription = results.subscription
 
+
 			daysSinceLastUpdated =  (new Date() - project.lastUpdated) /86400000
 			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?
 					allowedFreeTrial = !!subscription.freeTrial.allowed || true
+
 				logger.log project_id:project_id, "rendering editor page"
 				res.render 'project/editor',
 					title:  project.name
@@ -310,5 +319,4 @@ do generateThemeList = () ->
 	for file in files
 		if file.slice(-2) == "js" and file.match(/^theme-/)
 			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
 			 owner_ref  : new ObjectId(owner_id)
 			 name       : projectName
+		if Settings.currentImageName?
+			project.imageName = Settings.currentImageName
 		project.rootFolder[0] = rootFolder
 		User.findById owner_id, "ace.spellCheckLanguage", (err, user)->
 			project.spellCheckLanguage = user.ace.spellCheckLanguage
@@ -33,7 +35,9 @@ module.exports =
 			self._buildTemplate "mainbasic.tex", owner_id, projectName, (error, docLines)->
 				return callback(error) if error?
 				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) ->
 						callback(error, project)
 

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

@@ -1,4 +1,5 @@
 Project = require('../../models/Project').Project
+ProjectGetter = require("./ProjectGetter")
 logger = require('logger-sharelatex')
 documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
 tagsHandler = require("../Tags/TagsHandler")
@@ -33,10 +34,10 @@ module.exports = ProjectDeleter =
 			Project.remove _id: project_id, callback
 
 	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?
-				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)
 			else
 				async.series [
@@ -57,8 +58,10 @@ module.exports = ProjectDeleter =
 						Project.update {_id:project_id}, { $set: { archived: true }}, cb
 				], (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) ->) ->
 		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 = 
 
 	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?
 				logger.err err:err, project_id:project_id, "error getting project"
 				return callback(err)
@@ -37,7 +37,7 @@ module.exports =
 
 	renameProject: (project_id, newName, callback = ->)->
 		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?
 				logger.err err:err,  project_id:project_id, "error getting project or could not find it todo project rename"
 				return callback(err)

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

@@ -4,61 +4,86 @@ projectLocator = require('./ProjectLocator')
 projectOptionsHandler = require('./ProjectOptionsHandler')
 DocumentUpdaterHandler = require("../DocumentUpdater/DocumentUpdaterHandler")
 DocstoreManager = require "../Docstore/DocstoreManager"
-Project = require("../../models/Project").Project
+ProjectGetter = require("./ProjectGetter")
 _ = require('underscore')
 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
 				compileTimeout: 60
 				compileGroup:"standard"
+				templates: false
+				references: false
 
 			if project.owner_ref.features?
 				if project.owner_ref.features.collaborators?
@@ -37,7 +39,11 @@ module.exports = ProjectEditorHandler =
 				if project.owner_ref.features.compileTimeout?
 					result.features.compileTimeout = project.owner_ref.features.compileTimeout
 				if project.owner_ref.features.compileGroup?
-					result.features.compileGroup = project.owner_ref.features.compileGroup	
+					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"

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

@@ -1,4 +1,5 @@
 Project = require('../../models/Project').Project
+settings = require "settings-sharelatex"
 Doc = require('../../models/Doc').Doc
 Folder = require('../../models/Folder').Folder
 File = require('../../models/File').File
@@ -75,23 +76,21 @@ module.exports = ProjectEntityHandler =
 		documentUpdaterHandler = require('../../Features/DocumentUpdater/DocumentUpdaterHandler')
 		documentUpdaterHandler.flushProjectToMongo project_id, (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?
 				requests = []
 				self.getAllDocs project_id, (error, docs) ->
 					return callback(error) if error?
 					for docPath, doc of docs
 						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) ->
 						return callback(error) if error?
 						for filePath, file of files
 							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) ->
 							logger.log project_id:project_id, "finished flushing project to tpds"
 							callback(err)
@@ -110,27 +109,36 @@ module.exports = ProjectEntityHandler =
 			options = {}
 		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) ->) ->
 		# 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?
 			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?
-						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)
-					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?
 			findOpts = 
 				project_id:project._id
@@ -182,21 +202,36 @@ module.exports = ProjectEntityHandler =
 						Project.update conditons, update, {}, (err, second)->
 							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?
-						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)->)->
 		self = @
@@ -204,7 +239,7 @@ module.exports = ProjectEntityHandler =
 		folders = _.select folders, (folder)->
 			return folder.length != 0
 
-		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)=>
+		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project)=>
 			if path == '/'
 				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])
@@ -217,7 +252,7 @@ module.exports = ProjectEntityHandler =
 				if parentFolder?  
 					parentFolder_id = parentFolder._id
 				builtUpPath = "#{builtUpPath}/#{folderName}"
-				projectLocator.findElementByPath project_id, builtUpPath, (err, foundFolder)=>
+				projectLocator.findElementByPath project, builtUpPath, (err, foundFolder)=>
 					if !foundFolder?
 						logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp"
 						@addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)->
@@ -235,16 +270,23 @@ module.exports = ProjectEntityHandler =
 				folders = _.select folders, (folder)->
 					!folder.filterOut
 				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) ->)->
 		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
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
-		Project.findById project_id, (err, project)=>
+		ProjectGetter.getProject project_id, {rootFolder:true, name:true}, (err, project)=>
 			return callback(err) if err?
 			projectLocator.findElement {project:project, element_id:entity_id, type:entityType}, (err, entity, path)->
 				return callback(err) if err?
@@ -302,7 +344,7 @@ module.exports = ProjectEntityHandler =
 					return callback(error) if error?
 					self._removeElementFromMongoArray Project, project_id, path.mongo, (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?
 							opts = 
 								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
 			return callback("No entityType set")
 		entityType = entityType.toLowerCase()
-		Project.findById project_id, (err, project)=>
+		ProjectGetter.getProject project_id, {name:true, rootFolder:true}, (err, project)=>
 			return callback(error) if error?
 			projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=>
 				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
 			return callback("No entityType set")
 		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)=>
 				if err?
 					return callback err
@@ -426,6 +468,72 @@ module.exports = ProjectEntityHandler =
 			}
 		}, {}, 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)->
 	logger.log folder_id:folder_id, project_id:project._id, "confirming folder in project"
 	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
 ObjectId = mongojs.ObjectId
 async = require "async"
+Errors = require("../../errors")
+logger = require("logger-sharelatex")
+
+
 
 module.exports = ProjectGetter =
 	EXCLUDE_DEPTH: 8
 
+
 	getProjectWithoutDocLines: (project_id, callback=(error, project) ->) ->
 		excludes = {}
-		for i in [1..@EXCLUDE_DEPTH]
+		for i in [1..ProjectGetter.EXCLUDE_DEPTH]
 			excludes["rootFolder#{Array(i).join(".folder")}.docs.lines"] = 0
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 			callback error, projects[0]
 
 	getProjectWithOnlyFolders: (project_id, callback=(error, project) ->) ->
 		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")}.fileRefs"] = 0
 		db.projects.find _id: ObjectId(project_id.toString()), excludes, (error, projects = []) ->
 			callback error, projects[0]
 
+
 	getProject: (query, projection, callback = (error, project) ->) ->
+		if !query?
+			return callback("no query provided")
+
+		if typeof(projection) == "function"
+			callback = projection
+
 		if typeof query == "string"
 			query = _id: ObjectId(query)
 		else if query instanceof ObjectId
 			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) ->) ->
 		# 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
+ProjectGetter = require("./ProjectGetter")
 Errors = require "../../errors"
 _ = require('underscore')
 logger = require('logger-sharelatex')
 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
 		elementType = sanitizeTypeOfElement type
 
@@ -46,7 +51,7 @@ module.exports =
 		if project?
 			startSearch(project)
 		else
-			Project.findById project_id, (err, project)->
+			ProjectGetter.getProject project_id, {rootFolder:true, rootDoc_id:true}, (err, project)->
 				return callback(err) if err?
 				if !project?
 					return callback(new Errors.NotFoundError("project not found"))
@@ -62,8 +67,12 @@ module.exports =
 		if project?
 			getRootDoc project
 		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)->)->
 
@@ -122,11 +131,11 @@ module.exports =
 			async.waterfall jobs, 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)
 			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"
 			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) ->
 		return (req, res, next) ->
-			if req.session.user?
+			if req.session?.user?
 				user_id = req.session.user._id
 			else
 				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)
 		.on "error", (error) ->
 			logger.error err: error, "Spelling API error"
-			res.sendStatus 500
 		.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 '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		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")
 

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

@@ -52,11 +52,17 @@ module.exports =
 			return callback(err) if err?
 			callback err, subscriptions.length > 0, subscriptions
 
-	hasGroupMembersLimitReached: (user_id, callback)->
+	hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, 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
 			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)->
 	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)
 		)
 
+	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) ->
 		@_parseXml xml, (error, data) ->

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

@@ -1,7 +1,6 @@
 SecurityManager     = require '../../managers/SecurityManager'
 SubscriptionHandler  = require './SubscriptionHandler'
 PlansLocator = require("./PlansLocator")
-SubscriptionFormatters = require("./SubscriptionFormatters")
 SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
 LimitationsManager = require("./LimitationsManager")
 RecurlyWrapper = require './RecurlyWrapper'
@@ -9,7 +8,6 @@ Settings   = require 'settings-sharelatex'
 logger     = require('logger-sharelatex')
 GeoIpLookup = require("../../infrastructure/GeoIpLookup")
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
-require("../../infrastructure/Sixpack")
 
 module.exports = SubscriptionController =
 
@@ -98,14 +96,14 @@ module.exports = SubscriptionController =
 				else
 					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groups) ->
 						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()
 						res.render "subscriptions/dashboard",
 							title: "your_subscription"
 							recomendedCurrency: subscription?.currency
 							taxRate:subscription?.taxRate
 							plans: plans
-							subscription: subscription
+							subscription: subscription || {}
 							groups: groups
 							subscriptionTabActive: true
 
@@ -226,6 +224,14 @@ module.exports = SubscriptionController =
 				else
 					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) ->
 		xml = ""

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

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

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

@@ -1,10 +1,7 @@
 SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
 logger = require("logger-sharelatex")
 SubscriptionLocator = require("./SubscriptionLocator")
-
 ErrorsController = require("../Errors/ErrorController")
-settings = require("settings-sharelatex")
-
 SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
 _ = require("underscore")
 
@@ -12,9 +9,12 @@ module.exports =
 
 	addUserToGroup: (req, res)->
 		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"
 		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 = 
 				user:user
 			if err and err.limitReached
@@ -25,7 +25,20 @@ module.exports =
 		adminUserId = req.session.user._id
 		userToRemove_id = req.params.user_id
 		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()
 
 	renderSubscriptionGroupAdminPage: (req, res)->
@@ -70,13 +83,16 @@ module.exports =
 		subscription_id = req.params.subscription_id
 		if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
 			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"
-				res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
+				return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
 			else if err?
-				res.sendStatus 500
+				return res.sendStatus 500
 			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)->
 		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")
 EmailHandler = require("../Email/EmailHandler")
 settings = require("settings-sharelatex")
+NotificationsBuilder = require("../Notifications/NotificationsBuilder")
 
 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)->
-			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
+					logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 					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)
 					callback(err, userViewModel)
 
@@ -60,13 +77,20 @@ module.exports = SubscriptionGroupHandler =
 			EmailHandler.sendEmail "completeJoinGroupAccount", opts, 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)->
-			
 			if err?  or subscription_id != token_subscription_id
 				logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
 				return callback("token_not_found")
 			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)->

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

@@ -74,5 +74,5 @@ module.exports =
 					return callback("no user found")
 				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
 
 	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.get '/subscription/group/export',  AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
 		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.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/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.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 = 
 			"$addToSet": {member_ids:user_id}
 		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
 
 	removeUserFromGroup: (adminUser_id, user_id, callback)->
@@ -39,10 +42,12 @@ module.exports =
 			admin_id: adminUser_id
 		removeOperation = 
 			"$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
 
-
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
 		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
 							taxRate:parseFloat(recurlySubscription?.tax_rate?._)
 							groupPlan: subscription.groupPlan
+							trial_ends_at:recurlySubscription?.trial_ends_at
 						}, memberSubscriptions
 				else
 					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")
 User = require('../../models/User').User
 PlansLocator = require("./PlansLocator")
@@ -9,7 +8,7 @@ module.exports =
 		conditions = _id:user_id
 		update = {}
 		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
 		User.update conditions, update, (err)->
 			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")
 
 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
-		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
-			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")
 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:
-				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:
-				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)->
-		uri = buildUri(user_id, project_id)
+		url = "#{settings.apis.tags.url}/user/#{user_id}/project/#{project_id}"
 		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 = {}
 		_.each tags, (tag)->
 			_.each tag.project_ids, (project_id)->
@@ -69,7 +92,3 @@ module.exports =
 				delete clonedTag.project_ids
 				result[project_id].push(clonedTag)
 		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
 		source = req.headers["x-sl-update-source"] or "unknown"
 		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?
 			res.sendStatus(200)
 			

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

@@ -27,7 +27,7 @@ module.exports =
 			FileTypeManager.shouldIgnore path, (err, shouldIgnore)->
 				if shouldIgnore
 					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)->	

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

@@ -42,6 +42,9 @@ module.exports = TpdsUpdateSender =
 
 	_addEntity: (options, callback = (err)->)->
 		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"
 			postOptions =
 				method : "post"
@@ -53,6 +56,9 @@ module.exports = TpdsUpdateSender =
 				title: "addFile"
 				streamOrigin : options.streamOrigin
 			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"
 				callback(err)
 	

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

@@ -8,7 +8,7 @@ uuid = require('node-uuid')
 fs = require('fs')
 
 module.exports =
-	mergeUpdate: (project_id, path, updateRequest, source, callback = (error) ->)->
+	mergeUpdate: (user_id, project_id, path, updateRequest, source, callback = (error) ->)->
 		self = @
 		logger.log project_id:project_id, path:path, "merging update from tpds"
 		projectLocator.findElementByPath project_id, path, (err, element)=>
@@ -30,7 +30,7 @@ module.exports =
 					if isFile
 						self.p.processFile project_id, elementId, fsPath, path, source, callback
 					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)->
 		projectLocator.findElementByPath project_id, path, (err, element)->
@@ -49,14 +49,14 @@ module.exports =
 
 	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)->
 				if err?
 					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)
 				logger.log docLines:docLines, doc_id:doc_id, project_id:project_id, "processing doc update from tpds"
 				if doc_id?
-					editorController.setDoc project_id, doc_id, docLines, source, (err)->
+					editorController.setDoc project_id, doc_id, user_id, docLines, source, (err)->
 						callback()
 				else
 					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"
 logger  = require "logger-sharelatex"
 metrics = require "../../infrastructure/Metrics"
+fs      = require "fs"
+Path    = require "path"
+_ = require("underscore")
+
+ONE_MEG = 1024 * 1024
 
 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)->
+			output += d
 
 		error = null
 		unzip.stderr.on "data", (chunk) ->
@@ -29,9 +31,80 @@ module.exports = ArchiveManager =
 			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)
+				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"
 EditorController = require "../Editor/EditorController"
 ProjectLocator   = require "../Project/ProjectLocator"
+logger = require("logger-sharelatex")
 
 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(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
-					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?
-				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?
-						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
-							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
 			logger.err project_id:project_id, name:name, "bad name when trying to upload file"
 			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, ->
 			timer.done()
 			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) ->) ->
 		ProjectCreationHandler.createBlankProject owner_id, name, (error, project) =>
 			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?
 				ProjectRootDocManager.setRootDocAutomatically project._id, (error) ->
 					return callback(error) if error?
 					callback(error, project)
 
-	insertZipArchiveIntoFolder: (project_id, folder_id, path, callback = (error) ->) ->
+	insertZipArchiveIntoFolder: (owner_id, project_id, folder_id, path, callback = (error) ->) ->
 		destination = @_getDestinationDirectory path
 		ArchiveManager.extractZipArchive path, destination, (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?
-				rimraf(destination, callback)
+				FileSystemImportManager.addFolderContents owner_id, project_id, folder_id, topLevelDestination, false, (error) ->
+					return callback(error) if error?
+					rimraf(destination, callback)
 
 	_getDestinationDirectory: (source) ->
 		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({
 				endpointName: "file-upload"
 				params: ["Project_id"]
-				maxRequests: 100
-				timeInterval: 60 * 20
+				maxRequests: 200
+				timeInterval: 60 * 30
 			}),
 			SecurityManager.requestCanModifyProject,
 			ProjectUploadController.uploadFile

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

@@ -1,3 +1,4 @@
+UserHandler = require("./UserHandler")
 UserDeleter = require("./UserDeleter")
 UserLocator = require("./UserLocator")
 User = require("../../models/User").User
@@ -8,12 +9,9 @@ metrics = require("../../infrastructure/Metrics")
 Url = require("url")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 UserUpdater = require("./UserUpdater")
-EmailHandler = require("../Email/EmailHandler")
-OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
 settings = require "settings-sharelatex"
-crypto = require "crypto"
 
-module.exports =
+module.exports = UserController =
 
 	deleteUser: (req, res)->
 		user_id = req.session.user._id
@@ -66,11 +64,18 @@ module.exports =
 						if err?
 							logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
 							if err.message == "alread_exists"
-								message = req.i18n.translate("alread_exists")
+								message = req.i18n.translate("email_already_registered")
 							else
 								message = req.i18n.translate("problem_changing_email_address")
 							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)->
 		metrics.inc "user.logout"
@@ -85,32 +90,12 @@ module.exports =
 		if !email? or email == ""
 			res.sendStatus 422 # Unprocessable Entity
 			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) ->)->
 		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")
+UserGetter = require("./UserGetter")
+ErrorController = require("../Errors/ErrorController")
 logger = require("logger-sharelatex")
 Settings = require("settings-sharelatex")
 fs = require('fs')
@@ -20,11 +22,33 @@ module.exports =
 			sharedProjectData: sharedProjectData
 			newTemplateData: newTemplateData
 			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)->
 		res.render 'user/login',
 			title: 'login',
-			redir: req.query.redir
+			redir: req.query.redir,
+			email: req.query.email
 
 	settingsPage : (req, res, next)->
 		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")
 async = require("async")
 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) ->
 		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)
@@ -58,6 +62,31 @@ module.exports =
 				], (err)->
 					logger.log user: user, "registered"
 					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}libs.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"
 ]
 	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:''}
 	archived          : { type: Boolean }
 	deletedDocs       : [DeletedDocSchema]
+	imageName         : { type: String }
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	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)=>
 				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 }
 							compileTimeout: { type:Number, default: Settings.defaultFeatures.compileTimeout }
 							compileGroup:  { type:String,  default: Settings.defaultFeatures.compileGroup }
+							templates:     { type:Boolean, default: Settings.defaultFeatures.templates }
+							references:    { type:Boolean, default: Settings.defaultFeatures.references }
 						}
 	featureSwitches	  : {
 		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')
 AuthenticationController = require('./Features/Authentication/AuthenticationController')
 TagsController = require("./Features/Tags/TagsController")
+NotificationsController = require("./Features/Notifications/NotificationsController")
 CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter')
 UserInfoController = require('./Features/User/UserInfoController')
 UserController = require("./Features/User/UserController")
@@ -37,6 +38,7 @@ RateLimiterMiddlewear = require('./Features/Security/RateLimiterMiddlewear')
 RealTimeProxyRouter = require('./Features/RealTimeProxy/RealTimeProxyRouter')
 InactiveProjectController = require("./Features/InactiveData/InactiveProjectController")
 ContactRouter = require("./Features/Contacts/ContactRouter")
+ReferencesController = require('./Features/References/ReferencesController')
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -46,7 +48,7 @@ module.exports = class Router
 		if !Settings.allowPublicAccess
 			webRouter.all '*', AuthenticationController.requireGlobalLogin
 
-		
+
 		webRouter.get  '/login', UserPagesController.loginPage
 		AuthenticationController.addEndpointToLoginWhitelist '/login'
 
@@ -67,16 +69,18 @@ module.exports = class Router
 		StaticPagesRouter.apply(webRouter, apiRouter)
 		RealTimeProxyRouter.apply(webRouter, apiRouter)
 		ContactRouter.apply(webRouter, apiRouter)
-		
+
 		Modules.applyRouter(webRouter, apiRouter)
 
 
 		if Settings.enableSubscriptions
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalMiddleware.getUserReferalId, ReferalController.bonus
-		
+
 		webRouter.get '/blog', BlogController.getIndexPage
 		webRouter.get '/blog/*', BlogController.getPage
-		
+
+		webRouter.get '/user/activate', UserPagesController.activateAccountPage
+
 		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		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/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
 		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.delete '/user/:user_id/update/*', AuthenticationController.httpAuth, TpdsController.deleteUpdate
-		
+
 		apiRouter.post '/project/:project_id/contents/*', AuthenticationController.httpAuth, TpdsController.updateProjectContents
 		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.post "/project/:Project_id/messages", SecurityManager.requestCanAccessProject, ChatController.sendMessage
-		
+
 		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
 		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
@@ -186,7 +200,7 @@ module.exports = class Router
 
 		apiRouter.get '/status', (req,res)->
 			res.send("websharelatex is up")
-		
+
 
 		webRouter.get '/health_check', HealthCheckController.check
 		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"
 			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
 
-				#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
 						include ./editor/file-tree
 
@@ -76,7 +82,7 @@ block content
 				ng-click="done()"
 			) &times;
 			h3 {{ title }}
-		.modal-body {{ message }}
+		.modal-body(ng-bind-html="message")
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
@@ -95,13 +101,13 @@ block content
 			"paths" : {
 				"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML",
 				"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')}",
 			"waitSeconds": 0,
 			"shim": {
 				"libs/pdf": {
-					deps: ["libs/pdfjs-1.0.1040/compatibility"]
+					deps: ["libs/pdfjs-1.3.91/compatibility"]
 				},
 				"ace/ext-searchbox": {
 					deps: ["ace/ace"]
@@ -120,7 +126,7 @@ block content
 
 	- 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 pdfJsWorkerPath = jsPath+pdfPath+'?fingerprint='+fingerprintedPath
 	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-proportionally="true"
 	initial-size-east="'50%'"
+	minimum-restore-size-east="300"
 )
 	.ui-layout-center
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
@@ -19,6 +20,7 @@ div.full-size(
 			keybindings="settings.mode",
 			font-size="settings.fontSize",
 			auto-complete="settings.autoComplete",
+			spell-check="true",
 			spell-check-language="project.spellCheckLanguage",
 			highlights="onlineUserCursorHighlights[editor.open_doc_id]"
 			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")
 		a(
 			href,
@@ -27,7 +27,8 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 				href,
 				ng-click="startRenamingSelected()",
 				tooltip="#{translate('rename')}",
-				tooltip-placement="bottom"
+				tooltip-placement="bottom",
+				ng-show="multiSelectedCount == 0"
 			)
 				i.fa.fa-pencil
 			a(
@@ -45,26 +46,24 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 		ng-controller="FileTreeRootFolderController",
 		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(
 			droppable="permissions.write"
 			accept=".entity-name"
 			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(
 				entity="entity",
 				permissions="permissions",
@@ -81,27 +80,25 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 				)
 					.entity
 						.entity-name(
-							ng-click="select()"
+							ng-click="select($event)"
 						)
 							//- Just a spacer to align with folders
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.fa-file
 
 							span  {{ entity.name }}
-							
-		
-
 
 script(type='text/ng-template', id='entityListItemTemplate')
 	li(
-		ng-class="{ 'selected': entity.selected }",
+		ng-class="{ 'selected': entity.selected, 'multi-selected': entity.multiSelected }",
 		ng-controller="FileTreeEntityController"
 	)
 		.entity(ng-if="entity.type != 'folder'")
 			.entity-name(
-				ng-click="select()"
+				ng-click="select($event)"
 				ng-dblclick="permissions.write && startRenaming()"
 				draggable="permissions.write"
+				draggable-helper="draggableHelper"
 				context-menu
 				data-target="context-menu-{{ entity.id }}"
 				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-image(ng-if="entity.type == 'file'")
-
 				span(
 					ng-hide="entity.renaming"
 				)  {{ entity.name }}
@@ -126,12 +122,11 @@ script(type='text/ng-template', id='entityListItemTemplate')
 						on-enter="finishRenaming()"
 					)
 
-				span.dropdown(
+				span.dropdown.entity-menu-toggle(
 					dropdown,
-					ng-show="entity.selected",
 					ng-if="permissions.write"
 				)
-					a.dropdown-toggle(href, dropdown-toggle)
+					a.dropdown-toggle(href, dropdown-toggle, stop-propagation="click")
 						i.fa.fa-chevron-down
 
 					ul.dropdown-menu.dropdown-menu-right
@@ -140,12 +135,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 								href
 								ng-click="startRenaming()"
 								right-click="startRenaming()"
+								ng-show="!entity.multiSelected"
 							) #{translate("rename")}
 						li
 							a(
 								href
 								ng-click="openDeleteModal()"
 								right-click="openDeleteModal()"
+								stop-propagation="click"
 							) #{translate("delete")}
 
 				div.dropdown.context-menu(
@@ -158,20 +155,23 @@ script(type='text/ng-template', id='entityListItemTemplate')
 								href
 								ng-click="startRenaming()"
 								right-click="startRenaming()"
+								ng-show="!entity.multiSelected"
 							) #{translate("rename")}
 						li
 							a(
 								href
 								ng-click="openDeleteModal()"
 								right-click="openDeleteModal()"
+								stop-propagation="click"
 							) #{translate("delete")}
 
 
 		.entity(ng-if="entity.type == 'folder'", ng-controller="FileTreeFolderController")
 			.entity-name(
-				ng-click="select()"
+				ng-click="select($event)"
 				ng-dblclick="permissions.write && startRenaming()"
 				draggable="permissions.write"
+				draggable-helper="draggableHelper"
 				droppable="permissions.write"
 				accept=".entity-name"
 				on-drop-callback="onDrop"
@@ -194,7 +194,7 @@ script(type='text/ng-template', id='entityListItemTemplate')
 							'fa-folder': !expanded, \
 							'fa-folder-open': expanded \
 						}"
-						ng-click="select()"
+						ng-click="select($event)"
 					)
 
 					span(
@@ -210,12 +210,11 @@ script(type='text/ng-template', id='entityListItemTemplate')
 							on-enter="finishRenaming()"
 						)
 
-					span.dropdown(
+					span.dropdown.entity-menu-toggle(
 						dropdown,
 						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
 
 						ul.dropdown-menu.dropdown-menu-right
@@ -224,12 +223,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 									href
 									ng-click="startRenaming()"
 									right-click="startRenaming()"
+									ng-show="!entity.multiSelected"
 								) #{translate("rename")}
 							li
 								a(
 									href
 									ng-click="openDeleteModal()"
 									right-click="openDeleteModal()"
+									stop-propagation="click"
 								) #{translate("delete")}
 							li.divider
 							li
@@ -261,12 +262,14 @@ script(type='text/ng-template', id='entityListItemTemplate')
 									href
 									ng-click="startRenaming()"
 									right-click="startRenaming()"
+									ng-show="!entity.multiSelected"
 								) #{translate("rename")}
 							li
 								a(
 									href
 									ng-click="openDeleteModal()"
 									right-click="openDeleteModal()"
+									stop-propagation="click"
 								) #{translate("delete")}
 							li.divider
 							li
@@ -306,6 +309,7 @@ script(type='text/ng-template', id='newDocModalTemplate')
 		h3 #{translate("new_file")}
 	.modal-body
 		form(novalidate, name="newDocForm")
+			div.alert.alert-danger(ng-if="error") {{error}}
 			input.form-control(
 				type="text",
 				placeholder="File Name",
@@ -330,6 +334,7 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 	.modal-header
 		h3 #{translate("new_folder")}
 	.modal-body
+		div.alert.alert-danger(ng-if="error") {{error}}
 		form(novalidate, name="newFolderForm")
 			input.form-control(
 				type="text",
@@ -354,9 +359,20 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 script(type="text/ng-template", id="uploadFileModalTemplate")
 	.modal-header
 		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(
 		fine-upload
@@ -367,10 +383,14 @@ script(type="text/ng-template", id="uploadFileModalTemplate")
 		drag-area-text="{{drag_files}}"
 		hint-text="{{hint_press_and_hold_control_key}}"
 		multiple="true"
+		auto-upload="false"
 		on-complete-callback="onComplete"
 		on-upload-callback="onUpload"
 		on-validate-batch="onValidateBatch"
 		on-error-callback="onError"
+		on-submit-callback="onSubmit"
+		on-cancel-callback="onCancel"
+		control="control"
 		params="{'folder_id': parent_folder_id}"
 	)
 		span #{translate("upload_files")}
@@ -382,6 +402,8 @@ script(type='text/ng-template', id='deleteEntityModalTemplate')
 		h3 #{translate("delete")} {{ entity.name }}
 	.modal-body
 		p !{translate("sure_you_want_to_delete")}
+		ul
+			li(ng-repeat="entity in entities") {{entity.name}}
 	.modal-footer
 		button.btn.btn-default(
 			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
 					span.combination {{ctrl}} + A
 					span.description Select All
-			.col-xs-6
 				.hotkey
 					span.combination Tab
 					span.description Indent Selection
+			.col-xs-6
 				.hotkey
-					span.combination {{ctrl}} + U
+					span.combination Ctrl + U
 					span.description To Uppercase
 				.hotkey
 					span.combination Ctrl + Shift + U
 					span.description To Lowercase
+				.hotkey
+					span.combination {{ctrl}} + B
+					span.description Bold text
+				.hotkey
+					span.combination {{ctrl}} + I
+					span.description Italic Text
 	.modal-footer
 		button.btn.btn-default(
 			ng-click="cancel()"

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

@@ -1,16 +1,34 @@
 div.full-size.pdf(ng-controller="PdfController")
 	.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(
 			href
 			ng-click="toggleLogs()"
@@ -94,41 +112,43 @@ div.full-size.pdf(ng-controller="PdfController")
 					| #{translate("learn_how_to_make_documents_compile_quickly")}
 
 		.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;
 						| #{translate("unlimited_projects")}
 					
-					h4
+					li
 						i.fa.fa-check &nbsp;
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 					
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						| #{translate("full_doc_history")}
 					
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_dropbox")}
 
-					h4 
+					li
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_github")}
 
-					h4  
+					li
 						i.fa.fa-check &nbsp;
 						|#{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")
 		.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()"
 		) &times;
 		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"
 								ng-mousedown="addMembers()"
 							) #{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(
 							href
 							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")}
 		button.btn.btn-primary(
 			ng-click="done()"
-		) #{translate("done")}
+		) #{translate("close")}
 
 script(type="text/ng-template", id="makePublicModalTemplate")
 	.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'")
 	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;
 						| #{translate("unlimited_projects")}
 					
-					h4
+					li
 						i.fa.fa-check &nbsp;
 						| #{translate("collabs_per_proj", {collabcount:'Multiple'})}
 					
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						| #{translate("full_doc_history")}
 					
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_dropbox")}
 
-					h4 
+					li 
 						i.fa.fa-check &nbsp;
 						| #{translate("sync_to_github")}
 
-					h4  
+					li  
 						i.fa.fa-check &nbsp;
 						|#{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(
 						href
 						ng-class="buttonClass"
-						ng-click="startFreeTrial('track-changes', 'cf3yutfzu7ztxz')"
+						ng-click="startFreeTrial('track-changes')"
 					) #{translate("start_free_trial")}
 
 

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

@@ -9,7 +9,8 @@ block content
 	script(type="text/javascript").
 		window.data = {
 			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 = {
 			institutions: {
@@ -20,12 +21,14 @@ block content
 
 	.content.content-alt(ng-controller="ProjectPageController")
 		.container
+				
 			.row(ng-cloak)
 				span(ng-show="first_sign_up == 'default' || projects.length > 0")
 					aside.col-md-2.col-xs-3
 						include ./list/side-bar
 
 					.col-md-10.col-xs-9
+						include ./list/notifications
 						include ./list/project-list
 				
 				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"
 			)
 	.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
 		//- 'move to folder' menu.
 		button.btn.btn-default(
@@ -25,10 +27,67 @@ script(type='text/ng-template', id='newTagModalTemplate')
 			stop-propagation="click"
 		) #{translate("cancel")}
 		button.btn.btn-primary(
-			ng-disabled="newTagForm.$invalid"
+			ng-disabled="newTagForm.$invalid || state.inflight"
 			ng-click="create()"
 			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')
 	.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(
-							ng-repeat="tag in tags | filter:nonEmpty | orderBy:'name'",
+							ng-repeat="tag in tags | orderBy:'name'",
 							ng-controller="TagDropdownItemController"
 						)
 							a(href="#", ng-click="addOrRemoveProjectsFromTag()", stop-propagation="click")
@@ -79,7 +79,7 @@
 						href='#',
 						data-toggle="dropdown",
 						dropdown-toggle
-					) #{translate("more")}
+					) #{translate("more")} 
 						span.caret
 					ul.dropdown-menu.dropdown-menu-right(role="menu")
 						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(
 		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
 			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-cloak
+			ng-cloak,
+			ng-click="selectTag(tag)"
 		)
-			a.tag(href, ng-click="selectTag(tag)")
+			a.tag-name(href)
 				i.icon.fa.fa-fw(
 					ng-class="{\
 						'fa-folder-open-o': tag.selected,\
@@ -61,6 +62,23 @@
 				)
 				span.name {{tag.name}} 
 					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)
 			a.tag(href, ng-click="openNewTagModal()")
 				i.fa.fa-fw.fa-plus
@@ -102,41 +120,51 @@
 					) #{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
 				hr
 				p.small #{translate("on_free_sl")}
 				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
 					| #{translate("or_unlock_features_bonus")} 
 					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
-						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
 						| #{translate("or_unlock_features_bonus")} 
 						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.
 	window.userHasSubscription = #{settings.enableSubscriptions && !hasSubscription}
 

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

@@ -32,8 +32,8 @@
 				'EBCallBackMessageReceived',
 				// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
 				'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'
 			],
 			ignoreUrls: [
@@ -57,7 +57,7 @@
 			],
 			shouldSendCallback: function(data) {
 				// only send a fraction of errors
-				var sampleRate = 1.00;
+				var sampleRate = 0.01;
 				return (Math.random() <= sampleRate);
 			},
 			dataCallback: function(data) {

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

@@ -8,7 +8,7 @@ block content
 					.card
 						.page-header
 							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
 							-if(subscription.groupPlan)

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

@@ -6,7 +6,7 @@ block scripts
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
 		window.taxRate = #{taxRate}
-
+		window.subscription = !{JSON.stringify(subscription)}
 
 
 
@@ -36,32 +36,32 @@ mixin printPlans(plans)
 		mixin printPlan(plan)
 
 block content
-	.content.content-alt
-		.container
+	.content.content-alt(ng-cloak)
+		.container(ng-controller="UserSubscriptionController")
 			.row
 				.col-md-8.col-md-offset-2
-					.card
+					.card(ng-if="view == 'overview'")
 						.page-header
 							h1 #{translate("your_subscription")}
 						-if (groups.length != 0)
 							each groupSubscription in groups
 								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)
 							case subscription.state
 								when "free-trial"
 									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
-									p !{translate("choose_a_plan_below")}
+									
 								when "active"
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})} 
 										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.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")}
 										| &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"
 									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</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")}
 
 							-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.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").
 		$('#cancelSubscription').on("click", function() {
 			ga('send', 'event', 'subscription-funnel', 'cancelation')
 		})
 
-
 	script(type='text/ng-template', id='confirmChangePlanModalTemplate')
 		.modal-header
-			h3 Change plan?
+			h3 #{translate("change_plan")}
 		.modal-body
 			p !{translate("sure_you_want_to_change_plan", {planName:"<strong>{{plan.name}}</strong>"})}
 		.modal-footer
@@ -131,4 +164,22 @@ block content
 				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' : ''")
 													select.form-control(data-recurly='year', ng-change="validateExpiry()", ng-model='data.year')
 														option(value="", disabled, selected) Year
-														option(value="2015") 2015
 														option(value="2016") 2016
 														option(value="2017") 2017
 														option(value="2018") 2018

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

@@ -3,6 +3,12 @@ block scripts
 	script(type='text/javascript').
 		window.recomendedCurrency = '#{recomendedCurrency}'
 		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
 	.content-alt
 		.content.plans(ng-controller="PlansController")
@@ -186,30 +192,30 @@ block content
 						.modal-header
 							h3 #{translate("group_plan_enquiry")}
 						.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
 					.col-md-12

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

@@ -5,14 +5,18 @@ block content
 		.container
 			.row
 				.col-md-8.col-md-offset-2
-					.card
+					.card(ng-cloak)
 						.page-header
 							h2 #{translate("thanks_for_subscribing")}
 						.alert.alert-success
 							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
 							- if (subscription.groupPlan == true)
 								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',
 									ng-model="email",
 									ng-model-options="{ updateOn: 'blur' }",
+									ng-init="email = #{JSON.stringify(email)}",
 									focus="true"
 								)
 								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
 						)
 							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
 								input.form-control#passwordField(

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

@@ -106,6 +106,10 @@ module.exports =
 			url: "http://localhost:3036"
 		sixpack:
 			url: ""
+		references:
+			url: "http://localhost:3040"
+		notifications:
+			url: "http://localhost:3042" 
 			
 	templates:
 		user_id: process.env.TEMPLATES_USER_ID or "5395eb7aad1f29a88756c7f2"
@@ -125,6 +129,9 @@ module.exports =
 	# Same, but with http auth credentials.
 	httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
 
+
+	maxEntitiesPerProject: 2000
+	
 	# Security
 	# --------
 	security:
@@ -143,6 +150,8 @@ module.exports =
 		versioning: true
 		compileTimeout: 60
 		compileGroup: "standard"
+		references: true
+		templates: true
 
 	plans: plans = [{
 		planCode: "personal"
@@ -291,7 +300,7 @@ module.exports =
 		title: "ShareLaTeX Community Edition"
 		
 		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: [{

+ 7 - 2
services/web/package.json

@@ -27,18 +27,23 @@
     "http-proxy": "^1.8.1",
     "jade": "~1.3.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#v1.3.1",
+>>>>>>> master
     "lynx": "0.1.1",
     "marked": "^0.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",
     "mocha": "1.17.1",
     "mongojs": "0.18.2",
     "mongoose": "4.1.0",
     "multer": "^0.1.8",
     "node-uuid": "1.4.1",
-    "nodemailer": "0.6.1",
+    "nodemailer": "2.1.0",
+    "nodemailer-ses-transport": "^1.3.0",
     "optimist": "0.6.1",
     "redback": "0.4.0",
     "redis": "0.10.1",

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

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

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

@@ -8,6 +8,7 @@ define [
 	"ide/permissions/PermissionsManager"
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
+	"ide/references/ReferencesManager"
 	"ide/settings/index"
 	"ide/share/index"
 	"ide/chat/index"
@@ -24,6 +25,7 @@ define [
 	"directives/onEnter"
 	"directives/stopPropagation"
 	"directives/rightClick"
+	"services/queued-http"
 	"filters/formatDate"
 	"main/event"
 	"main/account-upgrade"
@@ -37,6 +39,7 @@ define [
 	PermissionsManager
 	PdfManager
 	BinaryFilesManager
+	ReferencesManager
 ) ->
 
 	App.controller "IdeController", ($scope, $timeout, ide, localStorage) ->
@@ -66,12 +69,13 @@ define [
 
 		$scope.chat = {}
 
-		
+
 		window._ide = ide
 
 		ide.project_id = $scope.project_id = window.project_id
 		ide.$scope = $scope
 
+		ide.referencesSearchManager = new ReferencesManager(ide, $scope)
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
 		ide.editorManager = new EditorManager(ide, $scope)
@@ -80,7 +84,7 @@ define [
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
-		
+
 		inited = false
 		$scope.$on "project:joined", () ->
 			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.
 					If the project has been renamed please look in your project list for a new project under the new name.
 				""")
-				
+
 		DARK_THEMES = [
 			"ambiance", "chaos", "clouds_midnight", "cobalt", "idle_fingers",
 			"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
 					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 = () ->
 						state = element.layout().readState()

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

@@ -36,6 +36,7 @@ define [
 			@doc?.detachFromAce()
 			editorDoc = @ace?.getSession().getDocument()
 			editorDoc?.off "change", @_checkConsistency
+			@ide.$scope.$emit 'document:closed', @doc
 
 		_checkConsistency: () ->
 			# We've been seeing a lot of errors when I think there shouldn't be
@@ -116,6 +117,26 @@ define [
 		flush: () ->
 			@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: () ->
 			# returns false if doc has ops waiting to be acknowledged or
 			# 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)
 				callback null, new_sharejs_doc
 
+
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 			sharejs_doc.on "error", (error, meta) =>
 				if error?.message?.match "maxDocLength"
@@ -98,7 +99,7 @@ define [
 					@ide.reportError(error, meta)
 					@ide.showGenericMessageModal(
 						"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)
 

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

@@ -18,7 +18,7 @@ define [
 			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
 			return url
 
-	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage) ->
+	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory) ->
 		monkeyPatchSearch($rootScope, $compile)
 
 		return  {
@@ -29,6 +29,7 @@ define [
 				fontSize: "="
 				autoComplete: "="
 				sharejsDoc: "="
+				spellCheck: "="
 				spellCheckLanguage: "="
 				highlights: "="
 				text: "="
@@ -55,7 +56,9 @@ define [
 				scope.name = attrs.aceEditor
 
 				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)
 				highlightsManager     = new HighlightsManager(scope, editor, element)
 				cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
@@ -69,6 +72,21 @@ define [
 				editor.commands.removeCommand "transposeletters"
 				editor.commands.removeCommand "showSettingsMenu"
 				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
 				editor.commands.addCommand
@@ -77,7 +95,6 @@ define [
 					exec: (editor) ->
 						ace.require("ace/ext/searchbox").Search(editor, true)
 					readOnly: true
-				editor.commands.removeCommand "replace"
 				
 				# Bold text on CMD+B
 				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) ->
 			@suggestionManager = new SuggestionManager()
 
-			@monkeyPatchAutocomplete()	
+			@monkeyPatchAutocomplete()
 
 			@$scope.$watch "autoComplete", (autocomplete) =>
 				if autocomplete
@@ -37,11 +37,47 @@ define [
 				enableSnippets: true,
 				enableLiveAutocompletion: false
 			})
-			
+
 			SnippetCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
 					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: () ->
 			@editor.setOptions({
@@ -83,7 +119,7 @@ define [
 					# since it will be adding in with the autocomplete of \begin{item}...
 					if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
 						editor.session.remove(range)
-					
+
 					Autocomplete::_insertMatch.call this, data
 
 				# 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}}..."
 			snippet: """
 				\\begin{#{env}}
-				$1
+				\t$1
 				\\end{#{env}}
 			"""
 			meta: "env"
@@ -24,8 +24,8 @@ define () ->
 		caption: "\\begin{array}..."
 		snippet: """
 			\\begin{array}{${1:cc}}
-			$2 & $3 \\\\\\\\
-			$4 & $5
+			\t$2 & $3 \\\\\\\\
+			\t$4 & $5
 			\\end{array}
 		"""
 		meta: "env"
@@ -33,10 +33,10 @@ define () ->
 		caption: "\\begin{figure}..."
 		snippet: """
 			\\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}
 		"""
 		meta: "env"
@@ -44,8 +44,8 @@ define () ->
 		caption: "\\begin{tabular}..."
 		snippet: """
 			\\begin{tabular}{${1:c|c}}
-			$2 & $3 \\\\\\\\
-			$4 & $5
+			\t$2 & $3 \\\\\\\\
+			\t$4 & $5
 			\\end{tabular}
 		"""
 		meta: "env"
@@ -53,13 +53,13 @@ define () ->
 		caption: "\\begin{table}..."
 		snippet: """
 			\\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}
 		"""
 		meta: "env"
@@ -67,7 +67,7 @@ define () ->
 		caption: "\\begin{list}..."
 		snippet: """
 			\\begin{list}
-			\\item $1
+			\t\\item $1
 			\\end{list}
 		"""
 		meta: "env"
@@ -75,7 +75,7 @@ define () ->
 		caption: "\\begin{enumerate}..."
 		snippet: """
 			\\begin{enumerate}
-			\\item $1
+			\t\\item $1
 			\\end{enumerate}
 		"""
 		meta: "env"
@@ -83,7 +83,7 @@ define () ->
 		caption: "\\begin{itemize}..."
 		snippet: """
 			\\begin{itemize}
-			\\item $1
+			\t\\item $1
 			\\end{itemize}
 		"""
 		meta: "env"
@@ -91,7 +91,7 @@ define () ->
 		caption: "\\begin{frame}..."
 		snippet: """
 			\\begin{frame}{${1:Frame Title}}
-			$2
+			\t$2
 			\\end{frame}
 		"""
 		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
 
 	class SpellCheckManager
-		constructor: (@$scope, @editor, @element) ->
+		constructor: (@$scope, @editor, @element, @cache) ->
 			$(document.body).append @element.find(".spell-check-menu")
 			
 			@updatedLines = []
@@ -102,6 +102,8 @@ define [
 		learnWord: (highlight) ->
 			@apiRequest "/learn", word: highlight.word
 			@highlightedWordManager.removeWord highlight.word
+			language = @$scope.spellCheckLanguage
+			@cache?.put("#{language}:#{highlight.word}", true)
 
 		getHighlightedWordAtCursor: () ->
 			cursor = @editor.getCursorPosition()
@@ -143,24 +145,67 @@ define [
 		runSpellCheck: (linesToProcess) ->
 			{words, positions} = @getWords(linesToProcess)
 			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?
 					for shouldProcess, row in linesToProcess
 						@highlightedWordManager.clearRows(row, row) if shouldProcess
 				else
 					@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) ->
 			lines = @editor.getValue().split("\n")

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

@@ -19,6 +19,12 @@ define [
 					@recalculateDocList()
 
 			@_bindToSocketEvents()
+			
+			@$scope.multiSelectedCount = 0
+			
+			$(document).on "click", =>
+				@clearMultiSelectedEntities()
+				$scope.$digest()
 
 		_bindToSocketEvents: () ->
 			@ide.socket.on "reciveNewDoc", (parent_folder_id, doc) =>
@@ -65,6 +71,7 @@ define [
 				@$scope.$apply () =>
 					@_deleteEntityFromScope entity
 					@recalculateDocList()
+				@$scope.$emit "entity:deleted", entity
 
 			@ide.socket.on "reciveEntityMove", (entity_id, folder_id) =>
 				entity = @findEntityById(entity_id)
@@ -78,7 +85,62 @@ define [
 			@ide.fileTreeManager.forEachEntity (entity) ->
 				entity.selected = false
 			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: () ->
 			selected = null
 			@forEachEntity (entity) ->
@@ -277,7 +339,7 @@ define [
 		deleteEntity: (entity, callback = (error) ->) ->
 			# We'll wait for the socket.io notification to 
 			# delete from scope.
-			return @ide.$http {
+			return @ide.queuedHttp {
 				method: "DELETE"
 				url:    "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}"
 				headers:
@@ -289,7 +351,7 @@ define [
 			# since that would break the tree structure.
 			return if @_isChildFolder(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
 				_csrf: window.csrfToken
 			}
@@ -316,8 +378,6 @@ define [
 				entity.deleted = true
 				@$scope.deletedDocs.push entity
 
-			@$scope.$emit "entity:deleted", entity
-
 		_moveEntityInScope: (entity, parent_folder) ->
 			return if entity in parent_folder.children
 			@_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
 				ide.fileTreeManager
 					.createDoc(name, parent_folder)
+					.error (e)->
+						$scope.error = e
 					.success () ->
 						$scope.state.inflight = false
 						$modalInstance.close()
@@ -90,6 +92,8 @@ define [
 				$scope.state.inflight = true
 				ide.fileTreeManager
 					.createFolder(name, parent_folder)
+					.error (e)->
+						$scope.error = e
 					.success () ->
 						$scope.state.inflight = false
 						$modalInstance.close()
@@ -99,17 +103,30 @@ define [
 	]
 
 	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.tooManyFiles = 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) ->
 				$timeout (() ->
 					uploadCount--
@@ -127,8 +144,40 @@ define [
 					return true
 
 			$scope.onError = (id, name, reason)->
+				console.log(id, name, reason)
 				if reason.indexOf("429") != -1
 					$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 = () ->
 				$modalInstance.dismiss('cancel')

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

@@ -2,9 +2,23 @@ define [
 	"base"
 ], (App) ->
 	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 =
 			name: $scope.entity.name
@@ -24,10 +38,15 @@ define [
 			$scope.startRenaming() if $scope.entity.selected
 
 		$scope.openDeleteModal = () ->
+			if ide.fileTreeManager.multiSelectedCount() > 0
+				entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
+			else
+				entities = [$scope.entity]
 			$modal.open(
 				templateUrl: "deleteEntityModalTemplate"
 				controller:  "DeleteEntityModalController"
-				scope: $scope
+				resolve:
+					entities: () -> entities
 			)
 
 		$scope.$on "delete:selected", () ->
@@ -35,18 +54,18 @@ define [
 	]
 
 	App.controller "DeleteEntityModalController", [
-		"$scope", "ide", "$modalInstance",
-		($scope,   ide,   $modalInstance) ->
+		"$scope", "ide", "$modalInstance", "entities"
+		($scope,   ide,   $modalInstance, entities) ->
 			$scope.state =
 				inflight: false
+				
+			$scope.entities = entities
 
 			$scope.delete = () ->
 				$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 = () ->
 				$modalInstance.dismiss('cancel')

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