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

Merge remote-tracking branch 'origin/master' into afc-email-tokens

Alberto Fernández Capel 8 лет назад
Родитель
Сommit
25d7196570
67 измененных файлов с 1204 добавлено и 410 удалено
  1. 2 2
      services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee
  2. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee
  3. 1 1
      services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee
  4. 16 9
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
  5. 127 0
      services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
  6. 7 1
      services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
  7. 1 1
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  8. 29 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  9. 31 28
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  10. 2 0
      services/web/app/coffee/Features/References/ReferencesHandler.coffee
  11. 3 3
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  12. 80 0
      services/web/app/coffee/Features/Templates/TemplatesController.coffee
  13. 8 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear
  14. 9 0
      services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee
  15. 10 0
      services/web/app/coffee/Features/Templates/TemplatesRouter.coffee
  16. 2 2
      services/web/app/coffee/Features/User/UserController.coffee
  17. 0 9
      services/web/app/coffee/Features/User/UserCreator.coffee
  18. 10 0
      services/web/app/coffee/Features/User/UserGetter.coffee
  19. 0 21
      services/web/app/coffee/Features/User/UserLocator.coffee
  20. 1 2
      services/web/app/coffee/Features/User/UserPagesController.coffee
  21. 2 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  22. 2 2
      services/web/app/coffee/Features/User/UserUpdater.coffee
  23. 20 5
      services/web/app/coffee/infrastructure/FileWriter.coffee
  24. 2 1
      services/web/app/coffee/infrastructure/Modules.coffee
  25. 8 2
      services/web/app/coffee/router.coffee
  26. 0 67
      services/web/app/views/contact-us-modal.pug
  27. 1 1
      services/web/app/views/layout.pug
  28. 16 1
      services/web/app/views/project/editor/binary-file.pug
  29. 2 4
      services/web/app/views/project/editor/editor.pug
  30. 71 0
      services/web/app/views/project/editor/file-tree.pug
  31. 1 1
      services/web/app/views/project/editor/history/diffPanelV1.pug
  32. 18 0
      services/web/app/views/project/editor/left-menu.pug
  33. 26 0
      services/web/app/views/project/editor/new_from_template.pug
  34. 39 2
      services/web/app/views/project/list/item.pug
  35. 8 2
      services/web/app/views/project/list/project-list.pug
  36. 2 2
      services/web/app/views/project/list/v1-item.pug
  37. 2 2
      services/web/config/settings.defaults.coffee
  38. 1 0
      services/web/docker-compose.yml
  39. 3 1
      services/web/public/coffee/ide.coffee
  40. 24 7
      services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
  41. 1 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  42. 124 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  43. 13 14
      services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee
  44. 16 0
      services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
  45. 3 0
      services/web/public/coffee/ide/test-controls/index.coffee
  46. 0 72
      services/web/public/coffee/main/contact-us.coffee
  47. 32 13
      services/web/public/coffee/main/project-list/project-list.coffee
  48. 2 2
      services/web/public/coffee/services/algolia-search.coffee
  49. 20 0
      services/web/public/coffee/services/wait-for.coffee
  50. 10 0
      services/web/public/stylesheets/app/project-list.less
  51. 6 1
      services/web/public/stylesheets/components/forms.less
  52. 120 4
      services/web/test/acceptance/coffee/LinkedFilesTests.coffee
  53. 12 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  54. 5 5
      services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee
  55. 9 6
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee
  56. 9 9
      services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee
  57. 4 4
      services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
  58. 58 1
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  59. 17 20
      services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  60. 76 0
      services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
  61. 3 3
      services/web/test/unit/coffee/User/UserControllerTests.coffee
  62. 3 21
      services/web/test/unit/coffee/User/UserCreatorTests.coffee
  63. 58 0
      services/web/test/unit/coffee/User/UserGetterTests.coffee
  64. 0 39
      services/web/test/unit/coffee/User/UserLocatorTests.coffee
  65. 4 5
      services/web/test/unit/coffee/User/UserPagesControllerTests.coffee
  66. 6 4
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  67. 5 5
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee

+ 2 - 2
services/web/app/coffee/Features/BetaProgram/BetaProgramController.coffee

@@ -1,5 +1,5 @@
 BetaProgramHandler = require './BetaProgramHandler'
-UserLocator = require "../User/UserLocator"
+UserGetter = require "../User/UserGetter"
 Settings = require "settings-sharelatex"
 logger = require 'logger-sharelatex'
 AuthenticationController = require '../Authentication/AuthenticationController'
@@ -30,7 +30,7 @@ module.exports = BetaProgramController =
 	optInPage: (req, res, next)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {user_id}, "showing beta participation page for user"
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			if err
 				logger.err {err, user_id}, "error fetching user"
 				return next(err)

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee

@@ -27,7 +27,7 @@ module.exports = CollaboratorsInviteController =
 	_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
 		if Settings.restrictInvitesToExistingAccounts == true
 			logger.log {email}, "checking if user exists with this email"
-			UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
+			UserGetter.getUserByMainEmail email, {_id: 1}, (err, user) ->
 				return callback(err) if err?
 				userExists = user? and user?._id?
 				callback(null, userExists)

+ 1 - 1
services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee

@@ -32,7 +32,7 @@ module.exports = CollaboratorsInviteHandler =
 
 	_trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) ->
 		email = invite.email
-		UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) ->
+		UserGetter.getUserByMainEmail email, {_id: 1}, (err, existingUser) ->
 			if err?
 				logger.err {projectId, email}, "error checking if user exists"
 				return callback(err)

+ 16 - 9
services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee

@@ -5,7 +5,8 @@ logger = require 'logger-sharelatex'
 
 module.exports = LinkedFilesController = {
 	Agents: {
-		url: require('./UrlAgent')
+		url: require('./UrlAgent'),
+		project_file: require('./ProjectFileAgent')
 	}
 
 	createLinkedFile: (req, res, next) ->
@@ -22,11 +23,17 @@ module.exports = LinkedFilesController = {
 
 		linkedFileData = Agent.sanitizeData(data)
 		linkedFileData.provider = provider
-		Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
-			if error?
-				logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
-				return Agent.handleError(error, req, res, next)
-			EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error) ->
-				return next(error) if error?
-				res.send(204) # created
-}
+		Agent.checkAuth project_id, linkedFileData, user_id, (err, allowed) ->
+			return Agent.handleError(err, req, res, next) if err?
+			return res.sendStatus(403) if !allowed
+			Agent.decorateLinkedFileData linkedFileData, (err, newLinkedFileData) ->
+				return Agent.handleError(err) if err?
+				linkedFileData = newLinkedFileData
+				Agent.writeIncomingFileToDisk project_id, linkedFileData, user_id, (error, fsPath) ->
+					if error?
+						logger.error {err: error, project_id, name, linkedFileData, parent_folder_id, user_id}, 'error writing linked file to disk'
+						return Agent.handleError(error, req, res, next)
+					EditorController.upsertFile project_id, parent_folder_id, name, fsPath, linkedFileData, "upload", user_id, (error, file) ->
+						return next(error) if error?
+						res.json(new_file_id: file._id) # created
+	}

+ 127 - 0
services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee

@@ -0,0 +1,127 @@
+FileWriter = require('../../infrastructure/FileWriter')
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectLocator = require('../Project/ProjectLocator')
+ProjectGetter = require('../Project/ProjectGetter')
+DocstoreManager = require('../Docstore/DocstoreManager')
+FileStoreHandler = require('../FileStore/FileStoreHandler')
+FileWriter = require('../../infrastructure/FileWriter')
+_ = require "underscore"
+Settings = require 'settings-sharelatex'
+
+
+AccessDeniedError = (message) ->
+	error = new Error(message)
+	error.name = 'AccessDenied'
+	error.__proto__ = AccessDeniedError.prototype
+	return error
+AccessDeniedError.prototype.__proto__ = Error.prototype
+
+
+BadEntityTypeError = (message) ->
+	error = new Error(message)
+	error.name = 'BadEntityType'
+	error.__proto__ = BadEntityTypeError.prototype
+	return error
+BadEntityTypeError.prototype.__proto__ = Error.prototype
+
+
+BadDataError = (message) ->
+	error = new Error(message)
+	error.name = 'BadData'
+	error.__proto__ = BadDataError.prototype
+	return error
+BadDataError.prototype.__proto__ = Error.prototype
+
+
+ProjectNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'ProjectNotFound'
+	error.__proto__ = ProjectNotFoundError.prototype
+	return error
+ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'BadData'
+	error.__proto__ = SourceFileNotFoundError.prototype
+	return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = ProjectFileAgent =
+
+	sanitizeData: (data) ->
+		return _.pick(
+			data,
+			'source_project_id',
+			'source_entity_path'
+		)
+
+	_validate: (data) ->
+		return (
+			data.source_project_id? &&
+			data.source_entity_path?
+		)
+
+	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+		callback = _.once(callback)
+		{ source_project_id } = data
+		return callback(new BadDataError()) if !source_project_id?
+		ProjectGetter.getProject source_project_id, (err, project) ->
+			return callback(err) if err?
+			return callback(new ProjectNotFoundError()) if !project?
+			callback(err, _.extend(data, {source_project_display_name: project.name}))
+
+	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+		callback = _.once(callback)
+		if !ProjectFileAgent._validate(data)
+			return callback(new BadDataError())
+		{source_project_id, source_entity_path} = data
+		AuthorizationManager.canUserReadProject current_user_id, source_project_id, null, (err, canRead) ->
+			return callback(err) if err?
+			callback(null, canRead)
+
+	writeIncomingFileToDisk:
+		(project_id, data, current_user_id, callback = (error, fsPath) ->) ->
+			callback = _.once(callback)
+			if !ProjectFileAgent._validate(data)
+				return callback(new BadDataError())
+			{source_project_id, source_entity_path} = data
+			ProjectLocator.findElementByPath {
+				project_id: source_project_id,
+				path: source_entity_path
+			}, (err, entity, type) ->
+				if err?
+					if err.toString().match(/^not found.*/)
+						err = new SourceFileNotFoundError()
+					return callback(err)
+				ProjectFileAgent._writeEntityToDisk source_project_id, entity._id, type, callback
+
+	_writeEntityToDisk: (project_id, entity_id, type, callback=(err, location)->) ->
+		callback = _.once(callback)
+		if type == 'doc'
+			DocstoreManager.getDoc project_id, entity_id, (err, lines) ->
+				return callback(err) if err?
+				FileWriter.writeLinesToDisk entity_id, lines, callback
+		else if type == 'file'
+			FileStoreHandler.getFileStream project_id, entity_id, null, (err, fileStream) ->
+				return callback(err) if err?
+				FileWriter.writeStreamToDisk entity_id, fileStream, callback
+		else
+			callback(new BadEntityTypeError())
+
+	handleError: (error, req, res, next) ->
+		if error instanceof AccessDeniedError
+			res.status(403).send("You do not have access to this project")
+		else if error instanceof BadDataError
+			res.status(400).send("The submitted data is not valid")
+		else if error instanceof BadEntityTypeError
+			res.status(400).send("The file is the wrong type")
+		else if error instanceof SourceFileNotFoundError
+			res.status(404).send("Source file not found")
+		else if error instanceof ProjectNotFoundError
+			res.status(404).send("Project not found")
+		else
+			next(error)
+		next()

+ 7 - 1
services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee

@@ -27,6 +27,12 @@ module.exports = UrlAgent = {
 			url: @._prependHttpIfNeeded(data.url)
 		}
 
+	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
+		return callback(null, data)
+
+	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+		callback(null, true)
+
 	writeIncomingFileToDisk: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		url = data.url
@@ -65,4 +71,4 @@ module.exports = UrlAgent = {
 		if !Settings.apis?.linkedUrlProxy?.url?
 			throw new Error('no linked url proxy configured')
 		return "#{Settings.apis.linkedUrlProxy.url}?url=#{encodeURIComponent(url)}"
-}
+}

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

@@ -9,7 +9,7 @@ logger = require("logger-sharelatex")
 module.exports =
 
 	generateAndEmailResetToken:(email, callback = (error, exists) ->)->
-		UserGetter.getUser email:email, (err, user)->
+		UserGetter.getUserByMainEmail email, (err, user)->
 			if err then return callback(err)
 			if !user? or user.holdingAccount
 				logger.err email:email, "user could not be found for password reset"

+ 29 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -25,6 +25,7 @@ Sources = require "../Authorization/Sources"
 TokenAccessHandler = require '../TokenAccess/TokenAccessHandler'
 CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
 Modules = require '../../infrastructure/Modules'
+ProjectEntityHandler = require './ProjectEntityHandler'
 crypto = require 'crypto'
 
 module.exports = ProjectController =
@@ -138,6 +139,33 @@ module.exports = ProjectController =
 			return next(err) if err?
 			res.sendStatus 200
 
+	userProjectsJson: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		ProjectGetter.findAllUsersProjects user_id,
+			'name lastUpdated publicAccesLevel archived owner_ref tokens', (err, projects) ->
+				return next(err) if err?
+				projects = ProjectController._buildProjectList(projects)
+					.filter((p) -> !p.archived)
+					.filter((p) -> !p.isV1Project)
+					.map((p) -> {_id: p.id, name: p.name, accessLevel: p.accessLevel})
+
+				res.json({projects: projects})
+
+	projectEntitiesJson: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		project_id = req.params.Project_id
+		ProjectGetter.getProject project_id, (err, project) ->
+			return next(err) if err?
+			ProjectEntityHandler.getAllEntitiesFromProject project, (err, docs, files) ->
+				return next(err) if err?
+				entities = docs.concat(files)
+					.sort (a, b) -> a.path > b.path  # Sort by path ascending
+					.map (e) -> {
+						path: e.path,
+						type: if e.doc? then 'doc' else 'file'
+					}
+				res.json({project_id: project_id, entities: entities})
+
 	projectListPage: (req, res, next)->
 		timer = new metrics.Timer("project-list")
 		user_id = AuthenticationController.getLoggedInUserId(req)
@@ -313,6 +341,7 @@ module.exports = ProjectController =
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
 					showRichText: req.query?.rt == 'true'
+					showTestControls: req.query?.tc == 'true' || user.isAdmin
 					showPublishModal: req.query?.pm == 'true'
 				timer.done()
 

+ 31 - 28
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -45,37 +45,40 @@ wrapWithLock = (methodWithoutLock) ->
 		methodWithLock
 
 module.exports = ProjectEntityUpdateHandler = self =
-	# this doesn't need any locking because it's only called by ProjectDuplicator
-	copyFileFromExistingProjectWithProject: (project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
-		project_id = project._id
-		projectHistoryId = project.overleaf?.history?.id
-		logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
-		return callback(err) if err?
-		ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id)=>
-			if !origonalFileRef?
-				logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
-				return callback()
-			# convert any invalid characters in original file to '_'
-			fileRef = new File name : SafePath.clean(origonalFileRef.name)
-			FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
+	copyFileFromExistingProjectWithProject: wrapWithLock
+		beforeLock: (next) ->
+			(project, folder_id, originalProject_id, origonalFileRef, userId, callback = (error, fileRef, folder_id) ->)->
+				project_id = project._id
+				logger.log { project_id, folder_id, originalProject_id, origonalFileRef }, "copying file in s3 with project"
+				ProjectEntityMongoUpdateHandler._confirmFolder project, folder_id, (folder_id) ->
+					if !origonalFileRef?
+						logger.err { project_id, folder_id, originalProject_id, origonalFileRef }, "file trying to copy is null"
+						return callback()
+					# convert any invalid characters in original file to '_'
+					fileRef = new File name : SafePath.clean(origonalFileRef.name)
+					FileStoreHandler.copyFile originalProject_id, origonalFileRef._id, project._id, fileRef._id, (err, fileStoreUrl)->
+						if err?
+							logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
+							return callback(err)
+						next(project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback)
+		withLock: (project, folder_id, originalProject_id, origonalFileRef, userId, fileRef, fileStoreUrl, callback = (error, fileRef, folder_id) ->)->
+			project_id = project._id
+			projectHistoryId = project.overleaf?.history?.id
+			ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result, newProject) ->
 				if err?
-					logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error coping file in s3"
+					logger.err { err, project_id, folder_id }, "error putting element as part of copy"
 					return callback(err)
-				ProjectEntityMongoUpdateHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+				TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
 					if err?
-						logger.err { err, project_id, folder_id }, "error putting element as part of copy"
-						return callback(err)
-					TpdsUpdateSender.addFile { project_id, file_id:fileRef._id, path:result?.path?.fileSystem, rev:fileRef.rev, project_name:project.name}, (err) ->
-						if err?
-							logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
-						newFiles = [
-							file: fileRef
-							path: result?.path?.fileSystem
-							url: fileStoreUrl
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles}, (error) ->
-							return callback(error) if error?
-							callback null, fileRef, folder_id
+						logger.err { err, project_id, folder_id, originalProject_id, origonalFileRef }, "error sending file to tpds worker"
+					newFiles = [
+						file: fileRef
+						path: result?.path?.fileSystem
+						url: fileStoreUrl
+					]
+					DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, {newFiles, newProject}, (error) ->
+						return callback(error) if error?
+						callback null, fileRef, folder_id
 
 	updateDocLines: (project_id, doc_id, lines, version, ranges, callback = (error) ->)->
 		ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->

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

@@ -10,6 +10,8 @@ Async = require('async')
 oneMinInMs = 60 * 1000
 fiveMinsInMs = oneMinInMs * 5
 
+if !settings.apis?.references?.url?
+	logger.log "references search not enabled"
 
 module.exports = ReferencesHandler =
 

+ 3 - 3
services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee

@@ -2,7 +2,7 @@ async = require("async")
 _ = require("underscore")
 SubscriptionUpdater = require("./SubscriptionUpdater")
 SubscriptionLocator = require("./SubscriptionLocator")
-UserLocator = require("../User/UserLocator")
+UserGetter = require("../User/UserGetter")
 LimitationsManager = require("./LimitationsManager")
 logger = require("logger-sharelatex")
 OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
@@ -22,7 +22,7 @@ module.exports = SubscriptionGroupHandler =
 			if limitReached
 				logger.err adminUserId:adminUserId, newEmail:newEmail, "group subscription limit reached not adding user to group"
 				return callback(limitReached:limitReached)
-			UserLocator.findByEmail newEmail, (err, user)->
+			UserGetter.getUserByMainEmail newEmail, (err, user)->
 				return callback(err) if err?
 				if user?
 					SubscriptionUpdater.addUserToGroup adminUserId, user._id, (err)->
@@ -52,7 +52,7 @@ module.exports = SubscriptionGroupHandler =
 
 			jobs = _.map subscription.member_ids, (user_id)->
 				return (cb)->
-					UserLocator.findById user_id, (err, user)->
+					UserGetter.getUser user_id, (err, user)->
 						if err? or !user?
 							users.push _id:user_id
 							return cb()

+ 80 - 0
services/web/app/coffee/Features/Templates/TemplatesController.coffee

@@ -0,0 +1,80 @@
+path = require('path')
+Project = require('../../../js/models/Project').Project
+ProjectUploadManager = require('../../../js/Features/Uploads/ProjectUploadManager')
+ProjectOptionsHandler = require('../../../js/Features/Project/ProjectOptionsHandler')
+AuthenticationController = require('../../../js/Features/Authentication/AuthenticationController')
+settings = require('settings-sharelatex')
+fs = require('fs')
+request = require('request')
+uuid = require('uuid')
+logger = require('logger-sharelatex')
+async = require("async")
+
+
+module.exports = TemplatesController =
+
+	getV1Template: (req, res)->
+		templateVersionId = req.params.Template_version_id
+		templateId = req.query.id
+		if !/^[0-9]+$/.test(templateVersionId) || !/^[0-9]+$/.test(templateId)
+			logger.err templateVersionId:templateVersionId, templateId: templateId, "invalid template id or version"
+			return res.sendStatus 400
+		data = {}
+		data.templateVersionId = templateVersionId
+		data.templateId = templateId
+		data.name = req.query.templateName
+		data.compiler = req.query.latexEngine
+		res.render path.resolve(__dirname, "../../../views/project/editor/new_from_template"), data
+
+	createProjectFromV1Template: (req, res)->
+		currentUserId = AuthenticationController.getLoggedInUserId(req)
+		zipUrl =	"#{settings.apis.v1.url}/api/v1/sharelatex/templates/#{req.body.templateVersionId}"
+		zipReq = request(zipUrl, {
+			'auth': {
+				'user': settings.apis.v1.user,
+				'pass': settings.apis.v1.pass
+			}
+		})
+
+		TemplatesController.createFromZip(
+			zipReq,
+			{
+				templateName: req.body.templateName,
+				currentUserId: currentUserId,
+				compiler: req.body.compiler
+				docId: req.body.docId
+				templateId: req.body.templateId
+				templateVersionId: req.body.templateVersionId
+			},
+			req,
+			res
+		)
+
+	createFromZip: (zipReq, options, req, res)->
+		dumpPath = "#{settings.path.dumpFolder}/#{uuid.v4()}"
+		writeStream = fs.createWriteStream(dumpPath)
+
+		zipReq.on "error", (error) ->
+			logger.error err: error, "error getting zip from template API"
+		zipReq.pipe(writeStream)
+		writeStream.on 'close', ->
+			ProjectUploadManager.createProjectFromZipArchive options.currentUserId, options.templateName, dumpPath, (err, project)->
+				if err?
+					logger.err err:err, zipReq:zipReq, "problem building project from zip"
+					return res.sendStatus 500
+				setCompiler project._id, options.compiler, ->
+					fs.unlink dumpPath, ->
+					delete req.session.templateData
+					conditions = {_id:project._id}
+					update = {
+						fromV1TemplateId:options.templateId,
+						fromV1TemplateVersionId:options.templateVersionId
+					}
+					Project.update conditions, update, {}, (err)->
+						res.redirect "/project/#{project._id}"
+
+setCompiler = (project_id, compiler, callback)->
+	if compiler?
+		ProjectOptionsHandler.setCompiler project_id, compiler, callback
+	else
+		callback()

+ 8 - 0
services/web/app/coffee/Features/Templates/TemplatesMiddlewear

@@ -0,0 +1,8 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+module.exports =
+	saveTemplateDataInSession: (req, res, next)->
+		if req.query.templateName
+			req.session.templateData = req.query
+		next()

+ 9 - 0
services/web/app/coffee/Features/Templates/TemplatesMiddlewear.coffee

@@ -0,0 +1,9 @@
+settings = require("settings-sharelatex")
+logger = require("logger-sharelatex")
+
+
+module.exports =
+	saveTemplateDataInSession: (req, res, next)->
+		if req.query.templateName
+			req.session.templateData = req.query
+		next()

+ 10 - 0
services/web/app/coffee/Features/Templates/TemplatesRouter.coffee

@@ -0,0 +1,10 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+TemplatesController = require("./TemplatesController")
+TemplatesMiddlewear = require('./TemplatesMiddlewear')
+
+module.exports = 
+	apply: (app)->
+
+		app.get '/project/new/template/:Template_version_id', TemplatesMiddlewear.saveTemplateDataInSession, AuthenticationController.requireLogin(), TemplatesController.getV1Template
+
+		app.post '/project/new/template', AuthenticationController.requireLogin(), TemplatesController.createProjectFromV1Template

+ 2 - 2
services/web/app/coffee/Features/User/UserController.coffee

@@ -1,6 +1,6 @@
 UserHandler = require("./UserHandler")
 UserDeleter = require("./UserDeleter")
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 User = require("../../models/User").User
 newsLetterManager = require('../Newsletter/NewsletterManager')
 UserRegistrationHandler = require("./UserRegistrationHandler")
@@ -45,7 +45,7 @@ module.exports = UserController =
 
 	unsubscribe: (req, res)->
 		user_id = AuthenticationController.getLoggedInUserId(req)
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			newsLetterManager.unsubscribe user, ->
 				res.send()
 

+ 0 - 9
services/web/app/coffee/Features/User/UserCreator.coffee

@@ -1,19 +1,10 @@
 User = require("../../models/User").User
-UserLocator = require("./UserLocator")
 logger = require("logger-sharelatex")
 metrics = require('metrics-sharelatex')
 
 
 module.exports = UserCreator =
 
-	getUserOrCreateHoldingAccount: (email, callback = (err, user)->)->
-		self = @
-		UserLocator.findByEmail email, (err, user)->
-			if user?
-				callback(err, user)
-			else
-				self.createNewUser email:email, holdingAccount:true, callback
-
 	createNewUser: (opts, callback)->
 		logger.log opts:opts, "creating new user"
 		user = new User()

+ 10 - 0
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -6,6 +6,8 @@ ObjectId = mongojs.ObjectId
 
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
+		if query?.email?
+			return callback(new Error("Don't use getUser to find user by email"), null)
 		if arguments.length == 2
 			callback = projection
 			projection = {}
@@ -19,6 +21,13 @@ module.exports = UserGetter =
 
 		db.users.findOne query, projection, callback
 
+	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
+		email = email.trim()
+		if arguments.length == 2
+			callback = projection
+			projection = {}
+		db.users.findOne email: email, projection, callback
+
 	getUsers: (user_ids, projection, callback = (error, users) ->) ->
 		try
 			user_ids = user_ids.map (u) -> ObjectId(u.toString())
@@ -39,6 +48,7 @@ module.exports = UserGetter =
 
 [
 	'getUser',
+	'getUserByMainEmail',
 	'getUsers',
 	'getUserOrUserStubById'
 ].map (method) ->

+ 0 - 21
services/web/app/coffee/Features/User/UserLocator.coffee

@@ -1,21 +0,0 @@
-mongojs = require("../../infrastructure/mongojs")
-metrics = require("metrics-sharelatex")
-db = mongojs.db
-ObjectId = mongojs.ObjectId
-logger = require('logger-sharelatex')
-
-module.exports = UserLocator =
-
-	findByEmail: (email, callback)->
-		email = email.trim()
-		db.users.findOne email:email, (err, user)->
-			callback(err, user)
-
-	findById: (_id, callback)->
-		db.users.findOne _id:ObjectId(_id+""), callback
-
-[
-	'findById',
-	'findByEmail'
-].map (method) ->
-	metrics.timeAsyncMethod UserLocator, method, 'mongo.UserLocator', logger

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

@@ -1,4 +1,3 @@
-UserLocator = require("./UserLocator")
 UserGetter = require("./UserGetter")
 UserSessionsManager = require("./UserSessionsManager")
 ErrorController = require("../Errors/ErrorController")
@@ -61,7 +60,7 @@ module.exports =
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log user: user_id, "loading settings page"
 		shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
-		UserLocator.findById user_id, (err, user)->
+		UserGetter.getUser user_id, (err, user)->
 			return next(err) if err?
 			res.render 'user/settings',
 				title:'account_settings'

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

@@ -1,6 +1,7 @@
 sanitize = require('sanitizer')
 User = require("../../models/User").User
 UserCreator = require("./UserCreator")
+UserGetter = require("./UserGetter")
 AuthenticationManager = require("../Authentication/AuthenticationManager")
 NewsLetterManager = require("../Newsletter/NewsletterManager")
 async = require("async")
@@ -47,7 +48,7 @@ module.exports = UserRegistrationHandler =
 		if !requestIsValid
 			return callback(new Error("request is not valid"))
 		userDetails.email = userDetails.email?.trim()?.toLowerCase()
-		User.findOne email:userDetails.email, (err, user)->
+		UserGetter.getUserByMainEmail userDetails.email, (err, user) =>
 			if err?
 				return callback err
 			if user?.holdingAccount == false

+ 2 - 2
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -3,7 +3,7 @@ mongojs = require("../../infrastructure/mongojs")
 metrics = require("metrics-sharelatex")
 db = mongojs.db
 ObjectId = mongojs.ObjectId
-UserLocator = require("./UserLocator")
+UserGetter = require("./UserGetter")
 
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
@@ -18,7 +18,7 @@ module.exports = UserUpdater =
 	changeEmailAddress: (user_id, newEmail, callback)->
 		self = @
 		logger.log user_id:user_id, newEmail:newEmail, "updaing email address of user"
-		UserLocator.findByEmail newEmail, (error, user) ->
+		UserGetter.getUserByMainEmail newEmail, (error, user) ->
 			if user?
 				return callback({message:"alread_exists"})
 			self.updateUser user_id.toString(), {

+ 20 - 5
services/web/app/coffee/infrastructure/FileWriter.coffee

@@ -6,16 +6,31 @@ Settings = require 'settings-sharelatex'
 request = require 'request'
 
 module.exports = FileWriter =
+
+	_ensureDumpFolderExists: (callback=(error)->) ->
+		fs.mkdir Settings.path.dumpFolder, (error) ->
+			if error? and error.code != 'EEXIST'
+				# Ignore error about already existing
+				return callback(error)
+			callback(null)
+
+	writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+		callback = _.once(callback)
+		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
+		FileWriter._ensureDumpFolderExists (error) ->
+			return callback(error) if error?
+			fs.writeFile fsPath, lines.join('\n'), (error) ->
+				return callback(error) if error?
+				callback(null, fsPath)
+
 	writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
 
 		stream.pause()
-		fs.mkdir Settings.path.dumpFolder, (error) ->
+		FileWriter._ensureDumpFolderExists (error) ->
+			return callback(error) if error?
 			stream.resume()
-			if error? and error.code != 'EEXIST'
-				# Ignore error about already existing
-				return callback(error)
 
 			writeStream = fs.createWriteStream(fsPath)
 			stream.pipe(writeStream)
@@ -39,4 +54,4 @@ module.exports = FileWriter =
 			else
 				err = new Error("bad response from url: #{response.statusCode}")
 				logger.err {err, identifier, url}, err.message
-				callback(err)
+				callback(err)

+ 2 - 1
services/web/app/coffee/infrastructure/Modules.coffee

@@ -30,7 +30,8 @@ module.exports = Modules =
 		for module in @modules
 			for view, partial of module.viewIncludes or {}
 				@viewIncludes[view] ||= []
-				@viewIncludes[view].push pug.compile(fs.readFileSync(Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")), doctype: "html")
+				filePath = Path.join(MODULE_BASE_PATH, module.name, "app/views", partial + ".pug")
+				@viewIncludes[view].push pug.compileFile(filePath, doctype: "html")
 			
 	moduleIncludes: (view, locals) ->
 		compiledPartials = Modules.viewIncludes[view] or []

+ 8 - 2
services/web/app/coffee/router.coffee

@@ -48,6 +48,7 @@ MetaController = require('./Features/Metadata/MetaController')
 TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
 Features = require('./infrastructure/Features')
 LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
+TemplatesRouter = require './Features/Templates/TemplatesRouter'
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -80,10 +81,10 @@ module.exports = class Router
 		ContactRouter.apply(webRouter, privateApiRouter)
 		AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
 		LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
+		TemplatesRouter.apply(webRouter)
 
 		Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
 
-
 		if Settings.enableSubscriptions
 			webRouter.get  '/user/bonus', AuthenticationController.requireLogin(), ReferalController.bonus
 
@@ -119,6 +120,11 @@ module.exports = class Router
 		webRouter.get  '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
 		privateApiRouter.get  '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
 
+		webRouter.get  '/user/projects', AuthenticationController.requireLogin(), ProjectController.userProjectsJson
+		webRouter.get  '/project/:Project_id/entities', AuthenticationController.requireLogin(),
+			AuthorizationMiddlewear.ensureUserCanReadProject,
+			ProjectController.projectEntitiesJson
+
 		webRouter.get  '/project', AuthenticationController.requireLogin(), ProjectController.projectListPage
 		webRouter.post '/project/new', AuthenticationController.requireLogin(), ProjectController.newProject
 
@@ -201,7 +207,7 @@ module.exports = class Router
 		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.get  "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
-		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
 		webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
 		privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory

+ 0 - 67
services/web/app/views/contact-us-modal.pug

@@ -1,67 +0,0 @@
-script(type='text/ng-template', id='supportModalTemplate')
-	.modal-header
-		button.close(
-			type="button"
-			data-dismiss="modal"
-			ng-click="close()"
-		) ×
-		h3 #{translate("contact_us")}
-	.modal-body.contact-us-modal
-		form(name="contactForm")
-			span(ng-show="sent == false")
-				.alert.alert-danger(ng-show="error") Something went wrong sending your request :(
-				label
-					| #{translate("subject")}
-				.form-group
-					input.field.text.medium.span8.form-control(
-						name="subject",
-						required
-						ng-model="form.subject",
-						ng-model-options="{ updateOn: 'default blur', debounce: {'default': 350, 'blur': 0} }"
-						maxlength='255',
-						tabindex='1',
-						onkeyup='')
-				.contact-suggestions(ng-show="suggestions.length")
-					p.contact-suggestion-label !{translate("kb_suggestions_enquiry", { kbLink: "<a href='learn/kb' target='_blank'>" + translate("knowledge_base") + "</a>" })} 
-					ul.contact-suggestion-list
-						li(ng-repeat="suggestion in suggestions")
-							a.contact-suggestion-list-item(ng-href="{{ suggestion.url }}", ng-click="clickSuggestionLink(suggestion.url);" target="_blank")
-								span(ng-bind-html="suggestion.name")
-								i.fa.fa-angle-right
-				label.desc(ng-show="'"+getUserEmail()+"'.length < 1")
-					| #{translate("email")}
-				.form-group(ng-show="'"+getUserEmail()+"'.length < 1")
-					input.field.text.medium.span8.form-control(
-						name="email",
-						required
-						ng-model="form.email", 
-						ng-init="form.email = '"+getUserEmail()+"'",
-						type='email', spellcheck='false',
-						value='',
-						maxlength='255',
-						tabindex='2')
-				label#title12.desc
-					| #{translate("project_url")} (#{translate("optional")})
-				.form-group
-					input.field.text.medium.span8.form-control(ng-model="form.project_url", tabindex='3', onkeyup='')
-				label.desc
-					| #{translate("contact_message_label")}
-				.form-group
-					textarea.field.text.medium.span8.form-control(
-						name="body",
-						required
-						ng-model="form.message",
-						type='text',
-						value='',
-						tabindex='4',
-						onkeyup=''
-					)
-				.form-group.text-center
-					input.btn-success.btn.btn-lg(
-						type='submit',
-						ng-disabled="contactForm.$invalid || sending",
-						ng-click="contactUs()"
-						value=translate("contact_us")
-					)
-		span(ng-show="sent")
-			p #{translate("request_sent_thank_you")}

+ 1 - 1
services/web/app/views/layout.pug

@@ -147,7 +147,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 				src=buildJsPath('libs/require.js', {hashedPath:true})
 			)
 
-		include contact-us-modal
+		!= moduleIncludes("contactModal", locals)
 		include v1-tooltip
 		include sentry
 		

+ 16 - 1
services/web/app/views/project/editor/binary-file.pug

@@ -47,7 +47,18 @@ div.binary-file.full-size(
 				|
 				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
 
-		span(ng-if="openFile.linkedFileData.provider == 'url'")
+		div(ng-if="openFile.linkedFileData.provider == 'project_file'")
+			p
+				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+				| Imported from
+				|
+				a(ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+					| {{ openFile.linkedFileData.source_project_display_name }}
+				| /{{ openFile.linkedFileData.source_entity_path.slice(1) }},
+				|
+				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+		span(ng-if="openFile.linkedFileData.provider == 'url' || openFile.linkedFileData.provider == 'project_file'")
 			button.btn.btn-success(
 				href, ng-click="refreshFile(openFile)",
 				ng-disabled="refreshing"
@@ -63,3 +74,7 @@ div.binary-file.full-size(
 			i.fa.fa-fw.fa-download
 			|
 			| #{translate("download")}
+		div(ng-if="refreshError")
+			br
+			.alert.alert-danger.col-md-6.col-md-offset-3
+				| Error: {{ refreshError}}

+ 2 - 4
services/web/app/views/project/editor/editor.pug

@@ -33,11 +33,11 @@ div.full-size(
 				i.fa.fa-arrow-left
 				| &nbsp;&nbsp;#{translate("open_a_file_on_the_left")}
 
-		!= moduleIncludes('editor:toolbar', locals)
+		!= moduleIncludes('editor:main', locals)
 
 		#editor(
 			ace-editor="editor",
-			ng-if="!editor.richText",
+			ng-if="!editor.showRichText",
 			ng-show="!!editor.sharejs_doc && !editor.opening",
 			style=showRichText ? "top: 32px" : "",
 			theme="settings.theme",
@@ -73,8 +73,6 @@ div.full-size(
 			line-height="settings.lineHeight || ui.defaultLineHeight"
 		)
 
-		!= moduleIncludes('editor:body', locals)
-
 		include ./review-panel
 
 	.ui-layout-east

+ 71 - 0
services/web/app/views/project/editor/file-tree.pug

@@ -342,6 +342,77 @@ script(type='text/ng-template', id='newDocModalTemplate')
 			span(ng-show="state.inflight") #{translate("creating")}...
 
 
+// Project Linked Files Modal
+script(type='text/ng-template', id='projectLinkedFileModalTemplate')
+	.modal-header
+		h3 New file from Project
+
+	.modal-body
+		div
+			div.alert.alert-danger(ng-if="state.error") Error, something went wrong!
+		div
+			form
+				.form-controls
+					label(for="project-select") Select a Project
+					span(ng-show="state.inFlight.projects")
+						| &nbsp;
+						i.fa.fa-spinner.fa-spin
+					select.form-control(
+						name="project-select"
+						ng-model="data.selectedProjectId"
+						ng-disabled="!shouldEnableProjectSelect()"
+					)
+						option(value="" disabled selected) - Please Select a Project
+						option(
+							ng-repeat="project in data.projects"
+							value="{{ project._id }}"
+						) {{ project.name }}
+
+				br
+				.form-controls
+					label(for="project-entity-select") Select a File
+					span(ng-show="state.inFlight.entities")
+						| &nbsp;
+						i.fa.fa-spinner.fa-spin
+					select.form-control(
+						name="project-entity-select"
+						ng-model="data.selectedProjectEntity"
+						ng-disabled="!shouldEnableProjectEntitySelect()"
+					)
+						option(value="" disabled selected) - Please Select a File
+						option(
+							ng-repeat="projectEntity in data.projectEntities"
+							value="{{ projectEntity.path }}"
+						) {{ projectEntity.path.slice(1) }}
+				br
+
+				.form-controls
+					label(for="name") File Name In This Project
+					input.form-control(
+						type="text"
+						placeholder="example.tex"
+						required
+						ng-model="data.name"
+						name="name"
+					)
+				br
+
+	.modal-footer
+		span(ng-show="state.inFlight.create")
+			i.fa.fa-spinner.fa-spin
+			| &nbsp;
+		button.btn.btn-default(
+			ng-disabled="state.inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-primary(
+			ng-disabled="!shouldEnableCreateButton()"
+			ng-click="create()"
+		)
+			span(ng-hide="state.inflight") #{translate("create")}
+			span(ng-show="state.inflight") #{translate("creating")}...
+
+
 script(type='text/ng-template', id='linkedFileModalTemplate')
 	.modal-header
 		h3 New file from URL

+ 1 - 1
services/web/app/views/project/editor/history/diffPanelV1.pug

@@ -13,7 +13,7 @@
 					}"
 				)
 				|  in <strong>{{history.diff.pathname}}</strong>
-			.toolbar-right
+			.toolbar-right(ng-if="permissions.write")
 				a.btn.btn-danger.btn-sm(
 					href,
 					ng-click="openRestoreDiffModal()"

+ 18 - 0
services/web/app/views/project/editor/left-menu.pug

@@ -62,6 +62,23 @@ aside#left-menu.full-size(
 			!= moduleIncludes("editorLeftMenu:editing_services", locals)
 
 
+	if showTestControls
+		h4 Test Controls
+		ul.list-unstyled.nav(ng-controller="TestControlsController")
+			li
+				a(href="#" ng-click="richText()")
+					i.fa.fa-exclamation.fa-fw
+					| Rich Text
+			li
+				a(href="#" ng-click="openProjectLinkedFileModal()")
+					i.fa.fa-exclamation.fa-fw
+					| Project-Linked-File Modal
+			li
+				a(href="#" ng-click="openLinkedFileModal()")
+					i.fa.fa-exclamation.fa-fw
+					| URL-Linked-File Modal
+
+
 	h4(ng-show="!anonymous") #{translate("settings")}
 	form.settings(ng-controller="SettingsController", ng-show="!anonymous")
 		.containter-fluid
@@ -179,6 +196,7 @@ aside#left-menu.full-size(
 					option(value="pdfjs") #{translate("built_in")}
 					option(value="native") #{translate("native")}
 
+
 	h4 #{translate("hotkeys")}
 	ul.list-unstyled.nav
 		li(ng-controller="HotkeysController")

+ 26 - 0
services/web/app/views/project/editor/new_from_template.pug

@@ -0,0 +1,26 @@
+extends ../../layout
+
+block content
+  script.
+    $(document).ready(function(){
+      $('#create_form').submit();
+    });
+
+  .editor.full-size
+    .loading-screen()
+      .loading-screen-brand-container
+        .loading-screen-brand(
+          style="height: 20%;"
+        )
+
+      h3.loading-screen-label() #{translate("Opening template")}
+        span.loading-screen-ellip .
+        span.loading-screen-ellip .
+        span.loading-screen-ellip .
+
+  form(id='create_form' method='POST' action='/project/new/template/')
+    input(type="hidden", name="_csrf", value=csrfToken)
+    input(type="hidden" name="templateId" value=templateId)
+    input(type="hidden" name="templateVersionId" value=templateVersionId)
+    input(type="hidden" name="templateName" value=name)
+    input(type="hidden" name="compiler" value=compiler)

+ 39 - 2
services/web/app/views/project/list/item.pug

@@ -1,4 +1,7 @@
-.col-xs-6
+- var titleClasses = settings.overleaf ? "col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+div(class=titleClasses)
 	input.select-item(
 		select-individual,
 		type="checkbox",
@@ -37,8 +40,42 @@
 			tooltip-placement="right"
 			tooltip-append-to-body="true"
 		)
-.col-xs-4
+
+div(class=lastUpdatedClasses)
 	if settings.overleaf
 		span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}
 	else
 		span.last-modified {{project.lastUpdated | formatDate}}
+
+if settings.overleaf
+	.hidden-xs.col-sm-3.col-md-2.action-btn-row
+		button.btn.btn-link.action-btn(
+			tooltip=translate('copy'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="clone($event)"
+		)
+			i.icon.fa.fa-files-o
+		button.btn.btn-link.action-btn(
+			tooltip=translate('download'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="download($event)"
+		)
+			i.icon.fa.fa-cloud-download
+		button.btn.btn-link.action-btn(
+			ng-if="!project.archived"
+			tooltip=translate('archive'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="archive($event)"
+		)
+			i.icon.fa.fa-inbox
+		button.btn.btn-link.action-btn(
+			ng-if="project.archived"
+			tooltip=translate('unarchive'),
+			tooltip-placement="top",
+			tooltip-append-to-body="true",
+			ng-click="restore($event)"
+		)
+			i.icon.fa.fa-reply

+ 8 - 2
services/web/app/views/project/list/project-list.pug

@@ -131,7 +131,10 @@
 			)
 				li.container-fluid
 					.row
-						.col-xs-6
+						- var titleClasses = settings.overleaf ? " col-xs-6 col-sm-4 col-md-6" : "col-xs-6"
+						- var lastUpdatedClasses = settings.overleaf ? " col-xs-4 col-sm-3 col-md-2" : "col-xs-4"
+
+						div(class=titleClasses)
 							input.select-all(
 								select-all,
 								type="checkbox"
@@ -142,9 +145,12 @@
 						.col-xs-2
 							span.header.clickable(ng-click="changePredicate('accessLevel')") #{translate("owner")}
 								i.tablesort.fa(ng-class="getSortIconClass('accessLevel')")
-						.col-xs-4
+						div(class=lastUpdatedClasses)
 							span.header.clickable(ng-click="changePredicate('lastUpdated')") #{translate("last_modified")}
 								i.tablesort.fa(ng-class="getSortIconClass('lastUpdated')")
+						if settings.overleaf
+							.hidden-xs.col-sm-3.col-md-2.action-btn-row-header
+								span.header #{translate("actions")}
 				li.project_entry.container-fluid(
 					ng-repeat="project in visibleProjects | orderBy:predicate:reverse",
 					ng-controller="ProjectListItemController"

+ 2 - 2
services/web/app/views/project/list/v1-item.pug

@@ -1,4 +1,4 @@
-.col-xs-6
+.col-xs-6.col-sm-4.col-md-6
 	.select-item
 		span.v1-badge(
 			aria-label=translate("v1_badge")
@@ -21,5 +21,5 @@
 .col-xs-2
 	span.owner {{ownerName()}}
 
-.col-xs-4
+.col-xs-4.col-sm-3.col-md-2
 	span.last-modified(tooltip="{{project.lastUpdated | formatDate}}") {{project.lastUpdated | fromNowDate}}

+ 2 - 2
services/web/config/settings.defaults.coffee

@@ -146,8 +146,8 @@ module.exports = settings =
 			url: "http://#{process.env['CONTACTS_HOST'] or 'localhost'}:3036"
 		sixpack:
 			url: ""
-		# references:
-		# 	url: "http://localhost:3040"
+		references:
+			url: "http://#{process.env['REFERENCES_HOST'] or 'localhost'}:3040"
 		notifications:
 			url: "http://#{process.env['NOTIFICATIONS_HOST'] or 'localhost'}:3042"
 		analytics:

+ 1 - 0
services/web/docker-compose.yml

@@ -17,6 +17,7 @@ services:
       PROJECT_HISTORY_ENABLED: 'true'
       ENABLED_LINKED_FILE_TYPES: 'url'
       LINKED_URL_PROXY: 'http://localhost:6543'
+      ENABLED_LINKED_FILE_TYPES: 'url,project_file'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
     depends_on:
       - redis

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

@@ -18,6 +18,7 @@ define [
 	"ide/chat/index"
 	"ide/clone/index"
 	"ide/hotkeys/index"
+	"ide/test-controls/index"
 	"ide/wordcount/index"
 	"ide/directives/layout"
 	"ide/directives/validFile"
@@ -34,6 +35,7 @@ define [
 	"directives/videoPlayState"
 	"services/queued-http"
 	"services/validateCaptcha"
+	"services/wait-for"
 	"filters/formatDate"
 	"main/event"
 	"main/account-upgrade"
@@ -54,7 +56,7 @@ define [
 	SafariScrollPatcher
 ) ->
 
-	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata) ->
+	App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) ->
 		# Don't freak out if we're already in an apply callback
 		$scope.$originalApply = $scope.$apply
 		$scope.$apply = (fn = () ->) ->

+ 24 - 7
services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee

@@ -2,7 +2,7 @@ define [
 	"base"
 	"moment"
 ], (App, moment) ->
-	App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", ($scope, $rootScope, $http, $timeout, $element, ide) ->
+	App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) ->
 
 		TWO_MEGABYTES = 2 * 1024 * 1024
 
@@ -31,6 +31,7 @@ define [
 			data: null
 
 		$scope.refreshing = false
+		$scope.refreshError = null
 
 		MAX_URL_LENGTH = 60
 		FRONT_OF_URL_LENGTH = 35
@@ -48,9 +49,27 @@ define [
 
 		$scope.refreshFile = (file) ->
 			$scope.refreshing = true
+			$scope.refreshError = null
 			ide.fileTreeManager.refreshLinkedFile(file)
-				.then () ->
-					loadTextFileFilePreview()
+				.then (response) ->
+					{ data } = response
+					{ new_file_id } = data
+					$timeout(
+						() ->
+							waitFor(
+								() ->
+									ide.fileTreeManager.findEntityById(new_file_id)
+								5000
+							)
+								.then (newFile) ->
+									ide.binaryFilesManager.openFile(newFile)
+								.catch (err) ->
+									console.warn(err)
+						, 0
+					)
+					$scope.refreshError = null
+				.catch (response) ->
+					$scope.refreshError = response.data
 				.finally () ->
 					$scope.refreshing = false
 
@@ -86,11 +105,9 @@ define [
 					# show dots when payload is closs to cutoff
 					if data.length >= (TWO_MEGABYTES - 200)
 						$scope.textPreview.shouldShowDots = true
-					try
 						# remove last partial line
-						data = data.replace(/\n.*$/, '')
-					finally
-						$scope.textPreview.data = data
+						data = data?.replace?(/\n.*$/, '')
+					$scope.textPreview.data = data
 					$timeout(setHeight, 0)
 				.catch (error) ->
 					console.error(error)

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

@@ -14,7 +14,7 @@ define [
 				opening: true
 				trackChanges: false
 				wantTrackChanges: false
-				richText: false
+				showRichText: false
 			}
 
 			@$scope.$on "entity:selected", (event, entity) =>

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

@@ -43,6 +43,19 @@ define [
 				}
 			)
 
+		$scope.openProjectLinkedFileModal = window.openProjectLinkedFileModal = () ->
+			unless 'project_file' in window.data.enabledLinkedFileTypes
+				console.warn("Project linked files are not enabled")
+				return
+			$modal.open(
+				templateUrl: "projectLinkedFileModalTemplate"
+				controller:  "ProjectLinkedFileModalController"
+				scope: $scope
+				resolve: {
+					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+				}
+			)
+
 		$scope.orderByFoldersFirst = (entity) ->
 			return '0' if entity?.type == "folder"
 			return '1'
@@ -201,6 +214,117 @@ define [
 				$modalInstance.dismiss('cancel')
 	]
 
+	App.controller "ProjectLinkedFileModalController", [
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+			$scope.data =
+				projects: null # or []
+				selectedProjectId: null
+				projectEntities: null # or []
+				selectedProjectEntity: null
+				name: null
+			$scope.state =
+				inFlight:
+					projects: false
+					entities: false
+					create: false
+				error: false
+
+			$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
+				return if !newVal
+				$scope.data.selectedProjectEntity = null
+				$scope.getProjectEntities($scope.data.selectedProjectId)
+
+			# auto-set filename based on selected file
+			$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
+				return if !newVal
+				fileName = newVal.split('/').reverse()[0]
+				if fileName
+					$scope.data.name = fileName
+
+			_setInFlight = (type) ->
+				$scope.state.inFlight[type] = true
+
+			_reset = (opts) ->
+				isError = opts.err == true
+				inFlight = $scope.state.inFlight
+				inFlight.projects = inFlight.entities = inFlight.create = false
+				$scope.state.error = isError
+
+			$scope.shouldEnableProjectSelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && data.projects
+
+			$scope.shouldEnableProjectEntitySelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
+
+			$scope.shouldEnableCreateButton = () ->
+				state = $scope.state
+				data = $scope.data
+				return !state.inFlight.projects &&
+					!state.inFlight.entities &&
+					data.projects &&
+					data.selectedProjectId &&
+					data.projectEntities &&
+					data.selectedProjectEntity &&
+					data.name
+
+			$scope.getUserProjects = () ->
+				_setInFlight('projects')
+				ide.$http.get("/user/projects", {
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					$scope.data.projectEntities = null
+					$scope.data.projects = resp.data.projects.filter (p) ->
+						p._id != ide.project_id
+					_reset(err: false)
+				.catch (err) ->
+					_reset(err: true)
+
+			$scope.getProjectEntities = (project_id) =>
+				_setInFlight('entities')
+				ide.$http.get("/project/#{project_id}/entities", {
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					if $scope.data.selectedProjectId == resp.data.project_id
+						$scope.data.projectEntities = resp.data.entities
+						_reset(err: false)
+				.catch (err) ->
+					_reset(err: true)
+
+			$scope.init = () ->
+				$scope.getUserProjects()
+			$timeout($scope.init, 0)
+
+			$scope.create = () ->
+				projectId = $scope.data.selectedProjectId
+				path = $scope.data.selectedProjectEntity
+				name = $scope.data.name
+				if !name || !path || !projectId
+					_reset(err: true)
+					return
+				_setInFlight('create')
+				ide.fileTreeManager
+					.createLinkedFile(name, parent_folder, 'project_file', {
+						source_project_id: projectId,
+						source_entity_path: path
+					})
+					.then () ->
+						_reset(err: false)
+						$modalInstance.close()
+					.catch (response)->
+						{ data } = response
+						_reset(err: true)
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+
+	]
+
+	# TODO: rename all this to UrlLinkedFilModalController
 	App.controller "LinkedFileModalController", [
 		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
 		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->

+ 13 - 14
services/web/public/coffee/ide/history/controllers/HistoryV2DiffController.coffee

@@ -1,7 +1,7 @@
 define [
 	"base"
 ], (App) ->
-	App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
+	App.controller "HistoryV2DiffController", ($scope, ide, event_tracking, waitFor) ->
 		$scope.restoreState =
 			inflight: false
 			error: false
@@ -24,17 +24,16 @@ define [
 					$scope.restoreState.inflight = false
 
 		openEntity = (data) ->
-			iterations = 0
 			{id, type} = data
-			do tryOpen = () ->
-				if iterations > 5
-					return
-				iterations += 1
-				entity = ide.fileTreeManager.findEntityById(id)
-				if entity? and type == 'doc'
-					ide.editorManager.openDoc(entity)
-				else if entity? and type == 'file'
-					ide.binaryFilesManager.openFile(entity)
-				else
-					setTimeout(tryOpen, 500)
-			
+			waitFor(
+				() ->
+					ide.fileTreeManager.findEntityById(id)
+				3000
+			)
+				.then (entity) ->
+					if type == 'doc'
+						ide.editorManager.openDoc(entity)
+					else if type == 'file'
+						ide.binaryFilesManager.openFile(entity)
+				.catch (err) ->
+					console.warn(err)

+ 16 - 0
services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee

@@ -0,0 +1,16 @@
+define [
+	"base"
+	"ace/ace"
+], (App) ->
+	App.controller "TestControlsController", ($scope) ->
+
+		$scope.openProjectLinkedFileModal = () ->
+			window.openProjectLinkedFileModal()
+
+		$scope.openLinkedFileModal = () ->
+			window.openLinkedFileModal()
+
+		$scope.richText = () ->
+			current = window.location.toString()
+			target = "#{current}#{if window.location.search then '&' else '?'}rt=true"
+			window.location.href = target

+ 3 - 0
services/web/public/coffee/ide/test-controls/index.coffee

@@ -0,0 +1,3 @@
+define [
+	"ide/test-controls/controllers/TestControlsController"
+], () ->

+ 0 - 72
services/web/public/coffee/main/contact-us.coffee

@@ -1,79 +1,7 @@
 define [
 	"base"
 	"libs/platform"
-	"services/algolia-search"
 ], (App, platform) ->
-	App.controller 'ContactModal', ($scope, $modal) ->
-		$scope.contactUsModal = () ->
-			modalInstance = $modal.open(
-				templateUrl: "supportModalTemplate"
-				controller: "SupportModalController"
-			)
-
-	App.controller 'SupportModalController', ($scope, $modalInstance, algoliaSearch, event_tracking) ->
-		$scope.form = {}
-		$scope.sent = false
-		$scope.sending = false
-		$scope.suggestions = [];
-
-		_handleSearchResults = (success, results) ->
-			suggestions = for hit in results.hits
-				page_underscored = hit.pageName.replace(/\s/g,'_')
-
-				suggestion = 
-					url :"/learn/kb/#{page_underscored}"
-					name : hit._highlightResult.pageName.value
-
-			event_tracking.sendMB "contact-form-suggestions-shown" if results.hits.length
-
-			$scope.$applyAsync () -> 
-				$scope.suggestions = suggestions
-
-		$scope.contactUs = ->
-			if !$scope.form.email? or $scope.form.email == ""
-				console.log "email not set"
-				return
-			$scope.sending = true
-			ticketNumber = Math.floor((1 + Math.random()) * 0x10000).toString(32)
-			message = $scope.form.message
-			if $scope.form.project_url?
-				message	= "#{message}\n\n project_url = #{$scope.form.project_url}" 
-			params =
-				email: $scope.form.email
-				message: message or ""
-				subject: $scope.form.subject + " - [#{ticketNumber}]"
-				labels: "support"
-				about: "<div>browser: #{platform?.name} #{platform?.version}</div>
-						<div>os: #{platform?.os?.family} #{platform?.os?.version}</div>"
-
-			Groove.createTicket params, (response)->
-				$scope.sending = false
-				if response.responseText == "" # Blocked request or similar
-					$scope.error = true
-				else
-					data = JSON.parse(response.responseText)
-					if data.errors?
-						$scope.error = true
-					else
-						$scope.sent = true
-				$scope.$apply()
-
-		$scope.$watch "form.subject", (newVal, oldVal) ->
-			if newVal and newVal != oldVal and newVal.length > 3
-				algoliaSearch.searchKB newVal, _handleSearchResults, { 
-					hitsPerPage: 3
-					typoTolerance: 'strict'
-				}
-			else
-				$scope.suggestions = [];
-
-		$scope.clickSuggestionLink = (url) ->
-			event_tracking.sendMB "contact-form-suggestions-clicked", { url }
-
-		$scope.close = () ->
-			$modalInstance.close()
-
-
 	App.controller 'UniverstiesContactController', ($scope, $modal, $http) ->
 
 		$scope.form = {}

+ 32 - 13
services/web/public/coffee/main/project-list/project-list.coffee

@@ -350,14 +350,15 @@ define [
 				$scope.archiveOrLeaveSelectedProjects()
 
 		$scope.archiveOrLeaveSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.archiveOrLeaveProjects($scope.getSelectedProjects())
 
+		$scope.archiveOrLeaveProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
 			# Remove project from any tags
 			for tag in $scope.tags
-				$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
+				$scope._removeProjectIdsFromTagArray(tag, projectIds)
 
-			for project in selected_projects
+			for project in projects
 				project.tags = []
 				if project.accessLevel == "owner"
 					project.archived = true
@@ -414,13 +415,14 @@ define [
 			$scope.updateVisibleProjects()
 
 		$scope.restoreSelectedProjects = () ->
-			selected_projects = $scope.getSelectedProjects()
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.restoreProjects($scope.getSelectedProjects())
 
-			for project in selected_projects
+		$scope.restoreProjects = (projects) ->
+			projectIds = projects.map (p) -> p.id
+			for project in projects
 				project.archived = false
 
-			for project_id in selected_project_ids
+			for projectId in projectIds
 				queuedHttp {
 					method: "POST"
 					url: "/project/#{project_id}/restore"
@@ -437,13 +439,14 @@ define [
 			)
 
 		$scope.downloadSelectedProjects = () ->
-			selected_project_ids = $scope.getSelectedProjectIds()
+			$scope.downloadProjectsById($scope.getSelectedProjectIds())
+
+		$scope.downloadProjectsById = (projectIds) ->
 			event_tracking.send 'project-list-page-interaction', 'project action', 'Download Zip'
-			if selected_project_ids.length > 1
-				path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}"
+			if projectIds.length > 1
+				path = "/project/download/zip?project_ids=#{projectIds.join(',')}"
 			else
-				path = "/project/#{selected_project_ids[0]}/download/zip"
-
+				path = "/project/#{projectIds[0]}/download/zip"
 			window.location = path
 
 		$scope.openV1ImportModal = (project) ->
@@ -490,3 +493,19 @@ define [
 		$scope.$watch "project.selected", (value) ->
 			if value?
 				$scope.updateSelectedProjects()
+
+		$scope.clone = (e) ->
+			e.stopPropagation()
+			$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
+
+		$scope.download = (e) ->
+			e.stopPropagation()
+			$scope.downloadProjectsById([$scope.project.id])
+
+		$scope.archive = (e) ->
+			e.stopPropagation()
+			$scope.archiveOrLeaveProjects([$scope.project])
+
+		$scope.restore = (e) ->
+			e.stopPropagation()
+			$scope.restoreProjects([$scope.project])

+ 2 - 2
services/web/public/coffee/services/algolia-search.coffee

@@ -8,7 +8,7 @@ define [
 			kbIdx = client.initIndex(window.sharelatex.algolia?.indexes?.kb)
 
 		service =
-			searchWiki: wikiIdx.search.bind(wikiIdx)
-			searchKB: kbIdx.search.bind(kbIdx)
+			searchWiki: if wikiIdx then wikiIdx.search.bind(wikiIdx) else null
+			searchKB: if kbIdx then kbIdx.search.bind(kbIdx) else null
 		
 		return service

+ 20 - 0
services/web/public/coffee/services/wait-for.coffee

@@ -0,0 +1,20 @@
+define [
+	"base"
+], (App) ->
+	App.factory "waitFor", ($q) ->
+		waitFor = (testFunction, timeout, pollInterval=500) ->
+			iterationLimit = Math.floor(timeout / pollInterval)
+			iterations = 0
+			$q(
+				(resolve, reject) ->
+					do tryIteration = () ->
+						if iterations > iterationLimit
+							return reject(new Error("waiting too long, #{JSON.stringify({timeout, pollInterval})}"))
+						iterations += 1
+						result = testFunction()
+						if result?
+							resolve(result)
+						else
+							setTimeout(tryIteration, pollInterval)
+			)
+		return waitFor

+ 10 - 0
services/web/public/stylesheets/app/project-list.less

@@ -369,6 +369,16 @@ ul.project-list {
 		.v1-badge {
 			margin-left: -4px;
 		}
+
+		.action-btn-row-header, .action-btn-row {
+			padding-right: 20px;
+			text-align: right;
+		}
+
+		.action-btn {
+			padding: 0 0.3em;
+			margin-left: 0.2em;
+		}
 	}
 	i.tablesort {
 		padding-left: 8px;

+ 6 - 1
services/web/public/stylesheets/components/forms.less

@@ -145,10 +145,15 @@ output {
     opacity: 1; // iOS fix for unreadable disabled content
   }
 
-  // Reset height for `textarea`s
+  // Reset height for `textarea`s, and smaller border-radius
   textarea& {
     height: auto;
+    border-radius: @border-radius-base;
   }
+	// Smaller border-radius for `select` inputs
+	select& {
+    border-radius: @border-radius-base;
+	}
 }
 
 

+ 120 - 4
services/web/test/acceptance/coffee/LinkedFilesTests.coffee

@@ -27,6 +27,122 @@ describe "LinkedFiles", ->
 			@owner.login ->
 				mkdirp Settings.path.dumpFolder, done
 
+	describe "creating a project linked file", ->
+		before (done) ->
+			@source_doc_name = 'test.txt'
+			async.series [
+				(cb) =>
+					@owner.createProject 'plf-test-one', {template: 'blank'}, (error, project_id) =>
+						@project_one_id = project_id
+						cb(error)
+				(cb) =>
+					@owner.getProject @project_one_id, (error, project) =>
+						@project_one = project
+						@project_one_root_folder_id = project.rootFolder[0]._id.toString()
+						cb(error)
+				(cb) =>
+					@owner.createProject 'plf-test-two', {template: 'blank'}, (error, project_id) =>
+						@project_two_id = project_id
+						cb(error)
+				(cb) =>
+					@owner.getProject @project_two_id, (error, project) =>
+						@project_two = project
+						@project_two_root_folder_id = project.rootFolder[0]._id.toString()
+						cb(error)
+				(cb) =>
+					@owner.createDocInProject @project_two_id,
+						@project_two_root_folder_id,
+						@source_doc_name,
+						(error, doc_id) =>
+							@source_doc_id = doc_id
+							cb(error)
+				(cb) =>
+					@owner.createDocInProject @project_two_id,
+						@project_two_root_folder_id,
+						'some-harmless-doc.txt',
+						(error, doc_id) =>
+							cb(error)
+			], done
+
+		it 'should produce a list of the users projects', (done) ->
+			@owner.request.get {
+				url: "/user/projects",
+				json: true
+			}, (err, response, body) =>
+				expect(err).to.not.exist
+				expect(body).to.deep.equal {
+					projects: [
+						{ _id: @project_one_id, name: 'plf-test-one', accessLevel: 'owner' },
+						{ _id: @project_two_id, name: 'plf-test-two', accessLevel: 'owner' }
+					]
+				}
+				done()
+
+		it 'should produce a list of entities in the project', (done) ->
+			@owner.request.get {
+				url: "/project/#{@project_two_id}/entities",
+				json: true
+			}, (err, response, body) =>
+				expect(err).to.not.exist
+				expect(body).to.deep.equal {
+					project_id: @project_two_id,
+					entities: [
+						{ path: '/main.tex',              type: 'doc' },
+						{ path: '/some-harmless-doc.txt', type: 'doc' },
+						{ path: '/test.txt',              type: 'doc' }
+					]
+				}
+				done()
+
+
+		it 'should import a file from the source project', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test-link.txt',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_file',
+					data:
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+			}, (error, response, body) =>
+				new_file_id = body.new_file_id
+				@existing_file_id = new_file_id
+				expect(new_file_id).to.exist
+				@owner.getProject @project_one_id, (error, project) =>
+					return done(error) if error?
+					firstFile = project.rootFolder[0].fileRefs[0]
+					expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+					expect(firstFile.linkedFileData).to.deep.equal {
+						provider: 'project_file',
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+						source_project_display_name: "plf-test-two"
+					}
+					expect(firstFile.name).to.equal('test-link.txt')
+					done()
+
+		it 'should refresh the file', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test-link.txt',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_file',
+					data:
+						source_project_id: @project_two_id,
+						source_entity_path: "/#{@source_doc_name}",
+			}, (error, response, body) =>
+				new_file_id = body.new_file_id
+				expect(new_file_id).to.exist
+				expect(new_file_id).to.not.equal @existing_file_id
+				@owner.getProject @project_one_id, (error, project) =>
+					return done(error) if error?
+					firstFile = project.rootFolder[0].fileRefs[0]
+					expect(firstFile._id.toString()).to.equal(new_file_id.toString())
+					expect(firstFile.name).to.equal('test-link.txt')
+					done()
+
 	describe "creating a URL based linked file", ->
 		before (done) ->
 			@owner.createProject "url-linked-files-project", {template: "blank"}, (error, project_id) =>
@@ -50,7 +166,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-1'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.getProject @project_id, (error, project) =>
 					throw error if error?
 					file = project.rootFolder[0].fileRefs[0]
@@ -76,7 +192,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-2'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.request.post {
 					url: "/project/#{@project_id}/linked_file",
 					json:
@@ -88,7 +204,7 @@ describe "LinkedFiles", ->
 						name: 'url-test-file-2'
 				}, (error, response, body) =>
 					throw error if error?
-					expect(response.statusCode).to.equal 204
+					expect(response.statusCode).to.equal 200
 					@owner.getProject @project_id, (error, project) =>
 						throw error if error?
 						file = project.rootFolder[0].fileRefs[1]
@@ -168,7 +284,7 @@ describe "LinkedFiles", ->
 					name: 'url-test-file-6'
 			}, (error, response, body) =>
 				throw error if error?
-				expect(response.statusCode).to.equal 204
+				expect(response.statusCode).to.equal 200
 				@owner.getProject @project_id, (error, project) =>
 					throw error if error?
 					file = _.find project.rootFolder[0].fileRefs, (file) ->

+ 12 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -143,6 +143,18 @@ class User
 				return callback(err)
 			callback(null)
 
+	createDocInProject: (project_id, parent_folder_id, name, callback=(error, doc_id)->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.post {
+				url: "/project/#{project_id}/doc",
+				json: {
+					name: name,
+					parent_folder_id: parent_folder_id
+				}
+			}, (error, response, body) =>
+				callback(null, body._id)
+
 	addUserToProject: (project_id, user, privileges, callback = (error, user) ->) ->
 		if privileges == 'readAndWrite'
 			updateOp = {$addToSet: {collaberator_refs: user._id.toString()}}

+ 5 - 5
services/web/test/unit/coffee/BetaProgram/BetaProgramControllerTests.coffee

@@ -23,8 +23,8 @@ describe "BetaProgramController", ->
 				optIn: sinon.stub()
 				optOut: sinon.stub()
 			},
-			"../User/UserLocator": @UserLocator = {
-				findById: sinon.stub()
+			"../User/UserGetter": @UserGetter = {
+				getUser: sinon.stub()
 			},
 			"settings-sharelatex": @settings = {
 				languages: {}
@@ -119,7 +119,7 @@ describe "BetaProgramController", ->
 	describe "optInPage", ->
 
 		beforeEach ->
-			@UserLocator.findById.callsArgWith(1, null, @user)
+			@UserGetter.getUser.callsArgWith(1, null, @user)
 
 		it "should render the opt-in page", () ->
 			@BetaProgramController.optInPage @req, @res, @next
@@ -128,10 +128,10 @@ describe "BetaProgramController", ->
 			args[0].should.equal 'beta_program/opt_in'
 
 
-		describe "when UserLocator.findById produces an error", ->
+		describe "when UserGetter.getUser produces an error", ->
 
 			beforeEach ->
-				@UserLocator.findById.callsArgWith(1, new Error('woops'))
+				@UserGetter.getUser.callsArgWith(1, new Error('woops'))
 
 			it "should not render the opt-in page", () ->
 				@BetaProgramController.optInPage @req, @res, @next

+ 9 - 6
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee

@@ -24,11 +24,14 @@ describe "CollaboratorsInviteController", ->
 			addCount: sinon.stub
 
 		@LimitationsManager = {}
+		@UserGetter =
+			getUserByMainEmail: sinon.stub()
+			getUser: sinon.stub()
 
 		@CollaboratorsInviteController = SandboxedModule.require modulePath, requires:
 			"../Project/ProjectGetter": @ProjectGetter = {}
 			'../Subscription/LimitationsManager' : @LimitationsManager
-			'../User/UserGetter': @UserGetter = {getUser: sinon.stub()}
+			'../User/UserGetter': @UserGetter
 			"./CollaboratorsHandler": @CollaboratorsHandler = {}
 			"./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {}
 			'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()}
@@ -713,7 +716,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = {_id: ObjectId().toString()}
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `true`', (done) ->
 					@call (err, shouldAllow) =>
@@ -725,7 +728,7 @@ describe "CollaboratorsInviteController", ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @user)
 
 				it 'should callback with `false`', (done) ->
 					@call (err, shouldAllow) =>
@@ -735,15 +738,15 @@ describe "CollaboratorsInviteController", ->
 
 				it 'should have called getUser', (done) ->
 					@call (err, shouldAllow) =>
-						@UserGetter.getUser.callCount.should.equal 1
-						@UserGetter.getUser.calledWith({email: @email}, {_id: 1}).should.equal true
+						@UserGetter.getUserByMainEmail.callCount.should.equal 1
+						@UserGetter.getUserByMainEmail.calledWith(@email, {_id: 1}).should.equal true
 						done()
 
 			describe 'when getUser produces an error', ->
 
 				beforeEach ->
 					@user = null
-					@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+					@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 				it 'should callback with an error', (done) ->
 					@call (err, shouldAllow) =>

+ 9 - 9
services/web/test/unit/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee

@@ -605,7 +605,7 @@ describe "CollaboratorsInviteHandler", ->
 				_id: ObjectId()
 				first_name: "jim"
 			@existingUser = {_id: ObjectId()}
-			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser)
+			@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, @existingUser)
 			@fakeProject =
 				_id: @project_id
 				name: "some project"
@@ -626,8 +626,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should call getProject', (done) ->
@@ -671,7 +671,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the user does not exist', ->
 
 			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, null, null)
 
 			it 'should not produce an error', (done) ->
 				@call (err) =>
@@ -680,8 +680,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->
@@ -698,7 +698,7 @@ describe "CollaboratorsInviteHandler", ->
 		describe 'when the getUser produces an error', ->
 
 			beforeEach ->
-				@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+				@UserGetter.getUserByMainEmail = sinon.stub().callsArgWith(2, new Error('woops'))
 
 			it 'should produce an error', (done) ->
 				@call (err) =>
@@ -707,8 +707,8 @@ describe "CollaboratorsInviteHandler", ->
 
 			it 'should call getUser', (done) ->
 				@call (err) =>
-					@UserGetter.getUser.callCount.should.equal 1
-					@UserGetter.getUser.calledWith({email: @invite.email}).should.equal true
+					@UserGetter.getUserByMainEmail.callCount.should.equal 1
+					@UserGetter.getUserByMainEmail.calledWith(@invite.email).should.equal true
 					done()
 
 			it 'should not call getProject', (done) ->

+ 4 - 4
services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee

@@ -16,7 +16,7 @@ describe "PasswordResetHandler", ->
 			getNewToken:sinon.stub()
 			getValueFromTokenAndExpire:sinon.stub()
 		@UserGetter =
-			getUser:sinon.stub()
+			getUserByMainEmail:sinon.stub()
 		@EmailHandler = 
 			sendEmail:sinon.stub()
 		@AuthenticationManager =
@@ -40,7 +40,7 @@ describe "PasswordResetHandler", ->
 	describe "generateAndEmailResetToken", ->
 
 		it "should check the user exists", (done)->
-			@UserGetter.getUser.callsArgWith(1)
+			@UserGetter.getUserByMainEmail.callsArgWith(1)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
@@ -49,7 +49,7 @@ describe "PasswordResetHandler", ->
 
 		it "should send the email with the token", (done)->
 
-			@UserGetter.getUser.callsArgWith(1, null, @user)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
 			@EmailHandler.sendEmail.callsArgWith(2)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
@@ -62,7 +62,7 @@ describe "PasswordResetHandler", ->
 
 		it "should return exists = false for a holdingAccount", (done) ->
 			@user.holdingAccount = true
-			@UserGetter.getUser.callsArgWith(1, null, @user)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
 			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false

+ 58 - 1
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -67,6 +67,7 @@ describe "ProjectController", ->
 			protectTokens: sinon.stub()
 		@CollaboratorsHandler =
 			userIsTokenMember: sinon.stub().callsArgWith(2, null, false)
+		@ProjectEntityHandler = {}
 		@Modules =
 			hooks:
 				fire: sinon.stub()
@@ -98,6 +99,7 @@ describe "ProjectController", ->
 			"../TokenAccess/TokenAccessHandler": @TokenAccessHandler
 			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
 			"../../infrastructure/Modules": @Modules
+			"./ProjectEntityHandler": @ProjectEntityHandler
 
 		@projectName = "£12321jkj9ujkljds"
 		@req =
@@ -520,7 +522,62 @@ describe "ProjectController", ->
 				@ProjectUpdateHandler.markAsOpened.calledWith(@project_id).should.equal true
 				done()
 			@ProjectController.loadEditor @req, @res
-		
+
+	describe 'userProjectsJson', ->
+		beforeEach (done) ->
+			projects = [
+				{archived: true,  id: 'a', name: 'A', accessLevel: 'a', somethingElse: 1}
+				{archived: false, id: 'b', name: 'B', accessLevel: 'b', somethingElse: 1}
+				{archived: false, id: 'c', name: 'C', accessLevel: 'c', somethingElse: 1}
+				{archived: false, id: 'd', name: 'D', accessLevel: 'd', somethingElse: 1}
+			]
+			@ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, [])
+			@ProjectController._buildProjectList = sinon.stub().returns(projects)
+			@AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc'
+			done()
+
+		it 'should produce a list of projects', (done) ->
+			@res.json = (data) =>
+				expect(data).to.deep.equal {
+					projects: [
+						{_id: 'b', name: 'B', accessLevel: 'b'},
+						{_id: 'c', name: 'C', accessLevel: 'c'},
+						{_id: 'd', name: 'D', accessLevel: 'd'}
+					]
+				}
+				done()
+			@ProjectController.userProjectsJson @req, @res, @next
+
+	describe 'projectEntitiesJson', ->
+		beforeEach () ->
+			@AuthenticationController.getLoggedInUserId = sinon.stub().returns 'abc'
+			@req.params = {Project_id: 'abcd'}
+			@project = { _id: 'abcd' }
+			@docs = [
+				{path: '/things/b.txt', doc: true},
+				{path: '/main.tex', doc: true}
+			]
+			@files = [
+				{path: '/things/a.txt'}
+			]
+			@ProjectGetter.getProject = sinon.stub().callsArgWith(1, null, @project)
+			@ProjectEntityHandler.getAllEntitiesFromProject = sinon.stub().callsArgWith(1, null, @docs, @files)
+
+		it 'should produce a list of entities', (done) ->
+			@res.json = (data) =>
+				expect(data).to.deep.equal {
+					project_id: 'abcd',
+					entities: [
+						{path: '/main.tex',     type: 'doc'},
+						{path: '/things/a.txt', type: 'file'},
+						{path: '/things/b.txt', type: 'doc'}
+					]
+				}
+				expect(@ProjectGetter.getProject.callCount).to.equal 1
+				expect(@ProjectEntityHandler.getAllEntitiesFromProject.callCount).to.equal 1
+				done()
+			@ProjectController.projectEntitiesJson @req, @res, @next
+
 	describe '_isInPercentageRollout', ->
 		before ->
 			@ids = [

+ 17 - 20
services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee

@@ -24,9 +24,6 @@ describe "SubscriptionGroupHandler", ->
 			getSubscriptionByMemberIdAndId: sinon.stub()
 			getSubscription: sinon.stub()
 
-		@UserCreator =
-			getUserOrCreateHoldingAccount: sinon.stub().callsArgWith(1, null, @user)
-
 		@SubscriptionUpdater =
 			addUserToGroup: sinon.stub().callsArgWith(2)
 			removeUserFromGroup: sinon.stub().callsArgWith(2)
@@ -34,9 +31,9 @@ describe "SubscriptionGroupHandler", ->
 		@TeamInvitesHandler =
 			createManagerInvite: sinon.stub().callsArgWith(2)
 
-		@UserLocator =
-			findById: sinon.stub()
-			findByEmail: sinon.stub()
+		@UserGetter =
+			getUser: sinon.stub()
+			getUserByMainEmail: sinon.stub()
 
 		@LimitationsManager =
 			hasGroupMembersLimitReached: sinon.stub()
@@ -61,7 +58,7 @@ describe "SubscriptionGroupHandler", ->
 			"./SubscriptionUpdater": @SubscriptionUpdater
 			"./TeamInvitesHandler": @TeamInvitesHandler
 			"./SubscriptionLocator": @SubscriptionLocator
-			"../User/UserLocator": @UserLocator
+			"../User/UserGetter": @UserGetter
 			"./LimitationsManager": @LimitationsManager
 			"../Security/OneTimeTokenHandler":@OneTimeTokenHandler
 			"../Email/EmailHandler":@EmailHandler
@@ -76,11 +73,11 @@ describe "SubscriptionGroupHandler", ->
 	describe "addUserToGroup", ->
 		beforeEach ->
 			@LimitationsManager.hasGroupMembersLimitReached.callsArgWith(1, null, false, @subscription)
-			@UserLocator.findByEmail.callsArgWith(1, null, @user)
-
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
+			
 		it "should find the user", (done)->
 			@Handler.addUserToGroup @adminUser_id, @newEmail, (err)=>
-				@UserLocator.findByEmail.calledWith(@newEmail).should.equal true
+				@UserGetter.getUserByMainEmail.calledWith(@newEmail).should.equal true
 				done()
 
 		it "should add the user to the group", (done)->
@@ -105,9 +102,9 @@ describe "SubscriptionGroupHandler", ->
 				@NotificationsBuilder.groupPlan.calledWith(@user, {subscription_id:@subscription._id}).should.equal true
 				@readStub.called.should.equal true
 				done()
-
-		it "should add a team invite if no user is found", (done) ->
-			@UserLocator.findByEmail.callsArgWith(1, null, null)
+		
+		it "should add an email invite if no user is found", (done) ->
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, null)
 			@Handler.addUserToGroup @adminUser_id, @newEmail, (err)=>
 				@TeamInvitesHandler.createManagerInvite.calledWith(@adminUser_id, @newEmail).should.equal true
 				done()
@@ -124,26 +121,26 @@ describe "SubscriptionGroupHandler", ->
 		beforeEach ->
 			@subscription = {}
 			@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)
-			@UserLocator.findById.callsArgWith(1, null, {_id:"31232"})
+			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
 
 		it "should locate the subscription", (done)->
-			@UserLocator.findById.callsArgWith(1, null, {_id:"31232"})
+			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
 			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
 				@SubscriptionLocator.getUsersSubscription.calledWith(@adminUser_id).should.equal true
 				done()
 
 		it "should get the users by id", (done)->
-			@UserLocator.findById.callsArgWith(1, null, {_id:"31232"})
+			@UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
 			@subscription.member_ids = ["1234", "342432", "312312"]
 			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
-				@UserLocator.findById.calledWith(@subscription.member_ids[0]).should.equal true
-				@UserLocator.findById.calledWith(@subscription.member_ids[1]).should.equal true
-				@UserLocator.findById.calledWith(@subscription.member_ids[2]).should.equal true
+				@UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true
+				@UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true
+				@UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true
 				users.length.should.equal @subscription.member_ids.length
 				done()
 
 		it "should just return the id if the user can not be found as they may have deleted their account", (done)->
-			@UserLocator.findById.callsArgWith(1)
+			@UserGetter.getUser.callsArgWith(1)
 			@subscription.member_ids = ["1234", "342432", "312312"]
 			@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
 				assert.deepEqual users[0], {_id:@subscription.member_ids[0]}

+ 76 - 0
services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee

@@ -0,0 +1,76 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = '../../../../app/js/Features/Templates/TemplatesController'
+
+
+describe 'TemplatesController', ->
+
+	project_id = "213432"
+
+	beforeEach ->
+		@request = sinon.stub()
+		@request.returns {
+			pipe:->
+			on:->
+		}
+		@fs = {
+			unlink : sinon.stub()
+			createWriteStream : sinon.stub().returns(on:(_, cb)->cb())
+		}
+		@ProjectUploadManager = {createProjectFromZipArchive : sinon.stub().callsArgWith(3, null, {_id:project_id})}
+		@dumpFolder = "dump/path"
+		@ProjectOptionsHandler = {setCompiler:sinon.stub().callsArgWith(2)}
+		@uuid = "1234"
+		@ProjectDetailsHandler =
+			getProjectDescription:sinon.stub()
+		@Project =
+			update: sinon.stub().callsArgWith(3, null)
+		@controller = SandboxedModule.require modulePath, requires:
+			'../../../js/Features/Uploads/ProjectUploadManager':@ProjectUploadManager
+			'../../../js/Features/Project/ProjectOptionsHandler':@ProjectOptionsHandler
+			'../../../js/Features/Authentication/AuthenticationController': @AuthenticationController = {getLoggedInUserId: sinon.stub()}
+			'./TemplatesPublisher':@TemplatesPublisher
+			"logger-sharelatex":
+				log:->
+				err:->
+			"settings-sharelatex":
+				path:
+					dumpFolder:@dumpFolder
+				siteUrl: @siteUrl = "http://localhost:3000"
+				apis:
+					v1:
+						url: @v1Url="http://overleaf.com"
+						user: "sharelatex"
+						pass: "password"
+				overleaf:
+					host: @v1Url
+			"uuid":v4:=>@uuid
+			"request": @request
+			"fs":@fs
+			"../../../../app/js/models/Project": {Project: @Project}
+		@zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex"
+		@templateName = "project name here"
+		@user_id = "1234"
+		@req =
+			session:
+				user: _id:@user_id
+				templateData:
+					zipUrl: @zipUrl
+					templateName: @templateName
+		@redirect = {}
+		@AuthenticationController.getLoggedInUserId.returns(@user_id)
+
+	describe 'v1Templates', ->
+
+		it "should fetch zip from v1 based on template id", (done)->
+			@templateVersionId = 15
+			@req.body = {templateVersionId: @templateVersionId}
+
+			redirect = =>
+				@request.calledWith("#{@v1Url}/api/v1/sharelatex/templates/#{@templateVersionId}").should.equal true
+				done()
+			res = redirect:redirect
+			@controller.createProjectFromV1Template @req, res

+ 3 - 3
services/web/test/unit/coffee/User/UserControllerTests.coffee

@@ -30,8 +30,8 @@ describe "UserController", ->
 
 		@UserDeleter =
 			deleteUser: sinon.stub().callsArgWith(1)
-		@UserLocator =
-			findById: sinon.stub().callsArgWith(1, null, @user)
+		@UserGetter =
+			getUser: sinon.stub().callsArgWith(1, null, @user)
 		@User =
 			findById: sinon.stub().callsArgWith(1, null, @user)
 		@NewsLetterManager =
@@ -63,7 +63,7 @@ describe "UserController", ->
 		@SudoModeHandler =
 			clearSudoMode: sinon.stub()
 		@UserController = SandboxedModule.require modulePath, requires:
-			"./UserLocator": @UserLocator
+			"./UserGetter": @UserGetter
 			"./UserDeleter": @UserDeleter
 			"./UserUpdater":@UserUpdater
 			"../../models/User": User:@User

+ 3 - 21
services/web/test/unit/coffee/User/UserCreatorTests.coffee

@@ -15,34 +15,16 @@ describe "UserCreator", ->
 			constructor: ->
 				return self.user
 
-		@UserLocator = 
-			findByEmail: sinon.stub()
+		@UserGetter =
+			getUserByMainEmail: sinon.stub()
 		@UserCreator = SandboxedModule.require modulePath, requires:
 			"../../models/User": User:@UserModel
-			"./UserLocator":@UserLocator
+			"./UserGetter":@UserGetter
 			"logger-sharelatex":{log:->}
 			'metrics-sharelatex': {timeAsyncMethod: ()->}
 
 		@email = "bob.oswald@gmail.com"
 
-
-	describe "getUserOrCreateHoldingAccount", ->
-
-		it "should immediately return the user if found", (done)->
-			@UserLocator.findByEmail.callsArgWith(1, null, @user)
-			@UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=>
-				assert.deepEqual returnedUser, @user
-				done()
-
-		it "should create new holding account if the user is not found", (done)->
-			@UserLocator.findByEmail.callsArgWith(1)
-			@UserCreator.createNewUser = sinon.stub().callsArgWith(1, null, @user)
-			@UserCreator.getUserOrCreateHoldingAccount @email, (err, returnedUser)=>
-				@UserCreator.createNewUser.calledWith(email:@email, holdingAccount:true).should.equal true
-				assert.deepEqual returnedUser, @user
-				done()
-
-
 	describe "createNewUser", ->
 
 		it "should take the opts and put them in the model", (done)->

+ 58 - 0
services/web/test/unit/coffee/User/UserGetterTests.coffee

@@ -0,0 +1,58 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+sinon = require('sinon')
+modulePath = path.join __dirname, "../../../../app/js/Features/User/UserGetter"
+expect = require("chai").expect
+
+describe "UserGetter", ->
+
+	beforeEach ->
+		@fakeUser = {_id:"12390i"}
+		@findOne = sinon.stub().callsArgWith(2, null, @fakeUser)
+		@Mongo =
+			db: users: findOne: @findOne
+			ObjectId: (id) -> return id
+
+		@UserGetter = SandboxedModule.require modulePath, requires:
+			"logger-sharelatex": log:->
+			"../../infrastructure/mongojs": @Mongo
+			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
+
+	describe "getUser", ->
+		it "should get user", (done)->
+			query = _id: 'foo'
+			projection = email: 1
+			@UserGetter.getUser query, projection, (error, user) =>
+				@findOne.called.should.equal true
+				@findOne.calledWith(query, projection).should.equal true
+				user.should.deep.equal @fakeUser
+				done()
+
+		it "should not allow email in query", (done)->
+			@UserGetter.getUser email: 'foo@bar.com', {}, (error, user) =>
+				error.should.exist
+				done()
+
+	describe "getUserbyMainEmail", ->
+		it "query user by main email", (done)->
+			email = 'hello@world.com'
+			projection = emails: 1
+			@UserGetter.getUserByMainEmail email, projection, (error, user) =>
+				@findOne.called.should.equal true
+				@findOne.calledWith(email: email, projection).should.equal true
+				done()
+
+		it "return user if found", (done)->
+			email = 'hello@world.com'
+			@UserGetter.getUserByMainEmail email, (error, user) =>
+				user.should.deep.equal @fakeUser
+				done()
+
+		it "trim email", (done)->
+			email = 'hello@world.com'
+			@UserGetter.getUserByMainEmail " #{email} ", (error, user) =>
+				@findOne.called.should.equal true
+				@findOne.calledWith(email: email).should.equal true
+				done()

+ 0 - 39
services/web/test/unit/coffee/User/UserLocatorTests.coffee

@@ -1,39 +0,0 @@
-sinon = require('sinon')
-chai = require('chai')
-should = chai.should()
-modulePath = "../../../../app/js/Features/User/UserLocator.js"
-SandboxedModule = require('sandboxed-module')
-
-describe "UserLocator", ->
-
-	beforeEach ->
-		@user = {_id:"12390i"}
-		@UserLocator = SandboxedModule.require modulePath, requires:
-			"../../infrastructure/mongojs": db: @db =  { users: {} }
-			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
-			'logger-sharelatex' : { log: sinon.stub() }
-		@db.users =
-			findOne : sinon.stub().callsArgWith(1, null, @user)
-
-		@email = "bob.oswald@gmail.com"
-
-
-	describe "findByEmail", ->
-
-		it "should try and find a user with that email address", (done)->
-			@UserLocator.findByEmail @email, (err, user)=>
-				@db.users.findOne.calledWith(email:@email).should.equal true
-				done()
-
-		it "should trim white space", (done)->
-			@UserLocator.findByEmail "#{@email}   ", (err, user)=>
-				@db.users.findOne.calledWith(email:@email).should.equal true
-				done()
-
-		it "should return the user if found", (done)->
-			@UserLocator.findByEmail @email, (err, user)=>
-				user.should.deep.equal @user
-				done()
-
-
-

+ 4 - 5
services/web/test/unit/coffee/User/UserPagesControllerTests.coffee

@@ -16,10 +16,7 @@ describe "UserPagesController", ->
 			features:{}
 			email: "joe@example.com"
 
-		@UserLocator =
-			findById: sinon.stub().callsArgWith(1, null, @user)
-		@UserGetter =
-			getUser: sinon.stub().callsArgWith(2, null, @user)
+		@UserGetter = getUser: sinon.stub()
 		@UserSessionsManager =
 			getAllUserSessions: sinon.stub()
 		@dropboxStatus = {}
@@ -37,7 +34,6 @@ describe "UserPagesController", ->
 			"logger-sharelatex":
 				log:->
 				err:->
-			"./UserLocator": @UserLocator
 			"./UserGetter": @UserGetter
 			"./UserSessionsManager": @UserSessionsManager
 			"../Errors/ErrorController": @ErrorController
@@ -136,6 +132,8 @@ describe "UserPagesController", ->
 				@UserPagesController.sessionsPage @req, @res, @next
 
 	describe "settingsPage", ->
+		beforeEach ->
+			@UserGetter.getUser = sinon.stub().callsArgWith(1, null, @user)
 
 		it "should render user/settings", (done)->
 			@res.render = (page)->
@@ -185,6 +183,7 @@ describe "UserPagesController", ->
 
 	describe "activateAccountPage", ->
 		beforeEach ->
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
 			@req.query.user_id = @user_id
 			@req.query.token = @token = "mock-token-123"
 

+ 6 - 4
services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee

@@ -12,8 +12,9 @@ describe "UserRegistrationHandler", ->
 		@user =
 			_id: @user_id = "31j2lk21kjl"
 		@User = 
-			findOne:sinon.stub()
 			update: sinon.stub().callsArgWith(2)
+		@UserGetter =
+			getUserByMainEmail: sinon.stub()
 		@UserCreator = 
 			createNewUser:sinon.stub().callsArgWith(1, null, @user)
 		@AuthenticationManager =
@@ -26,6 +27,7 @@ describe "UserRegistrationHandler", ->
 			getNewToken: sinon.stub()
 		@handler = SandboxedModule.require modulePath, requires:
 			"../../models/User": {User:@User}
+			"./UserGetter": @UserGetter
 			"./UserCreator": @UserCreator
 			"../Authentication/AuthenticationManager":@AuthenticationManager
 			"../Newsletter/NewsletterManager":@NewsLetterManager
@@ -70,7 +72,7 @@ describe "UserRegistrationHandler", ->
 			beforeEach ->
 				@user.holdingAccount = true
 				@handler._registrationRequestIsValid = sinon.stub().returns true
-				@User.findOne.callsArgWith(1, null, @user)
+				@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
 
 			it "should not create a new user if there is a holding account there", (done)->
 				@handler.registerNewUser @passingRequest, (err)=>
@@ -94,7 +96,7 @@ describe "UserRegistrationHandler", ->
 					done()
 
 			it "should return email registered in the error if there is a non holdingAccount there", (done)->
-				@User.findOne.callsArgWith(1, null, @user = {holdingAccount:false})
+				@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user = {holdingAccount:false})
 				@handler.registerNewUser @passingRequest, (err, user)=>
 					err.should.deep.equal new Error("EmailAlreadyRegistered")
 					user.should.deep.equal @user
@@ -103,7 +105,7 @@ describe "UserRegistrationHandler", ->
 		describe "validRequest", ->
 			beforeEach ->
 				@handler._registrationRequestIsValid = sinon.stub().returns true
-				@User.findOne.callsArgWith 1
+				@UserGetter.getUserByMainEmail.callsArgWith 1
 
 			it "should create a new user", (done)->
 				@handler.registerNewUser @passingRequest, (err)=>

+ 5 - 5
services/web/test/unit/coffee/User/UserUpdaterTests.coffee

@@ -14,12 +14,12 @@ describe "UserUpdater", ->
 		@mongojs = 
 			db:{}
 			ObjectId:(id)-> return id
-		@UserLocator =
-			findByEmail:sinon.stub()
+		@UserGetter =
+			getUserByMainEmail: sinon.stub()
 		@UserUpdater = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex":@settings
 			"logger-sharelatex": log:->
-			"./UserLocator":@UserLocator
+			"./UserGetter": @UserGetter
 			"../../infrastructure/mongojs":@mongojs
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
 
@@ -34,7 +34,7 @@ describe "UserUpdater", ->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2)
 
 		it "should check if the new email already has an account", (done)->
-			@UserLocator.findByEmail.callsArgWith(1, null, @stubbedUser)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @stubbedUser)
 			@UserUpdater.changeEmailAddress @user_id, @stubbedUser.email, (err)=>
 				@UserUpdater.updateUser.called.should.equal false
 				should.exist(err)
@@ -42,7 +42,7 @@ describe "UserUpdater", ->
 
 
 		it "should set the users password", (done)->
-			@UserLocator.findByEmail.callsArgWith(1, null)
+			@UserGetter.getUserByMainEmail.callsArgWith(1, null)
 			@UserUpdater.changeEmailAddress @user_id, @newEmail, (err)=>
 				@UserUpdater.updateUser.calledWith(@user_id, $set: { "email": @newEmail}).should.equal true
 				done()