Procházet zdrojové kódy

Merge branch 'master' into ho-clsi-double-backend

Henry Oswald před 8 roky
rodič
revize
c1a8d8b31a
64 změnil soubory, kde provedl 2389 přidání a 972 odebrání
  1. 11 6
      services/web/Makefile
  2. 8 0
      services/web/app/coffee/Features/Compile/ClsiManager.coffee
  3. 10 6
      services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
  4. 10 6
      services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
  5. 96 152
      services/web/app/coffee/Features/Email/EmailBuilder.coffee
  6. 8 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  7. 72 51
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee
  8. 84 0
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee
  9. 86 0
      services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee
  10. 84 127
      services/web/app/coffee/Features/LinkedFiles/ProjectFileAgent.coffee
  11. 158 0
      services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee
  12. 33 47
      services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee
  13. 2 2
      services/web/app/coffee/Features/PasswordReset/PasswordResetHandler.coffee
  14. 7 3
      services/web/app/coffee/Features/Project/ProjectController.coffee
  15. 4 1
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  16. 35 19
      services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee
  17. 14 1
      services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee
  18. 5 1
      services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
  19. 36 0
      services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee
  20. 39 10
      services/web/app/coffee/Features/User/UserEmailsController.coffee
  21. 34 5
      services/web/app/coffee/Features/User/UserGetter.coffee
  22. 1 1
      services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
  23. 87 13
      services/web/app/coffee/Features/User/UserUpdater.coffee
  24. 4 1
      services/web/app/coffee/infrastructure/FileWriter.coffee
  25. 0 16
      services/web/app/coffee/infrastructure/OldAssetProxy.coffee
  26. 36 0
      services/web/app/coffee/infrastructure/ProxyManager.coffee
  27. 2 2
      services/web/app/coffee/infrastructure/Server.coffee
  28. 1 1
      services/web/app/coffee/infrastructure/mongojs.coffee
  29. 2 1
      services/web/app/coffee/models/User.coffee
  30. 4 0
      services/web/app/coffee/router.coffee
  31. 21 3
      services/web/app/views/project/editor/binary-file.pug
  32. 7 221
      services/web/app/views/project/editor/file-tree.pug
  33. 0 8
      services/web/app/views/project/editor/left-menu.pug
  34. 217 0
      services/web/app/views/project/editor/new-file-modal.pug
  35. 1 1
      services/web/app/views/subscriptions/new.pug
  36. 28 0
      services/web/app/views/user/confirm_email.pug
  37. 1 1
      services/web/docker-compose.yml
  38. 11 5
      services/web/public/coffee/directives/asyncForm.coffee
  39. 30 31
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  40. 3 3
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee
  41. 0 4
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee
  42. 1 4
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee
  43. 3 6
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee
  44. 5 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee
  45. 172 98
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  46. 0 6
      services/web/public/coffee/ide/test-controls/controllers/TestControlsController.coffee
  47. 40 0
      services/web/public/stylesheets/app/editor/file-tree.less
  48. 4 3
      services/web/public/stylesheets/app/project-list.less
  49. 8 4
      services/web/public/stylesheets/components/fineupload.less
  50. 105 2
      services/web/test/acceptance/coffee/LinkedFilesTests.coffee
  51. 200 0
      services/web/test/acceptance/coffee/UserEmailsTests.coffee
  52. 12 1
      services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee
  53. 8 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  54. 5 5
      services/web/test/unit/coffee/PasswordReset/PasswordResetHandlerTests.coffee
  55. 16 0
      services/web/test/unit/coffee/Project/ProjectControllerTests.coffee
  56. 33 2
      services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
  57. 80 45
      services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee
  58. 16 0
      services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee
  59. 125 0
      services/web/test/unit/coffee/User/UserEmailsConfirmationHandlerTests.coffee
  60. 70 36
      services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee
  61. 34 0
      services/web/test/unit/coffee/User/UserGetterTests.coffee
  62. 2 2
      services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
  63. 92 9
      services/web/test/unit/coffee/User/UserUpdaterTests.coffee
  64. 66 0
      services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee

+ 11 - 6
services/web/Makefile

@@ -196,8 +196,11 @@ test_frontend: test_clean # stop service
 
 test_acceptance: test_acceptance_app test_acceptance_modules
 
-test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run
-	$(MAKE) test_acceptance_app_stop_service
+test_acceptance_app:
+	@set -e; \
+	$(MAKE) test_acceptance_app_start_service; \
+	$(MAKE) test_acceptance_app_run; \
+	$(MAKE) test_acceptance_app_stop_service;
 
 test_acceptance_app_start_service: test_clean # stop service and clear dbs
 	$(MAKE) compile
@@ -208,10 +211,12 @@ test_acceptance_app_stop_service:
 
 test_acceptance_app_run:
 	@docker-compose ${DOCKER_COMPOSE_FLAGS} exec -T test_acceptance npm -q run test:acceptance -- ${MOCHA_ARGS}; \
-	if [ $$? -eq 137 ]; then \
-		echo "\nOh dear, it looks like the web process crashed! To see why, run:\n\n\tdocker-compose logs test_acceptance\n"; \
-		exit 1; \
-	fi
+	result=$$?; \
+	if [ $$result -eq 137 ]; then \
+		docker-compose logs --tail=50 test_acceptance; \
+		echo "\nOh dear, it looks like the web process crashed! Some logs are above, but to see them all, run:\n\n\tdocker-compose logs test_acceptance\n"; \
+	fi; \
+	exit $$result
 
 test_acceptance_modules:
 	@set -e; \

+ 8 - 0
services/web/app/coffee/Features/Compile/ClsiManager.coffee

@@ -222,6 +222,14 @@ module.exports = ClsiManager =
 				return callback(error) if error?
 				callback(null, projectStateHash, docs)
 
+	getOutputFileStream: (project_id, user_id, build_id, output_file_path, callback=(err, readStream)->) ->
+		url = "#{Settings.apis.clsi.url}/project/#{project_id}/user/#{user_id}/build/#{build_id}/output/#{output_file_path}"
+		ClsiCookieManager.getCookieJar project_id, (err, jar)->
+			return callback(err) if err?
+			options = { url: url, method: "GET", timeout: 60 * 1000, jar : jar }
+			readStream = request(options)
+			callback(null, readStream)
+
 	_buildRequestFromDocupdater: (project_id, options, project, projectStateHash, docUpdaterDocs, callback = (error, request) ->) ->
 		ProjectEntityHandler.getAllDocPathsFromProject project, (error, docPath) ->
 				return callback(error) if error?

+ 10 - 6
services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee

@@ -4,13 +4,17 @@ settings = require "settings-sharelatex"
 module.exports = _.template """
 <table class="row" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
 	<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; text-align: left; width: 564px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
-		<h3 class="avoid-auto-linking" style="Margin: 0; color: inherit; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; word-wrap: normal;">
-			<%= title %>
-		</h3>
+		<% if (title) { %>
+			<h3 class="avoid-auto-linking" style="Margin: 0; color: inherit; font-family: Baskerville, 'Baskerville Old Face', Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; word-wrap: normal;">
+				<%= title %>
+			</h3>
+		<% } %>
 		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table> 
-		<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
-			<%= greeting %>
-		</p>
+		<% if (greeting) { %>
+			<p style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+				<%= greeting %>
+			</p>
+		<% } %>
 		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #0a0a0a; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
 			<%= message %>
 		</p>

+ 10 - 6
services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee

@@ -4,13 +4,17 @@ settings = require "settings-sharelatex"
 module.exports = _.template """
 <table class="row" style="border-collapse: collapse; border-spacing: 0; display: table; padding: 0; position: relative; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;">
 	<th class="small-12 large-12 columns first last" style="Margin: 0 auto; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0 auto; padding: 0; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; text-align: left; width: 564px;"><table style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tr style="padding: 0; text-align: left; vertical-align: top;"><th style="Margin: 0; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left;">
-		<h3 class="avoid-auto-linking" style="Margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; word-wrap: normal;">
-			<%= title %>
-		</h3>
+		<% if (title) { %>
+			<h3 class="avoid-auto-linking" style="Margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; margin: 0; padding: 0; text-align: left; word-wrap: normal;">
+				<%= title %>
+			</h3>
+		<% } %>
 		<table class="spacer" style="border-collapse: collapse; border-spacing: 0; padding: 0; text-align: left; vertical-align: top; width: 100%;"><tbody><tr style="padding: 0; text-align: left; vertical-align: top;"><td height="20px" style="-moz-hyphens: auto; -webkit-hyphens: auto; Margin: 0; border-collapse: collapse !important; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 20px; font-weight: normal; hyphens: auto; line-height: 20px; margin: 0; mso-line-height-rule: exactly; padding: 0; text-align: left; vertical-align: top; word-wrap: break-word;">&#xA0;</td></tr></tbody></table>
-		<p style="Margin: 0; Margin-bottom: 10px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
-			<%= greeting %>
-		</p>
+		<% if (greeting) { %>
+			<p style="Margin: 0; Margin-bottom: 10px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
+				<%= greeting %>
+			</p>
+		<% } %>
 		<p class="avoid-auto-linking" style="Margin: 0; Margin-bottom: 10px; color: #5D6879; font-family: Helvetica, Arial, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.3; margin: 0; margin-bottom: 10px; padding: 0; text-align: left;">
 			<%= message %>
 		</p>

+ 96 - 152
services/web/app/coffee/Features/Email/EmailBuilder.coffee

@@ -1,5 +1,6 @@
 _ = require('underscore')
 settings = require("settings-sharelatex")
+marked = require('marked')
 
 PersonalEmailLayout = require("./Layouts/PersonalEmailLayout")
 NotificationEmailLayout = require("./Layouts/NotificationEmailLayout")
@@ -7,169 +8,113 @@ BaseWithHeaderEmailLayout = require("./Layouts/" + settings.brandPrefix + "BaseW
 
 SingleCTAEmailBody = require("./Bodies/" + settings.brandPrefix + "SingleCTAEmailBody")
 
-templates = {}
-
-templates.registered =
-	subject:  _.template "Activate your #{settings.appName} Account"
-	layout: PersonalEmailLayout
-	type: "notification"
-	plainTextTemplate: _.template """
-Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
+CTAEmailTemplate = (content) ->
+	content.greeting ?= () -> 'Hi,'
+	content.secondaryMessage ?= () -> ""
+	return {
+		subject: (opts) -> content.subject(opts),
+		layout: BaseWithHeaderEmailLayout,
+		plainTextTemplate: (opts) -> """
+#{content.greeting(opts)}
+
+#{content.message(opts).trim()}
+
+#{content.ctaText(opts)}: #{content.ctaURL(opts)}
+
+#{content.secondaryMessage?(opts).trim() or ""}
+
+Regards,
+The #{settings.appName} Team - #{settings.siteUrl}
+		"""
+		compiledTemplate: (opts) ->
+			SingleCTAEmailBody({
+				title: content.title?(opts)
+				greeting: content.greeting(opts)
+				message: marked(content.message(opts).trim())
+				secondaryMessage: marked(content.secondaryMessage(opts).trim())
+				ctaText: content.ctaText(opts)
+				ctaURL: content.ctaURL(opts)
+				gmailGoToAction: content.gmailGoToAction?(opts)
+			})
+	}
 
-Click here to set your password and log in: <%= setNewPasswordUrl %>
-
-If you have any questions or problems, please contact #{settings.adminEmail}
-"""
-	compiledTemplate: _.template """
-<p>Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".</p>
+templates = {}
 
-<p><a href="<%= setNewPasswordUrl %>">Click here to set your password and log in.</a></p>
+templates.registered = CTAEmailTemplate({
+	subject: () -> "Activate your #{settings.appName} Account"
+	message: (opts) -> """
+Congratulations, you've just had an account created for you on #{settings.appName} with the email address '#{opts.to}'.
 
-<p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
+Click here to set your password and log in:
 """
-
-
-templates.canceledSubscription =
-	subject:  _.template "ShareLaTeX thoughts"
-	layout: PersonalEmailLayout
-	type:"lifecycle"
-	plainTextTemplate: _.template """
-Hi <%= first_name %>,
-
-I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?:
-
-https://sharelatex.typeform.com/to/f5lBiZ
-
-Thank you in advance.
-
-Henry
-
-ShareLaTeX Co-founder
+	secondaryMessage: () -> "If you have any questions or problems, please contact #{settings.adminEmail}"
+	ctaText: () -> "Set password"
+	ctaURL: (opts) -> opts.setNewPasswordUrl
+})
+
+templates.canceledSubscription = CTAEmailTemplate({
+	subject: () -> "#{settings.appName} thoughts"
+	message: () -> """
+I'm sorry to see you cancelled your #{settings.appName} premium account. Would you mind giving us some feedback on what the site is lacking at the moment via this quick survey?
 """
-	compiledTemplate: _.template '''
-<p>Hi <%= first_name %>,</p>
-
-<p>I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via <a href="https://sharelatex.typeform.com/to/f5lBiZ">this survey</a>?</p>
-
-<p>Thank you in advance.</p>
-
-<p>
-Henry <br>
-ShareLaTeX Co-founder
-</p>
-'''
-
-
-templates.passwordResetRequested =
-	subject:  _.template "Password Reset - #{settings.appName}"
-	layout: BaseWithHeaderEmailLayout
-	type:"notification"
-	plainTextTemplate: _.template """
-Password Reset
-
-We got a request to reset your #{settings.appName} password.
-
-Click this link to reset your password: <%= setNewPasswordUrl %>
-
+	secondaryMessage: () -> "Thank you in advance!"
+	ctaText: () -> "Leave Feedback"
+	ctaURL: (opts) -> "https://sharelatex.typeform.com/to/f5lBiZ"
+})
+
+templates.passwordResetRequested = CTAEmailTemplate({
+	subject: () -> "Password Reset - #{settings.appName}"
+	title: () -> "Password Reset"
+	message: () -> "We got a request to reset your #{settings.appName} password."
+	secondaryMessage: () -> """
 If you ignore this message, your password won't be changed.
 
 If you didn't request a password reset, let us know.
-
-Thank you
-
-#{settings.appName} - <%= siteUrl %>
-"""
-	compiledTemplate: (opts) ->
-		SingleCTAEmailBody({
-			title: "Password Reset"
-			greeting: "Hi,"
-			message: "We got a request to reset your #{settings.appName} password."
-			secondaryMessage: "If you ignore this message, your password won't be changed.<br>If you didn't request a password reset, let us know."
-			ctaText: "Reset password"
-			ctaURL: opts.setNewPasswordUrl
-			gmailGoToAction: null
-		})
-
-
-templates.projectInvite =
-	subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
-	layout: BaseWithHeaderEmailLayout
-	type:"notification"
-	plainTextTemplate: _.template """
-Hi, <%= owner.email %> wants to share '<%= project.name %>' with you.
-
-Follow this link to view the project: <%= inviteUrl %>
-
-Thank you
-
-#{settings.appName} - <%= siteUrl %>
-"""
-	compiledTemplate: (opts) ->
-		SingleCTAEmailBody({
-			title: "#{ opts.project.name } &ndash; shared by #{ opts.owner.email }"
-			greeting: "Hi,"
-			message: "#{ opts.owner.email } wants to share &ldquo;#{ opts.project.name }&rdquo; with you."
-			secondaryMessage: null
-			ctaText: "View project"
-			ctaURL: opts.inviteUrl
-			gmailGoToAction:
-				target: opts.inviteUrl
-				name: "View project"
-				description: "Join #{ opts.project.name } at ShareLaTeX"
-		})
-
-
-templates.verifyEmailToJoinTeam =
-	subject: _.template "<%= inviterName %> has invited you to join a team on #{settings.appName}"
-	layout: BaseWithHeaderEmailLayout
-	type:"notification"
-	plainTextTemplate: _.template """
-
-Please click the button below to join the team and enjoy the benefits of an upgraded  <%= appName %> account.
-
-<%= acceptInviteUrl %>
-
-Thank You
-
-#{settings.appName} - <%= siteUrl %>
 """
-	compiledTemplate: (opts) ->
-		SingleCTAEmailBody({
-			title: "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
-			greeting: "Hi,"
-			message: "Please click the button below to join the team and enjoy the benefits of an upgraded  #{ opts.appName } account."
-			secondaryMessage: null
-			ctaText: "Verify now"
-			ctaURL: opts.acceptInviteUrl
-			gmailGoToAction: null
-		})
-
-templates.testEmail =
-	subject: _.template "A Test Email from ShareLaTeX"
-	layout: BaseWithHeaderEmailLayout
-	type:"notification"
-	plainTextTemplate: _.template """
-Hi,
-
-This is a test email sent from ShareLaTeX.
-
-#{settings.appName} - <%= siteUrl %>
-"""
-	compiledTemplate: (opts) ->
-		SingleCTAEmailBody({
-			title: "A Test Email from ShareLaTeX"
-			greeting: "Hi,"
-			message: "This is a test email sent from ShareLaTeX"
-			secondaryMessage: null
-			ctaText: "Open ShareLaTeX"
-			ctaURL: "/"
-			gmailGoToAction: null
-		})
-
+	ctaText: () -> "Reset password"
+	ctaURL: (opts) -> opts.setNewPasswordUrl
+})
+
+templates.confirmEmail = CTAEmailTemplate({
+	subject: () -> "Confirm Email - #{settings.appName}"
+	title: () -> "Confirm Email"
+	message: () -> "Please confirm your email on #{settings.appName}."
+	ctaText: () -> "Confirm Email"
+	ctaURL: (opts) -> opts.confirmEmailUrl
+})
+
+templates.projectInvite = CTAEmailTemplate({
+	subject: (opts) -> "#{opts.project.name} - shared by #{opts.owner.email}"
+	title: (opts) -> "#{ opts.project.name } - shared by #{ opts.owner.email }"
+	message: (opts) -> "#{ opts.owner.email } wants to share '#{ opts.project.name }' with you."
+	ctaText: () -> "View project"
+	ctaURL: (opts) -> opts.inviteUrl
+	gmailGoToAction: (opts) ->
+		target: opts.inviteUrl
+		name: "View project"
+		description: "Join #{ opts.project.name } at #{ settings.appName }"
+})
+
+templates.verifyEmailToJoinTeam = CTAEmailTemplate({
+	subject: (opts) -> "#{ opts.inviterName } has invited you to join a team on #{settings.appName}"
+	title: (opts) -> "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
+	message: (opts) -> "Please click the button below to join the team and enjoy the benefits of an upgraded #{ settings.appName } account."
+	ctaText: (opts) -> "Join now"
+	ctaURL: (opts) -> opts.acceptInviteUrl
+})
+
+templates.testEmail = CTAEmailTemplate({
+	subject: () -> "A Test Email from #{settings.appName}"
+	title: () -> "A Test Email from #{settings.appName}"
+	greeting: () -> "Hi,"
+	message: () -> "This is a test Email from #{settings.appName}"
+	ctaText: () -> "Open #{settings.appName}"
+	ctaURL: () -> settings.siteUrl
+})
 
 module.exports =
 	templates: templates
-
+	CTAEmailTemplate: CTAEmailTemplate
 	buildEmail: (templateName, opts)->
 		template = templates[templateName]
 		opts.siteUrl = settings.siteUrl
@@ -180,5 +125,4 @@ module.exports =
 			subject : template.subject(opts)
 			html: template.layout(opts)
 			text: template?.plainTextTemplate?(opts)
-			type:template.type
 		}

+ 8 - 0
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -61,6 +61,13 @@ ProjectHistoryDisabledError = (message) ->
 	return error
 ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype
 
+V1ConnectionError = (message) ->
+	error = new Error(message)
+	error.name = "V1ConnectionError"
+	error.__proto__ = V1ConnectionError.prototype
+	return error
+V1ConnectionError.prototype.__proto___ = Error.prototype
+
 module.exports = Errors =
 	NotFoundError: NotFoundError
 	ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -71,3 +78,4 @@ module.exports = Errors =
 	UnsupportedExportRecordsError: UnsupportedExportRecordsError
 	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError
+	V1ConnectionError: V1ConnectionError

+ 72 - 51
services/web/app/coffee/Features/LinkedFiles/LinkedFilesController.coffee

@@ -4,11 +4,27 @@ ProjectLocator = require '../Project/ProjectLocator'
 Settings = require 'settings-sharelatex'
 logger = require 'logger-sharelatex'
 _ = require 'underscore'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+
+	UrlFetchFailedError,
+	InvalidUrlError,
+	OutputFileFetchFailedError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	BadDataError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	SourceFileNotFoundError,
+} = require './LinkedFilesErrors'
+
 
 module.exports = LinkedFilesController = {
+
 	Agents: {
 		url: require('./UrlAgent'),
-		project_file: require('./ProjectFileAgent')
+		project_file: require('./ProjectFileAgent'),
+		project_output_file: require('./ProjectOutputFileAgent')
 	}
 
 	_getAgent: (provider) ->
@@ -18,15 +34,6 @@ module.exports = LinkedFilesController = {
 			return null
 		LinkedFilesController.Agents[provider]
 
-	_getFileById: (project_id, file_id, callback=(err, file)->) ->
-		ProjectLocator.findElement {
-			project_id,
-			element_id: file_id,
-			type: 'file'
-		}, (err, file, path, parentFolder) ->
-			return callback(err) if err?
-			callback(null, file, path, parentFolder)
-
 	createLinkedFile: (req, res, next) ->
 		{project_id} = req.params
 		{name, provider, data, parent_folder_id} = req.body
@@ -37,23 +44,23 @@ module.exports = LinkedFilesController = {
 		if !Agent?
 			return res.sendStatus(400)
 
-		linkedFileData = Agent.sanitizeData(data)
-		linkedFileData.provider = provider
-
-		if !Agent.canCreate(linkedFileData)
-			return res.status(403).send('Cannot create linked file')
+		data.provider = provider
 
-		LinkedFilesController._doImport(
-			req, res, next, Agent, project_id, user_id,
-			parent_folder_id, name, linkedFileData
-		)
+		Agent.createLinkedFile project_id,
+			data,
+			name,
+			parent_folder_id,
+			user_id,
+			(err, newFileId) ->
+				return LinkedFilesController.handleError(err, req, res, next) if err?
+				res.json(new_file_id: newFileId)
 
 	refreshLinkedFile: (req, res, next) ->
 		{project_id, file_id} = req.params
 		user_id = AuthenticationController.getLoggedInUserId(req)
 		logger.log {project_id, file_id, user_id}, 'refresh linked file request'
 
-		LinkedFilesController._getFileById project_id, file_id, (err, file, path, parentFolder) ->
+		LinkedFilesHandler.getFileById project_id, file_id, (err, file, path, parentFolder) ->
 			return next(err) if err?
 			return res.sendStatus(404) if !file?
 			name = file.name
@@ -65,37 +72,51 @@ module.exports = LinkedFilesController = {
 			Agent = LinkedFilesController._getAgent(provider)
 			if !Agent?
 				return res.sendStatus(400)
-			LinkedFilesController._doImport(
-				req, res, next, Agent, project_id, user_id,
-				parent_folder_id, name, linkedFileData
+
+			Agent.refreshLinkedFile project_id,
+				linkedFileData,
+				name,
+				parent_folder_id,
+				user_id,
+				(err, newFileId) ->
+					return LinkedFilesController.handleError(err, req, res, next) if err?
+					res.json(new_file_id: newFileId)
+
+	handleError: (error, req, res, next) ->
+		if error instanceof BadDataError
+			res.status(400).send("The submitted data is not valid")
+
+		else 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 if error instanceof V1ProjectNotFoundError
+			res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
+
+		else if error instanceof OutputFileFetchFailedError
+			res.status(404).send("Could not get output file")
+
+		else if error instanceof UrlFetchFailedError
+			res.status(422).send(
+				"Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
 			)
 
-	_doImport: (req, res, next, Agent, project_id, user_id, parent_folder_id, name, linkedFileData) ->
-		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
+		else if error instanceof InvalidUrlError
+			res.status(422).send(
+				"Your URL is not valid. Please check it and try again."
+			)
 
-	}
+		else
+			next(error)
+}

+ 84 - 0
services/web/app/coffee/Features/LinkedFiles/LinkedFilesErrors.coffee

@@ -0,0 +1,84 @@
+UrlFetchFailedError = (message) ->
+	error = new Error(message)
+	error.name = 'UrlFetchFailedError'
+	error.__proto__ = UrlFetchFailedError.prototype
+	return error
+UrlFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+InvalidUrlError = (message) ->
+	error = new Error(message)
+	error.name = 'InvalidUrlError'
+	error.__proto__ = InvalidUrlError.prototype
+	return error
+InvalidUrlError.prototype.__proto__ = Error.prototype
+
+
+OutputFileFetchFailedError = (message) ->
+	error = new Error(message)
+	error.name = 'OutputFileFetchFailedError'
+	error.__proto__ = OutputFileFetchFailedError.prototype
+	return error
+OutputFileFetchFailedError.prototype.__proto__ = Error.prototype
+
+
+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
+
+
+V1ProjectNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'V1ProjectNotFound'
+	error.__proto__ = V1ProjectNotFoundError.prototype
+	return error
+V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
+
+
+SourceFileNotFoundError = (message) ->
+	error = new Error(message)
+	error.name = 'SourceFileNotFound'
+	error.__proto__ = SourceFileNotFoundError.prototype
+	return error
+SourceFileNotFoundError.prototype.__proto__ = Error.prototype
+
+
+module.exports = {
+
+	UrlFetchFailedError,
+	InvalidUrlError,
+	OutputFileFetchFailedError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	BadDataError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	SourceFileNotFoundError,
+}

+ 86 - 0
services/web/app/coffee/Features/LinkedFiles/LinkedFilesHandler.coffee

@@ -0,0 +1,86 @@
+FileWriter = require '../../infrastructure/FileWriter'
+EditorController = require '../Editor/EditorController'
+ProjectLocator = require '../Project/ProjectLocator'
+Project = require("../../models/Project").Project
+ProjectGetter = require("../Project/ProjectGetter")
+_ = require 'underscore'
+{
+	ProjectNotFoundError,
+	V1ProjectNotFoundError,
+	BadDataError
+} = require './LinkedFilesErrors'
+
+
+module.exports = LinkedFilesHandler =
+
+	getFileById: (project_id, file_id, callback=(err, file)->) ->
+		ProjectLocator.findElement {
+			project_id,
+			element_id: file_id,
+			type: 'file'
+		}, (err, file, path, parentFolder) ->
+			return callback(err) if err?
+			callback(null, file, path, parentFolder)
+
+	getSourceProject: (data, callback=(err, project)->) ->
+		projection = {_id: 1, name: 1}
+		if data.v1_source_doc_id?
+			Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
+				return callback(err) if err?
+				if !project?
+					return callback(new V1ProjectNotFoundError())
+				callback(null, project)
+		else if data.source_project_id?
+			ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
+				return callback(err) if err?
+				if !project?
+					return callback(new ProjectNotFoundError())
+				callback(null, project)
+		else
+			callback(new BadDataError('neither v1 nor v2 id present'))
+
+	importFromStream: (
+		project_id,
+		readStream,
+		linkedFileData,
+		name,
+		parent_folder_id,
+		user_id,
+		callback=(err, file)->
+	) ->
+		callback = _.once(callback)
+		FileWriter.writeStreamToDisk project_id, readStream, (err, fsPath) ->
+			return callback(err) if err?
+			EditorController.upsertFile project_id,
+				parent_folder_id,
+				name,
+				fsPath,
+				linkedFileData,
+				"upload",
+				user_id,
+				(err, file) =>
+					return callback(err) if err?
+					callback(null, file)
+
+	importContent: (
+		project_id,
+		content,
+		linkedFileData,
+		name,
+		parent_folder_id,
+		user_id,
+		callback=(err, file)->
+	) ->
+		callback = _.once(callback)
+		FileWriter.writeContentToDisk project_id, content, (err, fsPath) ->
+			return callback(err) if err?
+			EditorController.upsertFile project_id,
+				parent_folder_id,
+				name,
+				fsPath,
+				linkedFileData,
+				"upload",
+				user_id,
+				(err, file) =>
+					return callback(err) if err?
+					callback(null, file)

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

@@ -1,68 +1,94 @@
-FileWriter = require('../../infrastructure/FileWriter')
 AuthorizationManager = require('../Authorization/AuthorizationManager')
 ProjectLocator = require('../Project/ProjectLocator')
 ProjectGetter = require('../Project/ProjectGetter')
-Project = require("../../models/Project").Project
 DocstoreManager = require('../Docstore/DocstoreManager')
 FileStoreHandler = require('../FileStore/FileStoreHandler')
-FileWriter = require('../../infrastructure/FileWriter')
 _ = require "underscore"
 Settings = require 'settings-sharelatex'
+LinkedFilesHandler = require './LinkedFilesHandler'
+{
+	BadDataError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	SourceFileNotFoundError,
+	ProjectNotFoundError,
+	V1ProjectNotFoundError
+} = require './LinkedFilesErrors'
+
+module.exports = ProjectFileAgent = {
+
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		if !@_canCreate(linkedFileData)
+			return callback(new AccessDeniedError())
+		@_go(project_id, linkedFileData, name, parent_folder_id, user_id, callback)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@_go project_id, linkedFileData, name, parent_folder_id, user_id, callback
+
+	_prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+		@_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+			return callback(err) if err?
+			return callback(new AccessDeniedError()) if !allowed
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			callback(null, linkedFileData)
 
+	_go: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		linkedFileData = @_sanitizeData(linkedFileData)
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			@_getEntity linkedFileData, user_id, (err, source_project, entity, type) =>
+				return callback(err) if err?
+				if type == 'doc'
+					DocstoreManager.getDoc source_project._id, entity._id, (err, lines) ->
+						return callback(err) if err?
+						LinkedFilesHandler.importContent project_id,
+							lines.join('\n'),
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+				else if type == 'file'
+					FileStoreHandler.getFileStream source_project._id, entity._id, null, (err, fileStream) ->
+						return callback(err) if err?
+						LinkedFilesHandler.importFromStream project_id,
+							fileStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+				else
+					callback(new BadEntityTypeError())
+
+	_getEntity:
+		(linkedFileData, current_user_id, callback = (err, entity, type) ->) ->
+			callback = _.once(callback)
+			{ source_entity_path } = linkedFileData
+			@_getSourceProject linkedFileData, (err, project) ->
+				return callback(err) if err?
+				source_project_id = project._id
+				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)
+					callback(null, project, entity, type)
 
-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
-
-
-V1ProjectNotFoundError = (message) ->
-	error = new Error(message)
-	error.name = 'V1ProjectNotFound'
-	error.__proto__ = V1ProjectNotFoundError.prototype
-	return error
-V1ProjectNotFoundError.prototype.__proto__ = Error.prototype
-
-
-SourceFileNotFoundError = (message) ->
-	error = new Error(message)
-	error.name = 'SourceFileNotFound'
-	error.__proto__ = SourceFileNotFoundError.prototype
-	return error
-SourceFileNotFoundError.prototype.__proto__ = Error.prototype
-
-
-module.exports = ProjectFileAgent =
-
-	sanitizeData: (data) ->
+	_sanitizeData: (data) ->
 		return _.pick(
 			data,
+			'provider',
 			'source_project_id',
 			'v1_source_doc_id',
 			'source_entity_path'
@@ -74,34 +100,13 @@ module.exports = ProjectFileAgent =
 			data.source_entity_path?
 		)
 
-	canCreate: (data) ->
+	_canCreate: (data) ->
 		# Don't allow creation of linked-files with v1 doc ids
 		!data.v1_source_doc_id?
 
-	_getSourceProject: (data, callback=(err, project)->) ->
-		projection = {_id: 1, name: 1}
-		if data.v1_source_doc_id?
-			Project.findOne {'overleaf.id': data.v1_source_doc_id}, projection, (err, project) ->
-				return callback(err) if err?
-				if !project?
-					return callback(new V1ProjectNotFoundError())
-				callback(null, project)
-		else if data.source_project_id?
-			ProjectGetter.getProject data.source_project_id, projection, (err, project) ->
-				return callback(err) if err?
-				if !project?
-					return callback(new ProjectNotFoundError())
-				callback(null, project)
-		else
-			callback(new BadDataError('neither v1 nor v2 id present'))
+	_getSourceProject: LinkedFilesHandler.getSourceProject
 
-	decorateLinkedFileData: (data, callback = (err, newData) ->) ->
-		callback = _.once(callback)
-		@_getSourceProject data, (err, project) ->
-			return callback(err) if err?
-			callback(err, _.extend(data, {source_project_display_name: project.name}))
-
-	checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
+	_checkAuth: (project_id, data, current_user_id, callback = (error, allowed)->) ->
 		callback = _.once(callback)
 		if !ProjectFileAgent._validate(data)
 			return callback(new BadDataError())
@@ -110,52 +115,4 @@ module.exports = ProjectFileAgent =
 			AuthorizationManager.canUserReadProject current_user_id, 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_entity_path } = data
-			@_getSourceProject data, (err, project) ->
-				return callback(err) if err?
-				source_project_id = project._id
-				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 if error instanceof V1ProjectNotFoundError
-			res.status(409).send("Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file")
-		else
-			next(error)
-		next()
+}

+ 158 - 0
services/web/app/coffee/Features/LinkedFiles/ProjectOutputFileAgent.coffee

@@ -0,0 +1,158 @@
+AuthorizationManager = require('../Authorization/AuthorizationManager')
+ProjectGetter = require('../Project/ProjectGetter')
+Settings = require 'settings-sharelatex'
+CompileManager = require '../Compile/CompileManager'
+ClsiManager = require '../Compile/ClsiManager'
+ProjectFileAgent = require './ProjectFileAgent'
+_ = require "underscore"
+{
+	BadDataError,
+	AccessDeniedError,
+	BadEntityTypeError,
+	OutputFileFetchFailedError
+} = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
+logger = require 'logger-sharelatex'
+
+
+module.exports = ProjectOutputFileAgent = {
+
+	_prepare: (project_id, linkedFileData, user_id, callback=(err, linkedFileData)->) ->
+		@_checkAuth project_id, linkedFileData, user_id, (err, allowed) =>
+			return callback(err) if err?
+			return callback(new AccessDeniedError()) if !allowed
+			if !@_validate(linkedFileData)
+				return callback(new BadDataError())
+			callback(null, linkedFileData)
+
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		if !@_canCreate(linkedFileData)
+			return callback(new AccessDeniedError())
+		linkedFileData = @_sanitizeData(linkedFileData)
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			@_getFileStream linkedFileData, user_id, (err, readStream) =>
+				return callback(err) if err?
+				readStream.on "error", callback
+				readStream.on "response", (response) =>
+					if 200 <= response.statusCode < 300
+						readStream.resume()
+						LinkedFilesHandler.importFromStream project_id,
+							readStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+					else
+						err = new OutputFileFetchFailedError(
+							"Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+						)
+						err.statusCode = response.statusCode
+						callback(err)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@_prepare project_id, linkedFileData, user_id, (err, linkedFileData) =>
+			return callback(err) if err?
+			@_compileAndGetFileStream linkedFileData, user_id, (err, readStream, new_build_id) =>
+				return callback(err) if err?
+				readStream.on "error", callback
+				readStream.on "response", (response) =>
+					if 200 <= response.statusCode < 300
+						readStream.resume()
+						linkedFileData.build_id = new_build_id
+						LinkedFilesHandler.importFromStream project_id,
+							readStream,
+							linkedFileData,
+							name,
+							parent_folder_id,
+							user_id,
+							(err, file) ->
+								return callback(err) if err?
+								callback(null, file._id) # Created
+					else
+						err = new OutputFileFetchFailedError(
+							"Output file fetch failed: #{linkedFileData.build_id}, #{linkedFileData.source_output_file_path}"
+						)
+						err.statusCode = response.statusCode
+						callback(err)
+
+
+	_sanitizeData: (data) ->
+		return {
+			provider: data.provider,
+			source_project_id: data.source_project_id,
+			source_output_file_path: data.source_output_file_path,
+			build_id: data.build_id
+		}
+
+	_canCreate: ProjectFileAgent._canCreate
+
+	_getSourceProject: LinkedFilesHandler.getSourceProject
+
+	_validate: (data) ->
+		return (
+			(data.source_project_id? || data.v1_source_doc_id?) &&
+			data.source_output_file_path? &&
+			data.build_id?
+		)
+
+	_checkAuth: (project_id, data, current_user_id, callback = (err, allowed)->) ->
+		callback = _.once(callback)
+		if !@_validate(data)
+			return callback(new BadDataError())
+		@_getSourceProject data, (err, project) ->
+			return callback(err) if err?
+			AuthorizationManager.canUserReadProject current_user_id,
+				project._id,
+				null,
+				(err, canRead) ->
+					return callback(err) if err?
+					callback(null, canRead)
+
+	_getFileStream: (linkedFileData, user_id, callback=(err, fileStream)->) ->
+		callback = _.once(callback)
+		{ source_output_file_path, build_id } = linkedFileData
+		@_getSourceProject linkedFileData, (err, project) ->
+			return callback(err) if err?
+			source_project_id = project._id
+			ClsiManager.getOutputFileStream source_project_id,
+				user_id,
+				build_id,
+				source_output_file_path,
+				(err, readStream) ->
+					return callback(err) if err?
+					readStream.pause()
+					callback(null, readStream)
+
+	_compileAndGetFileStream: (linkedFileData, user_id, callback=(err, stream, build_id)->) ->
+		callback = _.once(callback)
+		{ source_output_file_path } = linkedFileData
+		@_getSourceProject linkedFileData, (err, project) ->
+			return callback(err) if err?
+			source_project_id = project._id
+			CompileManager.compile source_project_id,
+				user_id,
+				{},
+				(err, status, outputFiles) ->
+					return callback(err) if err?
+					if status != 'success'
+						return callback(new OutputFileFetchFailedError())
+					outputFile = _.find(
+						outputFiles,
+						(o) => o.path == source_output_file_path
+					)
+					if !outputFile?
+						return callback(new OutputFileFetchFailedError())
+					build_id = outputFile.build
+					ClsiManager.getOutputFileStream source_project_id,
+						user_id,
+						build_id,
+						source_output_file_path,
+						(err, readStream) ->
+							return callback(err) if err?
+							readStream.pause()
+							callback(null, readStream, build_id)
+}

+ 33 - 47
services/web/app/coffee/Features/LinkedFiles/UrlAgent.coffee

@@ -1,67 +1,53 @@
 request = require 'request'
-FileWriter = require('../../infrastructure/FileWriter')
 _ = require "underscore"
 urlValidator = require 'valid-url'
 Settings = require 'settings-sharelatex'
+{ InvalidUrlError, UrlFetchFailedError } = require './LinkedFilesErrors'
+LinkedFilesHandler = require './LinkedFilesHandler'
 
-UrlFetchFailedError = (message) ->
-	error = new Error(message)
-	error.name = 'UrlFetchFailedError'
-	error.__proto__ = UrlFetchFailedError.prototype
-	return error
-UrlFetchFailedError.prototype.__proto__ = Error.prototype
-
-InvalidUrlError = (message) ->
-	error = new Error(message)
-	error.name = 'InvalidUrlError'
-	error.__proto__ = InvalidUrlError.prototype
-	return error
-InvalidUrlError.prototype.__proto__ = Error.prototype
 
 module.exports = UrlAgent = {
-	UrlFetchFailedError: UrlFetchFailedError
-	InvalidUrlError: InvalidUrlError
 
-	sanitizeData: (data) ->
+	createLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		linkedFileData = @._sanitizeData(linkedFileData)
+		@_getUrlStream project_id, linkedFileData, user_id, (err, readStream) ->
+			return callback(err) if err?
+			readStream.on "error", callback
+			readStream.on "response", (response) ->
+				if 200 <= response.statusCode < 300
+					readStream.resume()
+					LinkedFilesHandler.importFromStream project_id,
+						readStream,
+						linkedFileData,
+						name,
+						parent_folder_id,
+						user_id,
+						(err, file) ->
+							return callback(err) if err?
+							callback(null, file._id) # Created
+				else
+					error = new UrlFetchFailedError("url fetch failed: #{linkedFileData.url}")
+					error.statusCode = response.statusCode
+					callback(error)
+
+	refreshLinkedFile: (project_id, linkedFileData, name, parent_folder_id, user_id, callback) ->
+		@createLinkedFile project_id, linkedFileData, name, parent_folder_id, user_id, callback
+
+	_sanitizeData: (data) ->
 		return {
+			provider: data.provider
 			url: @._prependHttpIfNeeded(data.url)
 		}
 
-	canCreate: (data) -> true
-
-	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) ->) ->
+	_getUrlStream: (project_id, data, current_user_id, callback = (error, fsPath) ->) ->
 		callback = _.once(callback)
 		url = data.url
 		if !urlValidator.isWebUri(url)
 			return callback(new InvalidUrlError("invalid url: #{url}"))
-		url = UrlAgent._wrapWithProxy(url)
+		url = @_wrapWithProxy(url)
 		readStream = request.get(url)
-		readStream.on "error", callback
-		readStream.on "response", (response) ->
-			if 200 <= response.statusCode < 300
-				FileWriter.writeStreamToDisk project_id, readStream, callback
-			else
-				error = new UrlFetchFailedError("url fetch failed: #{url}")
-				error.statusCode = response.statusCode
-				callback(error)
-
-	handleError: (error, req, res, next) ->
-		if error instanceof UrlFetchFailedError
-			res.status(422).send(
-				"Your URL could not be reached (#{error.statusCode} status code). Please check it and try again."
-			)
-		else if error instanceof InvalidUrlError
-			res.status(422).send(
-				"Your URL is not valid. Please check it and try again."
-			)
-		else
-			next(error)
+		readStream.pause()
+		callback(null, readStream)
 
 	_prependHttpIfNeeded: (url) ->
 		if !url.match('://')

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

@@ -14,7 +14,7 @@ module.exports =
 			if !user? or user.holdingAccount
 				logger.err email:email, "user could not be found for password reset"
 				return callback(null, false)
-			OneTimeTokenHandler.getNewToken user._id, (err, token)->
+			OneTimeTokenHandler.getNewToken 'password', user._id, (err, token)->
 				if err then return callback(err)
 				emailOptions =
 					to : email
@@ -24,7 +24,7 @@ module.exports =
 					callback null, true
 
 	setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
-		OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
+		OneTimeTokenHandler.getValueFromTokenAndExpire 'password', token, (err, user_id)->
 			if err then return callback(err)
 			if !user_id?
 				return callback null, false, null

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

@@ -27,6 +27,7 @@ CollaboratorsHandler = require '../Collaborators/CollaboratorsHandler'
 Modules = require '../../infrastructure/Modules'
 ProjectEntityHandler = require './ProjectEntityHandler'
 crypto = require 'crypto'
+{ V1ConnectionError } = require '../Errors/Errors'
 
 module.exports = ProjectController =
 
@@ -179,11 +180,14 @@ module.exports = ProjectController =
 				ProjectGetter.findAllUsersProjects user_id, 'name lastUpdated publicAccesLevel archived owner_ref tokens', cb
 			v1Projects: (cb) ->
 				Modules.hooks.fire "findAllV1Projects", user_id, (error, projects = []) ->
-					if error? and error.message == 'No V1 connection'
+					if error? and error instanceof V1ConnectionError
 						return cb(null, projects: [], tags: [], noConnection: true)
 					return cb(error, projects[0]) # hooks.fire returns an array of results, only need first
 			hasSubscription: (cb)->
-				LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, cb
+				LimitationsManager.userHasSubscriptionOrIsGroupMember currentUser, (error, hasSub) ->
+					if error? and error instanceof V1ConnectionError
+						return cb(null, true)
+					return cb(error, hasSub)
 			user: (cb) ->
 				User.findById user_id, "featureSwitches overleaf awareOfV2 features", cb
 			}, (err, results)->
@@ -210,7 +214,7 @@ module.exports = ProjectController =
 						tags: tags
 						notifications: notifications or []
 						user: user
-						hasSubscription: results.hasSubscription[0]
+						hasSubscription: results.hasSubscription
 						isShowingV1Projects: results.v1Projects?
 						warnings: warnings
 					}

+ 4 - 1
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -55,7 +55,10 @@ module.exports = ProjectEntityUpdateHandler = self =
 						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)
+					fileProperties = name : SafePath.clean(origonalFileRef.name)
+					if origonalFileRef.linkedFileData?
+						fileProperties.linkedFileData = origonalFileRef.linkedFileData
+					fileRef = new File(fileProperties)
 					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"

+ 35 - 19
services/web/app/coffee/Features/Security/OneTimeTokenHandler.coffee

@@ -1,34 +1,50 @@
 Settings = require('settings-sharelatex')
-RedisWrapper = require("../../infrastructure/RedisWrapper")
-rclient = RedisWrapper.client("one_time_token")
 crypto = require("crypto")
 logger = require("logger-sharelatex")
+{db} = require "../../infrastructure/mongojs"
+Errors = require "../Errors/Errors"
 
 ONE_HOUR_IN_S = 60 * 60
 
-buildKey = (token)-> return "password_token:#{token}"
-
 module.exports =
-
-	getNewToken: (value, options = {}, callback)->
+	getNewToken: (use, data, options = {}, callback = (error, data) ->)->
 		# options is optional
 		if typeof options == "function"
 			callback = options
 			options = {}
 		expiresIn = options.expiresIn or ONE_HOUR_IN_S
-		logger.log value:value, "generating token for password reset"
+		createdAt = new Date()
+		expiresAt = new Date(createdAt.getTime() + expiresIn * 1000)
 		token = crypto.randomBytes(32).toString("hex")
-		multi = rclient.multi()
-		multi.set buildKey(token), value
-		multi.expire buildKey(token), expiresIn
-		multi.exec (err)->
-			callback(err, token)
+		logger.log {data, expiresIn, token_start: token.slice(0,8)}, "generating token for #{use}"
+		db.tokens.insert {
+			use: use
+			token: token,
+			data: data,
+			createdAt: createdAt,
+			expiresAt: expiresAt
+		}, (error) ->
+			return callback(error) if error?
+			callback null, token
 
-	getValueFromTokenAndExpire: (token, callback)->
-		logger.log token:token, "getting user id from password token"
-		multi = rclient.multi()
-		multi.get buildKey(token)
-		multi.del buildKey(token)
-		multi.exec (err, results)->
-			callback err, results?[0]
+	getValueFromTokenAndExpire: (use, token, callback = (error, data) ->)->
+		logger.log token_start: token.slice(0,8), "getting data from #{use} token"
+		now = new Date()
+		db.tokens.findAndModify {
+			query: {
+				use: use,
+				token: token,
+				expiresAt: { $gt: now },
+				usedAt: { $exists: false }
+			},
+			update: {
+				$set: {
+					usedAt: now
+				}
+			}
+		}, (error, token) ->
+			return callback(error) if error?
+			if !token?
+				return callback(new Errors.NotFoundError('no token found'))
+			return callback null, token.data
 

+ 14 - 1
services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee

@@ -56,6 +56,20 @@ module.exports = TeamInvitesHandler =
 			return callback(error) if error?
 			createInvite(subscription, email, inviterName, callback)
 
+	importInvite: (subscription, inviterName, email, token, sentAt, callback) ->
+		checkIfInviteIsPossible subscription, email, (error, possible, reason) ->
+			return callback(error) if error?
+			return callback(reason) unless possible
+
+			subscription.teamInvites.push({
+				email: email
+				inviterName: inviterName
+				token: token
+				sentAt: sentAt
+			})
+
+			subscription.save callback
+
 	acceptInvite: (token, userId, callback) ->
 		logger.log {userId}, "Accepting invite"
 		TeamInvitesHandler.getInvite token, (err, invite, subscription) ->
@@ -93,7 +107,6 @@ createInvite = (subscription, email, inviterName, callback) ->
 		return callback(error) if error?
 		return callback(reason) unless possible
 
-
 		invite = subscription.teamInvites.find (invite) -> invite.email == email
 
 		if !invite?

+ 5 - 1
services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee

@@ -2,6 +2,7 @@ UserGetter = require "../User/UserGetter"
 request = require "request"
 settings = require "settings-sharelatex"
 logger = require "logger-sharelatex"
+{ V1ConnectionError } = require "../Errors/Errors"
 
 module.exports = V1SubscriptionManager =
 	# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
@@ -58,7 +59,10 @@ module.exports = V1SubscriptionManager =
 				json: true,
 				timeout: 5 * 1000
 			}, (error, response, body) ->
-				return callback(error) if error?
+				if error?
+					# Specially handle no connection err, so warning can be shown
+					error = new V1ConnectionError('No V1 connection') if error.code == 'ECONNREFUSED'
+					return callback(error)
 				if 200 <= response.statusCode < 300
 					return callback null, body
 				else

+ 36 - 0
services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee

@@ -0,0 +1,36 @@
+EmailHelper = require "../Helpers/EmailHelper"
+EmailHandler = require "../Email/EmailHandler"
+OneTimeTokenHandler = require "../Security/OneTimeTokenHandler"
+settings = require 'settings-sharelatex'
+Errors = require "../Errors/Errors"
+logger = require "logger-sharelatex"
+UserUpdater = require "./UserUpdater"
+
+ONE_YEAR_IN_S = 365 * 24 * 60 * 60
+
+module.exports = UserEmailsConfirmationHandler =
+	sendConfirmationEmail: (user_id, email, emailTemplate, callback = (error) ->) ->
+		if arguments.length == 3
+			callback = emailTemplate
+			emailTemplate = 'confirmEmail'
+		email = EmailHelper.parseEmail(email)
+		return callback(new Error('invalid email')) if !email?
+		data = {user_id, email}
+		OneTimeTokenHandler.getNewToken 'email_confirmation', data, {expiresIn: ONE_YEAR_IN_S}, (err, token)->
+			return callback(err) if err?
+			emailOptions =
+				to: email
+				confirmEmailUrl: "#{settings.siteUrl}/user/emails/confirm?token=#{token}"
+			EmailHandler.sendEmail emailTemplate, emailOptions, callback
+
+	confirmEmailFromToken: (token, callback = (error) ->) ->
+		logger.log {token_start: token.slice(0,8)}, 'confirming email from token'
+		OneTimeTokenHandler.getValueFromTokenAndExpire 'email_confirmation', token, (error, data) ->
+			return callback(error) if error?
+			if !data?
+				return callback(new Errors.NotFoundError('no token found'))
+			{user_id, email} = data
+			logger.log {data, user_id, email, token_start: token.slice(0,8)}, 'found data for email confirmation'
+			if !user_id? or email != EmailHelper.parseEmail(email)
+				return callback(new Errors.NotFoundError('invalid data'))
+			UserUpdater.confirmEmail user_id, email, callback

+ 39 - 10
services/web/app/coffee/Features/User/UserEmailsController.coffee

@@ -2,42 +2,71 @@ AuthenticationController = require('../Authentication/AuthenticationController')
 UserGetter = require("./UserGetter")
 UserUpdater = require("./UserUpdater")
 EmailHelper = require("../Helpers/EmailHelper")
+UserEmailsConfirmationHandler = require "./UserEmailsConfirmationHandler"
 logger = require("logger-sharelatex")
+Errors = require "../Errors/Errors"
 
 module.exports = UserEmailsController =
 
-	list: (req, res) ->
+	list: (req, res, next) ->
 		userId = AuthenticationController.getLoggedInUserId(req)
 		UserGetter.getUserFullEmails userId, (error, fullEmails) ->
-			return res.sendStatus 500 if error?
+			return next(error) if error?
 			res.json fullEmails
 
 
-	add: (req, res) ->
+	add: (req, res, next) ->
 		userId = AuthenticationController.getLoggedInUserId(req)
 		email = EmailHelper.parseEmail(req.body.email)
 		return res.sendStatus 422 unless email?
 
-		UserUpdater.addEmailAddress userId, email, (error)->
-			return res.sendStatus 500 if error?
-			res.sendStatus 200
+		affiliationOptions =
+			university: req.body.university
+			role: req.body.role
+			department: req.body.department
+		UserUpdater.addEmailAddress userId, email, affiliationOptions, (error)->
+			return next(error) if error?
+			UserEmailsConfirmationHandler.sendConfirmationEmail userId, email, (err) ->
+				return next(error) if error?
+				res.sendStatus 204
 
 
-	remove: (req, res) ->
+	remove: (req, res, next) ->
 		userId = AuthenticationController.getLoggedInUserId(req)
 		email = EmailHelper.parseEmail(req.body.email)
 		return res.sendStatus 422 unless email?
 
 		UserUpdater.removeEmailAddress userId, email, (error)->
-			return res.sendStatus 500 if error?
+			return next(error) if error?
 			res.sendStatus 200
 
 
-	setDefault: (req, res) ->
+	setDefault: (req, res, next) ->
 		userId = AuthenticationController.getLoggedInUserId(req)
 		email = EmailHelper.parseEmail(req.body.email)
 		return res.sendStatus 422 unless email?
 
 		UserUpdater.setDefaultEmailAddress userId, email, (error)->
-			return res.sendStatus 500 if error?
+			return next(error) if error?
 			res.sendStatus 200
+
+	showConfirm: (req, res, next) ->
+		res.render 'user/confirm_email', {
+			token: req.query.token,
+			title: 'confirm_email'
+		}
+
+	confirm: (req, res, next) ->
+		token = req.body.token
+		if !token?
+			return res.sendStatus 422
+		UserEmailsConfirmationHandler.confirmEmailFromToken token, (error) ->
+			if error?
+				if error instanceof Errors.NotFoundError
+					res.status(404).json({
+						message: 'Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.'
+					})
+				else
+					next(error)
+			else
+				res.sendStatus 200

+ 34 - 5
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -3,6 +3,8 @@ metrics = require('metrics-sharelatex')
 logger = require('logger-sharelatex')
 db = mongojs.db
 ObjectId = mongojs.ObjectId
+settings = require "settings-sharelatex"
+request = require "request"
 
 module.exports = UserGetter =
 	getUser: (query, projection, callback = (error, user) ->) ->
@@ -30,11 +32,9 @@ module.exports = UserGetter =
 			return callback error if error?
 			return callback new Error('User not Found') unless user
 
-			fullEmails = user.emails.map (emailData) ->
-				emailData.default = emailData.email == user.email
-				emailData
-
-			callback null, fullEmails
+			getAffiliations userId, (error, affiliationsData) ->
+				return callback error if error?
+				callback null, decorateFullEmails(user.email, user.emails, affiliationsData)
 
 	getUserByMainEmail: (email, projection, callback = (error, user) ->) ->
 		email = email.trim()
@@ -81,6 +81,35 @@ module.exports = UserGetter =
 			return callback(message: 'alread_exists') if user?
 			callback(error)
 
+decorateFullEmails = (defaultEmail, emailsData, affiliationsData) ->
+	emailsData.map (emailData) ->
+		emailData.default = emailData.email == defaultEmail
+
+		affiliation = affiliationsData.find (aff) -> aff.email == emailData.email
+		if affiliation?
+			{ institution, inferred, role, department } = affiliation
+			emailData.affiliation = { institution, inferred, role, department }
+		else
+			emailsData.affiliation = null
+
+		emailData
+
+getAffiliations = (userId, callback = (error) ->) ->
+	return callback(null, []) unless settings?.apis?.v1?.url # service is not configured
+	request {
+		method: 'GET'
+		url: "#{settings.apis.v1.url}/api/v2/users/#{userId.toString()}/affiliations"
+		auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+		json: true,
+		timeout: 20 * 1000
+	}, (error, response, body) ->
+		return callback(error) if error?
+		unless 200 <= response.statusCode < 300
+			errorMessage = "Couldn't get affiliations: #{response.statusCode}"
+			return callback(new Error(errorMessage))
+
+		callback(null, body)
+
 [
 	'getUser',
 	'getUserEmail',

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

@@ -74,7 +74,7 @@ module.exports = UserRegistrationHandler =
 				logger.log {email}, "user already exists, resending welcome email"
 
 			ONE_WEEK = 7 * 24 * 60 * 60 # seconds
-			OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
+			OneTimeTokenHandler.getNewToken 'password', user._id, { expiresIn: ONE_WEEK }, (err, token)->
 				return callback(err) if err?
 				
 				setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}"

+ 87 - 13
services/web/app/coffee/Features/User/UserUpdater.coffee

@@ -5,6 +5,10 @@ db = mongojs.db
 async = require("async")
 ObjectId = mongojs.ObjectId
 UserGetter = require("./UserGetter")
+EmailHelper = require "../Helpers/EmailHelper"
+Errors = require "../Errors/Errors"
+settings = require "settings-sharelatex"
+request = require "request"
 
 module.exports = UserUpdater =
 	updateUser: (query, update, callback = (error) ->) ->
@@ -42,30 +46,43 @@ module.exports = UserUpdater =
 
 	# Add a new email address for the user. Email cannot be already used by this
 	# or any other user
-	addEmailAddress: (userId, newEmail, callback) ->
+	addEmailAddress: (userId, newEmail, affiliationOptions, callback) ->
+		unless callback? # affiliationOptions is optional
+			callback = affiliationOptions
+			affiliationOptions = {}
+
 		UserGetter.ensureUniqueEmailAddress newEmail, (error) =>
 			return callback(error) if error?
 
-			update = $push: emails: email: newEmail, createdAt: new Date()
-			@updateUser userId, update, (error) ->
+			addAffiliation userId, newEmail, affiliationOptions, (error) =>
 				if error?
-					logger.err error: error, 'problem updating users emails'
+					logger.err error: error, 'problem adding affiliation'
 					return callback(error)
-				callback()
 
+				update = $push: emails: email: newEmail, createdAt: new Date()
+				@updateUser userId, update, (error) ->
+					if error?
+						logger.err error: error, 'problem updating users emails'
+						return callback(error)
+					callback()
 
 	# remove one of the user's email addresses. The email cannot be the user's
 	# default email address
 	removeEmailAddress: (userId, email, callback) ->
-		query = _id: userId, email: $ne: email
-		update = $pull: emails: email: email
-		@updateUser query, update, (error, res) ->
+		removeAffiliation userId, email, (error) =>
 			if error?
-				logger.err error:error, 'problem removing users email'
+				logger.err error: error, 'problem removing affiliation'
 				return callback(error)
-			if res.nMatched == 0
-				return callback(new Error('Cannot remove default email'))
-			callback()
+
+			query = _id: userId, email: $ne: email
+			update = $pull: emails: email: email
+			@updateUser query, update, (error, res) ->
+				if error?
+					logger.err error:error, 'problem removing users email'
+					return callback(error)
+				if res.n == 0
+					return callback(new Error('Cannot remove email'))
+				callback()
 
 
 	# set the default email address by setting the `email` attribute. The email
@@ -77,10 +94,67 @@ module.exports = UserUpdater =
 			if error?
 				logger.err error:error, 'problem setting default emails'
 				return callback(error)
-			if res.nMatched == 0
+			if res.n == 0 # TODO: Check n or nMatched?
 				return callback(new Error('Default email does not belong to user'))
 			callback()
 
+	confirmEmail: (userId, email, callback) ->
+		email = EmailHelper.parseEmail(email)
+		return callback(new Error('invalid email')) if !email?
+		logger.log {userId, email}, 'confirming user email'
+		query =
+			_id: userId
+			'emails.email': email
+		update =
+			$set:
+				'emails.$.confirmedAt': new Date()
+		@updateUser query, update, (error, res) ->
+			return callback(error) if error?
+			logger.log {res, userId, email}, "tried to confirm email"
+			if res.n == 0
+				return callback(new Errors.NotFoundError('user id and email do no match'))
+			callback()
+
+addAffiliation = (userId, email, { university, department, role }, callback = (error) ->) ->
+	makeAffiliationRequest {
+		method: 'POST'
+		path: "/api/v2/users/#{userId.toString()}/affiliations"
+		body: { email, university, department, role }
+		defaultErrorMessage: "Couldn't create affiliation"
+	}, callback
+
+removeAffiliation = (userId, email, callback = (error) ->) ->
+	email = encodeURIComponent(email)
+	makeAffiliationRequest {
+		method: 'DELETE'
+		path: "/api/v2/users/#{userId.toString()}/affiliations/#{email}"
+		extraSuccessStatusCodes: [404] # `Not Found` responses are considered successful
+		defaultErrorMessage: "Couldn't remove affiliation"
+	}, callback
+
+makeAffiliationRequest = (requestOptions, callback = (error) ->) ->
+	return callback(null) unless settings?.apis?.v1?.url # service is not configured
+	requestOptions.extraSuccessStatusCodes ||= []
+	request {
+		method: requestOptions.method
+		url: "#{settings.apis.v1.url}#{requestOptions.path}"
+		body: requestOptions.body
+		auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass }
+		json: true,
+		timeout: 20 * 1000
+	}, (error, response, body) ->
+		return callback(error) if error?
+		isSuccess = 200 <= response.statusCode < 300
+		isSuccess ||= response.statusCode in requestOptions.extraSuccessStatusCodes
+		unless isSuccess
+			if body?.errors
+				errorMessage = "#{response.statusCode}: #{body.errors}"
+			else
+				errorMessage = "#{requestOptions.defaultErrorMessage}: #{response.statusCode}"
+			return callback(new Error(errorMessage))
+
+		callback(null)
+
 [
 	'updateUser'
 	'changeEmailAddress'

+ 4 - 1
services/web/app/coffee/infrastructure/FileWriter.coffee

@@ -15,11 +15,14 @@ module.exports = FileWriter =
 			callback(null)
 
 	writeLinesToDisk: (identifier, lines, callback = (error, fsPath)->) ->
+		FileWriter.writeContentToDisk(identifier, lines.join('\n'), callback)
+
+	writeContentToDisk: (identifier, content, 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) ->
+			fs.writeFile fsPath, content, (error) ->
 				return callback(error) if error?
 				callback(null, fsPath)
 

+ 0 - 16
services/web/app/coffee/infrastructure/OldAssetProxy.coffee

@@ -1,16 +0,0 @@
-settings = require("settings-sharelatex")
-logger = require("logger-sharelatex")
-request = require("request")
-
-module.exports = (req, res, next)->
-	requestedUrl = req.url
-
-	redirectUrl = settings.proxyUrls[requestedUrl]
-	if redirectUrl?
-		logger.log redirectUrl:redirectUrl, reqUrl:req.url, "proxying url"
-		upstream = request(redirectUrl)
-		upstream.on "error", (error) ->
-			logger.error err: error, "error in OldAssetProxy"
-		upstream.pipe(res)
-	else
-		next()

+ 36 - 0
services/web/app/coffee/infrastructure/ProxyManager.coffee

@@ -0,0 +1,36 @@
+settings = require 'settings-sharelatex'
+logger = require 'logger-sharelatex'
+request = require 'request'
+URL = require 'url'
+
+module.exports = ProxyManager =
+	call: (req, res, next) ->
+		requestUrl = URL.parse(req.url)
+		requestPath = requestUrl.pathname # ignore the query part
+
+		target = settings.proxyUrls[requestPath]
+		return next() unless target? # nothing to proxy
+
+		targetUrl = makeTargetUrl(target, req.query)
+		logger.log targetUrl: targetUrl, reqUrl: req.url, "proxying url"
+
+		upstream = request(targetUrl)
+		upstream.on "error", (error) ->
+			logger.error err: error, "error in ProxyManager"
+
+		# TODO: better handling of status code
+		# see https://github.com/overleaf/write_latex/wiki/Streams-and-pipes-in-Node.js
+		upstream.pipe(res)
+
+# make a URL from a proxy target.
+# if the query is specified, set/replace the target's query with the given query
+makeTargetUrl = (target, query) ->
+	targetUrl = URL.parse(parseSettingUrl(target))
+	if query? and Object.keys(query).length > 0
+		targetUrl.query = query
+		targetUrl.search = null # clear `search` as it takes precedence over `query`
+	targetUrl.format()
+
+parseSettingUrl = (target) ->
+	return target if typeof target is 'string'
+	"#{target.baseUrl}#{target.path or ''}"

+ 2 - 2
services/web/app/coffee/infrastructure/Server.coffee

@@ -32,7 +32,7 @@ Mongoose = require("./Mongoose")
 oneDayInMilliseconds = 86400000
 ReferalConnect = require('../Features/Referal/ReferalConnect')
 RedirectManager = require("./RedirectManager")
-OldAssetProxy = require("./OldAssetProxy")
+ProxyManager = require("./ProxyManager")
 translations = require("translations-sharelatex").setup(Settings.i18n)
 Modules = require "./Modules"
 
@@ -74,7 +74,7 @@ app.use methodOverride()
 
 app.use metrics.http.monitor(logger)
 app.use RedirectManager
-app.use OldAssetProxy
+app.use ProxyManager.call
 
 
 webRouter.use cookieParser(Settings.security.sessionSecret)

+ 1 - 1
services/web/app/coffee/infrastructure/mongojs.coffee

@@ -1,6 +1,6 @@
 Settings = require "settings-sharelatex"
 mongojs = require "mongojs"
-db = mongojs(Settings.mongo.url, ["projects", "users", "userstubs"])
+db = mongojs(Settings.mongo.url, ["projects", "users", "userstubs", "tokens"])
 module.exports =
 	db: db
 	ObjectId: mongojs.ObjectId

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

@@ -10,7 +10,8 @@ UserSchema = new Schema
 	email             : {type : String, default : ''}
 	emails: [{
 		email: { type : String, default : '' },
-		createdAt: { type : Date, default: () -> new Date() }
+		createdAt: { type : Date, default: () -> new Date() },
+		confirmedAt: { type: Date }
 	}],
 	first_name        : {type : String, default : ''}
 	last_name         : {type : String, default : ''}

+ 4 - 0
services/web/app/coffee/router.coffee

@@ -120,6 +120,10 @@ module.exports = class Router
 		webRouter.post '/user/emails/default',
 			AuthenticationController.requireLogin(),
 			UserEmailsController.setDefault
+		webRouter.get '/user/emails/confirm',
+			UserEmailsController.showConfirm
+		webRouter.post '/user/emails/confirm',
+			UserEmailsController.confirm
 
 		webRouter.get  '/user/sessions',
 			AuthenticationController.requireLogin(),

+ 21 - 3
services/web/app/views/project/editor/binary-file.pug

@@ -38,6 +38,7 @@ div.binary-file.full-size(
 	) #{translate("no_preview_available")}
 
 	div.binary-file-footer
+		// Linked Files: URL
 		div(ng-if="openFile.linkedFileData.provider == 'url'")
 			p
 				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -47,6 +48,7 @@ div.binary-file.full-size(
 				|
 				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
 
+		// Linked Files: Project File
 		div(ng-if="openFile.linkedFileData.provider == 'project_file'")
 			p
 				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
@@ -54,14 +56,30 @@ div.binary-file.full-size(
 				|
 				a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
 					ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
-					| {{ openFile.linkedFileData.source_project_display_name }}
+					| Another project
 				span(ng-if='openFile.linkedFileData.v1_source_doc_id')
-					| {{ openFile.linkedFileData.source_project_display_name }}
+					| Another project
 				| /{{ 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'")
+		// Linked Files: Project Output File
+		div(ng-if="openFile.linkedFileData.provider == 'project_output_file'")
+			p
+				i.fa.fa-fw.fa-external-link-square.fa-rotate-180.linked-file-icon
+				| Imported from the output of
+				|
+				a(ng-if='!openFile.linkedFileData.v1_source_doc_id'
+					ng-href='/project/{{openFile.linkedFileData.source_project_id}}' target="_blank")
+					| Another project
+				span(ng-if='openFile.linkedFileData.v1_source_doc_id')
+					| Another project
+				| : {{ openFile.linkedFileData.source_output_file_path }},
+				|
+				| at {{ openFile.created | formatDate:'h:mm a' }} {{ openFile.created | relativeDate }}
+
+		// Bottom Controls
+		span(ng-if="openFile.linkedFileData.provider")
 			button.btn.btn-success(
 				href, ng-click="refreshFile(openFile)",
 				ng-disabled="refreshing"

+ 7 - 221
services/web/app/views/project/editor/file-tree.pug

@@ -308,165 +308,10 @@ script(type='text/ng-template', id='entityListItemTemplate')
 					ng-repeat="child in entity.children | orderBy:[orderByFoldersFirst, 'name']"
 				)
 
-script(type='text/ng-template', id='newDocModalTemplate')
-	.modal-header
-		h3 #{translate("new_file")}
-	.modal-body
-		form(novalidate, name="newDocForm")
-			div.alert.alert-danger(ng-if="error")
-				div(ng-switch="error") 
-					span(ng-switch-when="already exists") #{translate("file_already_exists")}
-					span(ng-switch-default) {{error}}
-			input.form-control(
-				type="text",
-				placeholder="File Name",
-				required,
-				ng-model="inputs.name",
-				on-enter="create()",
-				select-name-on="open",
-				valid-file,
-				name="name"
-			)
-			.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
-				| #{translate('files_cannot_include_invalid_characters')}
-	.modal-footer
-		button.btn.btn-default(
-			ng-disabled="state.inflight"
-			ng-click="cancel()"
-		) #{translate("cancel")}
-		button.btn.btn-primary(
-			ng-disabled="newDocForm.$invalid || state.inflight"
-			ng-click="create()"
-		)
-			span(ng-hide="state.inflight") #{translate("create")}
-			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
-	.modal-body
-		form(novalidate, name="newLinkedFileForm")
-			div.alert.alert-danger(ng-if="error")
-				div(ng-switch="error")
-					span(ng-switch-when="already exists") #{translate("file_already_exists")}
-					span(ng-switch-default) {{error}}
-			label(for="url") URL to fetch the file from
-			input.form-control(
-				type="text",
-				placeholder="www.example.com/my_file",
-				required,
-				ng-model="inputs.url",
-				focus-on="open",
-				on-enter="create()",
-				name="url"
-			)
-			.row-spaced
-				label(for="name") File name in this project
-				input.form-control(
-					type="text",
-					placeholder="my_file",
-					required,
-					ng-model="inputs.name",
-					ng-change="nameChangedByUser = true"
-					valid-file,
-					on-enter="create()",
-					name="name"
-				)
-				.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
-					| #{translate('files_cannot_include_invalid_characters')}
-	.modal-footer
-		button.btn.btn-default(
-			ng-disabled="state.inflight"
-			ng-click="cancel()"
-		) #{translate("cancel")}
-		button.btn.btn-primary(
-			ng-disabled="newLinkedFileForm.$invalid || state.inflight"
-			ng-click="create()"
-		)
-			span(ng-hide="state.inflight") #{translate("create")}
-			span(ng-show="state.inflight") #{translate("creating")}...
-
-
 script(type='text/ng-template', id='newFolderModalTemplate')
 	.modal-header
 		h3 #{translate("new_folder")}
 	.modal-body
-		div.alert.alert-danger(ng-if="error")
-			div(ng-switch="error") 
-				span(ng-switch-when="already exists") #{translate("file_already_exists")}
-				span(ng-switch-default) {{error}}
 		form(novalidate, name="newFolderForm")
 			input.form-control(
 				type="text",
@@ -478,8 +323,12 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 				valid-file,
 				name="name"
 			)
-			.text-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
-				| #{translate('files_cannot_include_invalid_characters')}
+		div.alert.alert-danger.row-spaced-small(ng-show="newFolderForm.name.$error.validFile")
+			| #{translate('files_cannot_include_invalid_characters')}
+		div.alert.alert-danger.row-spaced-small(ng-if="error")
+			div(ng-switch="error")
+				span(ng-switch-when="already exists") #{translate("file_already_exists")}
+				span(ng-switch-default) {{error}}
 	.modal-footer
 		button.btn.btn-default(
 			ng-disabled="state.inflight"
@@ -492,70 +341,7 @@ script(type='text/ng-template', id='newFolderModalTemplate')
 			span(ng-hide="state.inflight") #{translate("create")}
 			span(ng-show="state.inflight") #{translate("creating")}...
 
-
-script(type="text/template", id="qq-file-uploader-template")
-	div.qq-uploader-selector
-		div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
-			span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
-		div.qq-upload-button-selector.btn.btn-primary.btn-lg
-			div #{translate('upload')}
-		span.or.btn-lg #{translate('or')}
-		span.drag-here.btn-lg #{translate('drag_here')}
-		span.qq-drop-processing-selector
-			span #{translate('processing')}
-			span.qq-drop-processing-spinner-selector
-		ul.qq-upload-list-selector
-			li
-				div.qq-progress-bar-container-selector
-					div(
-						role="progressbar"
-						aria-valuenow="0"
-						aria-valuemin="0"
-						aria-valuemax="100"
-						class="qq-progress-bar-selector qq-progress-bar"
-					)
-				span.qq-upload-file-selector.qq-upload-file
-				span.qq-upload-size-selector.qq-upload-size
-				a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
-				button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
-				span(role="status").qq-upload-status-text-selector.qq-upload-status-text
-
-script(type="text/ng-template", id="uploadFileModalTemplate")
-	.modal-header
-		h3 #{translate("upload_files")}
-		.alert.alert-warning.small.modal-alert(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
-		.alert.alert-warning.small.modal-alert(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
-		.alert.alert-warning.small.modal-alert(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
-		.alert.alert-warning.small.modal-alert(ng-if="conflicts.length > 0")
-			p.text-center
-				| The following files already exist in this project:
-			ul.text-center.list-unstyled.row-spaced-small
-				li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
-			p.text-center.row-spaced-small
-				| Do you want to overwrite them?
-			p.text-center
-				a(href, ng-click="doUpload()").btn.btn-primary Overwrite
-				| &nbsp;
-				a(href, ng-click="cancel()").btn.btn-default Cancel
-
-	.modal-body(
-		fine-upload
-		endpoint="/project/{{ project_id }}/upload"
-		template-id="qq-file-uploader-template"
-		multiple="true"
-		auto-upload="false"
-		on-complete-callback="onComplete"
-		on-upload-callback="onUpload"
-		on-validate-batch="onValidateBatch"
-		on-error-callback="onError"
-		on-submit-callback="onSubmit"
-		on-cancel-callback="onCancel"
-		control="control"
-		params="{'folder_id': parent_folder_id}"
-	)
-	.modal-footer
-		button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
-
+include ./new-file-modal
 
 script(type='text/ng-template', id='deleteEntityModalTemplate')
 	.modal-header

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

@@ -69,14 +69,6 @@ aside#left-menu.full-size(
 				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")}

+ 217 - 0
services/web/app/views/project/editor/new-file-modal.pug

@@ -0,0 +1,217 @@
+script(type='text/ng-template', id='newFileModalTemplate')
+	.modal-header
+		h3 Add Files
+	.modal-body.modal-new-file
+		table
+			tr
+				td.modal-new-file--list
+					ul.list-unstyled
+						li(ng-class="type == 'doc' ? 'active' : null")
+							a(href, ng-click="type = 'doc'")
+								i.fa.fa-fw.fa-file
+								|
+								| New File
+						li(ng-class="type == 'upload' ? 'active' : null")
+							a(href, ng-click="type = 'upload'")
+								i.fa.fa-fw.fa-upload
+								|
+								| Upload
+						li(ng-class="type == 'project' ? 'active' : null")
+							a(href, ng-click="type = 'project'")
+								i.fa.fa-fw.fa-folder-open
+								|
+								| From Another Project
+						li(ng-class="type == 'url' ? 'active' : null")
+							a(href, ng-click="type = 'url'")
+								i.fa.fa-fw.fa-globe
+								|
+								| From External URL
+				td(class="modal-new-file--body modal-new-file--body-{{type}}")
+					div(ng-if="type == 'doc'", ng-controller="NewDocModalController")
+						form(novalidate, name="newDocForm")
+							label(for="name") File Name
+							input.form-control(
+								type="text",
+								placeholder="File Name",
+								required,
+								ng-model="inputs.name",
+								on-enter="create()",
+								select-name-on="open",
+								valid-file,
+								name="name"
+							)
+						div.alert.alert-danger.row-spaced-small(ng-if="error")
+							div(ng-switch="error")
+								span(ng-switch-when="already exists") #{translate("file_already_exists")}
+								span(ng-switch-default) {{error}}
+						div.alert.alert-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+							| #{translate('files_cannot_include_invalid_characters')}
+					div(ng-if="type == 'upload'", ng-controller="UploadFileModalController")
+						.alert.alert-warning.small.modal(ng-if="tooManyFiles") #{translate("maximum_files_uploaded_together", {max:"{{max_files}}"})}
+						.alert.alert-warning.small.modal(ng-if="rateLimitHit") #{translate("too_many_files_uploaded_throttled_short_period")}
+						.alert.alert-warning.small.modal(ng-if="notLoggedIn") #{translate("session_expired_redirecting_to_login", {seconds:"{{secondsToRedirect}}"})}
+						.alert.alert-warning.small.modal(ng-if="conflicts.length > 0")
+							p.text-center
+								| The following files already exist in this project:
+							ul.text-center.list-unstyled.row-spaced-small
+								li(ng-repeat="conflict in conflicts"): strong {{ conflict }}
+							p.text-center.row-spaced-small
+								| Do you want to overwrite them?
+							p.text-center
+								a(href, ng-click="doUpload()").btn.btn-primary Overwrite
+								| &nbsp;
+								a(href, ng-click="cancel()").btn.btn-default Cancel
+						div(
+							fine-upload
+							endpoint="/project/{{ project_id }}/upload"
+							template-id="qq-file-uploader-template"
+							multiple="true"
+							auto-upload="false"
+							on-complete-callback="onComplete"
+							on-upload-callback="onUpload"
+							on-validate-batch="onValidateBatch"
+							on-error-callback="onError"
+							on-submit-callback="onSubmit"
+							on-cancel-callback="onCancel"
+							control="control"
+							params="{'folder_id': parent_folder_id}"
+						)
+					div(ng-if="type == 'project'", ng-controller="ProjectLinkedFileModalController")
+						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 }}
+
+								.form-controls.row-spaced-small(ng-if="!state.isOutputFilesMode")
+									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) }}
+
+								.form-controls.row-spaced-small(ng-if="state.isOutputFilesMode")
+									label(for="project-entity-select") Select an Output File
+									span(ng-show="state.inFlight.compile")
+										| &nbsp;
+										i.fa.fa-spinner.fa-spin
+									select.form-control(
+										name="project-output-file-select"
+										ng-model="data.selectedProjectOutputFile"
+										ng-disabled="!shouldEnableProjectOutputFileSelect()"
+									)
+										option(value="" disabled selected) - Please Select an Output File
+										option(
+											ng-repeat="outputFile in data.projectOutputFiles"
+											value="{{ outputFile.path }}"
+										) {{ outputFile.path }}
+								div.toggle-output-files-button
+									| or&nbsp;
+									a(
+										href="#"
+										ng-click="toggleOutputFilesMode()"
+									)
+										span(ng-show="state.isOutputFilesMode") select from source files
+										span(ng-show="!state.isOutputFilesMode") select from output files
+
+								.form-controls.row-spaced-small
+									label(for="name") File Name In This Project
+									input.form-control(
+										type="text"
+										placeholder="example.tex"
+										required
+										ng-model="data.name"
+										name="name"
+									)
+						div.alert.alert-danger.row-spaced-small(ng-if="state.error") Error, something went wrong!
+					div(ng-if="type == 'url'", ng-controller="UrlLinkedFileModalController")
+						form(novalidate, name="newLinkedFileForm")
+							label(for="url") URL to fetch the file from
+							input.form-control(
+								type="text",
+								placeholder="www.example.com/my_file",
+								required,
+								ng-model="inputs.url",
+								focus-on="open",
+								on-enter="create()",
+								name="url"
+							)
+							.row-spaced.small
+								label(for="name") File name in this project
+								input.form-control(
+									type="text",
+									placeholder="my_file",
+									required,
+									ng-model="inputs.name",
+									ng-change="nameChangedByUser = true"
+									valid-file,
+									on-enter="create()",
+									name="name"
+								)
+								.text-danger.row-spaced-small(ng-show="newDocForm.name.$error.validFile")
+									| #{translate('files_cannot_include_invalid_characters')}
+						div.alert.alert-danger.row-spaced-small(ng-if="error")
+							div(ng-switch="error")
+								span(ng-switch-when="already exists") #{translate("file_already_exists")}
+								span(ng-switch-default) {{error}}
+	.modal-footer
+		button.btn.btn-default(
+			ng-disabled="state.inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-primary(
+			ng-disabled="state.inflight || !state.valid"
+			ng-click="create()"
+			ng-hide="type == 'upload'"
+		)
+			span(ng-hide="state.inflight") #{translate("create")}
+			span(ng-show="state.inflight") #{translate("creating")}...
+
+script(type="text/template", id="qq-file-uploader-template")
+	div.qq-uploader-selector
+		div(qq-hide-dropzone="").qq-upload-drop-area-selector.qq-upload-drop-area
+			span.qq-upload-drop-area-text-selector #{translate('drop_files_here_to_upload')}
+		div Drag here
+		div.row-spaced-small.small #{translate('or')}
+		div.row-spaced-small
+			div.qq-upload-button-selector.btn.btn-primary
+				| Select from your computer
+		span.qq-drop-processing-selector
+			span #{translate('processing')}
+			span.qq-drop-processing-spinner-selector
+		ul.qq-upload-list-selector
+			li
+				div.qq-progress-bar-container-selector
+					div(
+						role="progressbar"
+						aria-valuenow="0"
+						aria-valuemin="0"
+						aria-valuemax="100"
+						class="qq-progress-bar-selector qq-progress-bar"
+					)
+				span.qq-upload-file-selector.qq-upload-file
+				span.qq-upload-size-selector.qq-upload-size
+				a(type="button").qq-btn.qq-upload-cancel-selector.qq-upload-cancel #{translate('cancel')}
+				button(type="button").qq-btn.qq-upload-retry-selector.qq-upload-retry #{translate('retry')}
+				span(role="status").qq-upload-status-text-selector.qq-upload-status-text

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

@@ -249,7 +249,7 @@ block content
 								
 							hr
 								
-							p.small.text-center We're confident that you'll love ShareLaTeX, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
+							p.small.text-center We're confident that you'll love #{settings.appName}, but if not you can cancel anytime. We'll give you your money back, no questions asked, if you let us know within 30 days.
 							hr
 							span &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;  &nbsp;
 							a(href="https://www.positivessl.com" style="font-family: arial; font-size: 10px; color: #212121; text-decoration: none;")

+ 28 - 0
services/web/app/views/user/confirm_email.pug

@@ -0,0 +1,28 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
+					.card
+						.page-header
+							h1 #{translate("confirm_email")}
+						form(
+							async-form="confirm-email",
+							name="confirmEmailForm"
+							action="/user/emails/confirm",
+							method="POST",
+							id="confirmEmailForm",
+							auto-submit="true",
+							ng-cloak
+						)
+							input(type="hidden", name="_csrf", value=csrfToken)
+							input(type="hidden", name="token", value=token)
+							form-messages(for="confirmEmailForm")
+								.alert.alert-success(ng-show="confirmEmailForm.response.success")
+									| Thank you, your email is now confirmed
+							p.text-center(ng-show="!confirmEmailForm.response.success && !confirmEmailForm.response.error")
+								i.fa.fa-fw.fa-spin.fa-spinner
+								| 
+								| Confirming your email...

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

@@ -17,7 +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'
+      ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
     depends_on:
       - redis

+ 11 - 5
services/web/public/coffee/directives/asyncForm.coffee

@@ -15,11 +15,6 @@ define [
 				scope[attrs.name].response = response = {}
 				scope[attrs.name].inflight = false
 
-				element.on "submit", (e) ->
-					e.preventDefault()
-					validateCaptchaIfEnabled (response) ->
-						submitRequest response
-
 				validateCaptchaIfEnabled = (callback = (response) ->) ->
 					if attrs.captcha?
 						validateCaptcha callback
@@ -84,6 +79,17 @@ define [
 									text: data.message?.text or data.message or "Something went wrong talking to the server :(. Please try again."
 									type: 'error'
 							ga('send', 'event', formName, 'failure', data.message)
+
+				submit = () ->
+					validateCaptchaIfEnabled (response) ->
+						submitRequest response
+
+				element.on "submit", (e) ->
+					e.preventDefault()
+					submit()
+
+				if attrs.autoSubmit
+					submit()
 		}
 
 	App.directive "formMessages", () ->

+ 30 - 31
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -44,13 +44,11 @@ define [
 
 			GraphicsCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
-					context = Helpers.getContext(editor, pos)
-					{commandFragment, closingBrace} = context
+					{commandFragment} = Helpers.getContext(editor, pos)
 					if commandFragment
 						match = commandFragment.match(/^~?\\(includegraphics(?:\[.*])?){([^}]*, *)?(\w*)/)
 						if match
-							commandName = match[1]
-							currentArg = match[3]
+							[_, commandName, _, currentArg] = match
 							graphicsPaths = Preamble.getGraphicsPaths()
 							result = []
 							for graphic in Graphics.getGraphicsFiles()
@@ -60,8 +58,8 @@ define [
 										path = path.slice(graphicsPath.length)
 										break
 								result.push {
-									caption: "\\#{commandName}{#{path}#{closingBrace}"
-									value: "\\#{commandName}{#{path}#{closingBrace}"
+									caption: "\\#{commandName}{#{path}}"
+									value: "\\#{commandName}{#{path}}"
 									meta: "graphic"
 									score: 50
 								}
@@ -70,20 +68,18 @@ define [
 			metadataManager = @metadataManager
 			FilesCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) =>
-					context = Helpers.getContext(editor, pos)
-					{commandFragment, closingBrace} = context
+					{commandFragment} = Helpers.getContext(editor, pos)
 					if commandFragment
 						match = commandFragment.match(/^\\(input|include){(\w*)/)
 						if match
-							commandName = match[1]
-							currentArg = match[2]
+							[_, commandName, currentArg] = match
 							result = []
 							for file in Files.getTeXFiles()
 								if file.id != @$scope.docId
 									path = file.path
 									result.push {
-										caption: "\\#{commandName}{#{path}#{closingBrace}"
-										value: "\\#{commandName}{#{path}#{closingBrace}"
+										caption: "\\#{commandName}{#{path}}"
+										value: "\\#{commandName}{#{path}}"
 										meta: "file"
 										score: 50
 									}
@@ -91,13 +87,11 @@ define [
 
 			LabelsCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
-					context = Helpers.getContext(editor, pos)
-					{commandFragment, closingBrace} = context
+					{commandFragment} = Helpers.getContext(editor, pos)
 					if commandFragment
 						refMatch = commandFragment.match(/^~?\\([a-z]*ref){([^}]*, *)?(\w*)/)
 						if refMatch
-							commandName = refMatch[1]
-							currentArg = refMatch[2]
+							[_, commandName, currentArg] = refMatch
 							result = []
 							if commandName != 'ref' # ref is in top 100 commands
 								result.push {
@@ -108,8 +102,8 @@ define [
 								}
 							for label in metadataManager.getAllLabels()
 								result.push {
-									caption: "\\#{commandName}{#{label}#{closingBrace}"
-									value: "\\#{commandName}{#{label}#{closingBrace}"
+									caption: "\\#{commandName}{#{label}}"
+									value: "\\#{commandName}{#{label}}"
 									meta: "cross-reference"
 									score: 50
 								}
@@ -118,17 +112,14 @@ define [
 			references = @$scope.$root._references
 			ReferencesCompleter =
 				getCompletions: (editor, session, pos, prefix, callback) ->
-					context = Helpers.getContext(editor, pos)
-					{commandFragment, closingBrace} = context
+					{commandFragment} = Helpers.getContext(editor, pos)
 					if commandFragment
 						citeMatch = commandFragment.match(
 							/^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/
 						)
 						if citeMatch
-							commandName = citeMatch[1]
-							previousArgs = citeMatch[2]
-							currentArg = citeMatch[3]
-							if previousArgs == undefined
+							[_, commandName, previousArgs, currentArg] = citeMatch
+							if !previousArgs?
 								previousArgs = ""
 							previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
 							result = []
@@ -140,10 +131,10 @@ define [
 							}
 							if references.keys and references.keys.length > 0
 								references.keys.forEach (key) ->
-									if !(key in [null, undefined])
+									if key?
 										result.push({
-											caption: "\\#{commandName}{#{previousArgsCaption}#{key}#{closingBrace}"
-											value: "\\#{commandName}{#{previousArgs}#{key}#{closingBrace}"
+											caption: "\\#{commandName}{#{previousArgsCaption}#{key}}"
+											value: "\\#{commandName}{#{previousArgs}#{key}}"
 											meta: "reference"
 											score: 50
 										})
@@ -170,8 +161,7 @@ define [
 		onChange: (change) ->
 			cursorPosition = @editor.getCursorPosition()
 			end = change.end
-			context = Helpers.getContext(@editor, end)
-			{lineUpToCursor, commandFragment} = context
+			{lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
 			if lineUpToCursor.match(/.*%.*/)
 				return
 			lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
@@ -188,10 +178,19 @@ define [
 				end.row == cursorPosition.row and
 				end.column == cursorPosition.column + 1
 			)
-				if (commandFragment? and commandFragment.length > 2) or lastCharIsBackslash
+				if commandFragment?.length > 2 or lastCharIsBackslash
 					setTimeout () =>
 						@editor.execCommand("startAutocomplete")
 					, 0
+			if (
+				change.action == "insert" and
+				change.lines[0].match(/\\(\w+){}/)?[1].match(
+					/(begin|end|[a-z]*ref|usepackage|[a-z]*cite[a-z]*|input|include)/
+				)
+			)
+				setTimeout () =>
+					@editor.execCommand("startAutocomplete")
+				, 0
 
 		monkeyPatchAutocomplete: () ->
 			Autocomplete = ace.require("ace/autocomplete").Autocomplete
@@ -209,7 +208,7 @@ define [
 
 					# If we are in \begin{it|}, then we need to remove the trailing }
 					# since it will be adding in with the autocomplete of \begin{item}...
-					if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
+					if this.completions.filterText.match(/^\\\w+{/) and nextChar == "}"
 						editor.session.remove(range)
 
 					# Provide our own `insertMatch` implementation.

+ 3 - 3
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/CommandManager.coffee

@@ -154,14 +154,14 @@ define [
 				totalArgs = squareArgsNo + curlyArgsNo
 				if totalArgs == 0
 					completionBeforeCursor = completionBase
-					completionAfterCurspr = ""
+					completionAfterCursor = ""
 				else
 					completionBeforeCursor = completionBase + args[0]
 					completionAfterCursor = args.slice(1)
 
 				return {
-					base: base,
-					completion: completionBase + args,
+					base: base
+					completion: completionBase + args
 					completionBeforeCursor: completionBeforeCursor
 					completionAfterCursor: completionAfterCursor
 				}

+ 0 - 4
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.coffee

@@ -160,14 +160,10 @@ define () ->
 
 	hasDocumentEnvironment = (text) ->
 		re = /^\\begin{document}/m
-		envs = []
-		iterations = 0
 		return re.exec(text) != null
 
 	hasBibliographyEnvironment = (text) ->
 		re = /^\\begin{thebibliography}/m
-		envs = []
-		iterations = 0
 		return re.exec(text) != null
 
 	class EnvironmentManager

+ 1 - 4
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Helpers.coffee

@@ -33,14 +33,11 @@ define [
 			commandName = Helpers.getCommandNameFromFragment(commandFragment)
 			beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
 			lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange)
-			needsClosingBrace = !lineBeyondCursor.match(/^[^{]*}/)
-			closingBrace = if needsClosingBrace then '}' else ''
 			return {
 				lineUpToCursor,
 				commandFragment,
 				commandName,
-				lineBeyondCursor,
-				closingBrace
+				lineBeyondCursor
 			}
 
 	return Helpers

+ 3 - 6
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/PackageManager.coffee

@@ -1,6 +1,4 @@
-define [
-	"./Helpers"
-], (Helpers) ->
+define [], () ->
 	packages = [
 		'inputenc', 'graphicx', 'amsmath', 'geometry', 'amssymb', 'hyperref',
 		'babel', 'color', 'xcolor', 'url', 'natbib', 'fontenc', 'fancyhdr',
@@ -26,14 +24,13 @@ define [
 		constructor: (@metadataManager) ->
 
 		getCompletions: (editor, session, pos, prefix, callback) ->
-			{closingBrace} = Helpers.getContext(editor, pos)
 			usedPackages = Object.keys(@metadataManager.getAllPackages())
 			packageSnippets = []
 			for pkg in packages
 				if pkg not in usedPackages
 					packageSnippets.push {
-						caption: "\\usepackage{#{pkg}#{closingBrace}"
-						snippet: "\\usepackage{#{pkg}#{closingBrace}"
+						caption: "\\usepackage{#{pkg}}"
+						snippet: "\\usepackage{#{pkg}}"
 						meta: "pkg"
 					}
 

+ 5 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/top_hundred_snippets.coffee

@@ -73,6 +73,11 @@ define -> [{
     "snippet": "\\documentclass{$1}",
     "meta": "cmd",
     "score": 1.4425339817971206
+}, {
+    "caption": "\\ref{}",
+    "snippet": "\\ref{$1}",
+    "meta": "cross-reference",
+    "score": 0.014379554883991673
 }, {
     "caption": "\\frac{}{}",
     "snippet": "\\frac{$1}{$2}",

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

@@ -4,10 +4,12 @@ define [
 	App.controller "FileTreeController", ["$scope", "$modal", "ide", "$rootScope", ($scope, $modal, ide, $rootScope) ->
 		$scope.openNewDocModal = () ->
 			$modal.open(
-				templateUrl: "newDocModalTemplate"
-				controller:  "NewDocModalController"
+				templateUrl: "newFileModalTemplate"
+				controller:  "NewFileModalController"
+				size: 'lg'
 				resolve: {
 					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+					type: () -> 'doc'
 				}
 			)
 
@@ -22,37 +24,12 @@ define [
 
 		$scope.openUploadFileModal = () ->
 			$modal.open(
-				templateUrl: "uploadFileModalTemplate"
-				controller:  "UploadFileModalController"
-				scope: $scope
-				resolve: {
-					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
-				}
-			)
-
-		$scope.openLinkedFileModal = window.openLinkedFileModal = () ->
-			unless 'url' in window.data.enabledLinkedFileTypes
-				console.warn("Url linked files are not enabled")
-				return
-			$modal.open(
-				templateUrl: "linkedFileModalTemplate"
-				controller:  "LinkedFileModalController"
-				scope: $scope
-				resolve: {
-					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
-				}
-			)
-
-		$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
+				templateUrl: "newFileModalTemplate"
+				controller:  "NewFileModalController"
+				size: 'lg'
 				resolve: {
 					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+					type: () -> 'upload'
 				}
 			)
 
@@ -67,11 +44,11 @@ define [
 			$scope.$broadcast "delete:selected"
 	]
 
-	App.controller "NewDocModalController", [
+	App.controller "NewFolderModalController", [
 		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
 		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
-			$scope.inputs = 
-				name: "name.tex"
+			$scope.inputs =
+				name: "name"
 			$scope.state =
 				inflight: false
 
@@ -86,10 +63,10 @@ define [
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createDoc(name, parent_folder)
+					.createFolder(name, $scope.parent_folder)
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$modalInstance.dismiss('done')
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
@@ -99,42 +76,60 @@ define [
 				$modalInstance.dismiss('cancel')
 	]
 
-	App.controller "NewFolderModalController", [
-		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
-		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
-			$scope.inputs = 
-				name: "name"
-			$scope.state =
+	App.controller "NewFileModalController", [
+		"$scope", "type", "parent_folder", "$modalInstance"
+		($scope,   type,   parent_folder,   $modalInstance) ->
+			$scope.type = type
+			$scope.parent_folder = parent_folder
+			$scope.state = {
 				inflight: false
+				valid: true
+			}
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+			$scope.create = () ->
+				$scope.$broadcast 'create'
+			$scope.$on 'done', () ->
+				$modalInstance.dismiss('done')
+	]
 
-			$modalInstance.opened.then () ->
-				$timeout () ->
-					$scope.$broadcast "open"
-				, 200
+	App.controller "NewDocModalController", [
+		"$scope", "ide", "$timeout"
+		($scope,   ide,   $timeout) ->
+			$scope.inputs = 
+				name: "name.tex"
 
-			$scope.create = () ->
+			validate = () ->
+				name = $scope.inputs.name
+				$scope.state.valid = (name? and name.length > 0)
+			$scope.$watch 'inputs.name', validate
+
+			$timeout () ->
+				$scope.$broadcast "open"
+			, 200
+
+			$scope.$on 'create', () ->
 				name = $scope.inputs.name
 				if !name? or name.length == 0
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createFolder(name, parent_folder)
+					.createDoc(name, $scope.parent_folder)
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
 						$scope.state.inflight = false
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]
 
 	App.controller "UploadFileModalController", [
-		"$scope", "$rootScope", "ide", "$modalInstance", "$timeout", "parent_folder", "$window"
-		($scope,   $rootScope,   ide,   $modalInstance,   $timeout,   parent_folder, $window) ->
-			$scope.parent_folder_id = parent_folder?.id
+		"$scope", "$rootScope", "ide", "$timeout", "$window"
+		($scope,   $rootScope,   ide,   $timeout,   $window) ->
+			$scope.parent_folder_id = $scope.parent_folder?.id
+			$scope.project_id = ide.project_id
 			$scope.tooManyFiles = false
 			$scope.rateLimitHit = false
 			$scope.secondsToRedirect = 10
@@ -162,7 +157,7 @@ define [
 					if response.success
 						$rootScope.$broadcast 'file:upload:complete', response
 					if uploadCount == 0 and response? and response.success
-						$modalInstance.close("done")
+						$scope.$emit 'done'
 				), 250
 
 			$scope.onValidateBatch = (files)->
@@ -210,30 +205,44 @@ define [
 			$scope.doUpload = () ->
 				$scope.control?.q?.uploadStoredFiles()
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]
 
 	App.controller "ProjectLinkedFileModalController", [
-		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
-		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+		"$scope", "ide", "$timeout",
+		($scope,   ide,   $timeout) ->
+
 			$scope.data =
 				projects: null # or []
 				selectedProjectId: null
 				projectEntities: null # or []
+				projectOutputFiles: null # or []
 				selectedProjectEntity: null
+				selectedProjectOutputFile: null
+				buildId: null
 				name: null
-			$scope.state =
-				inFlight:
-					projects: false
-					entities: false
-					create: false
-				error: false
+			$scope.state.inFlight =
+				projects: false
+				entities: false
+				compile: false
+			$scope.state.isOutputFilesMode = false
+			$scope.state.error = false
 
 			$scope.$watch 'data.selectedProjectId', (newVal, oldVal) ->
 				return if !newVal
 				$scope.data.selectedProjectEntity = null
-				$scope.getProjectEntities($scope.data.selectedProjectId)
+				$scope.data.selectedProjectOutputFile = null
+				if $scope.state.isOutputFilesMode
+					$scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+				else
+					$scope.getProjectEntities($scope.data.selectedProjectId)
+
+			$scope.$watch 'state.isOutputFilesMode', (newVal, oldVal) ->
+				return if !newVal and !oldVal
+				$scope.data.selectedProjectOutputFile = null
+				if newVal == true
+					$scope.compileProjectAndGetOutputFiles($scope.data.selectedProjectId)
+				else
+					$scope.getProjectEntities($scope.data.selectedProjectId)
 
 			# auto-set filename based on selected file
 			$scope.$watch 'data.selectedProjectEntity', (newVal, oldVal) ->
@@ -242,15 +251,31 @@ define [
 				if fileName
 					$scope.data.name = fileName
 
+			# auto-set filename based on selected file
+			$scope.$watch 'data.selectedProjectOutputFile', (newVal, oldVal) ->
+				return if !newVal
+				if newVal == 'output.pdf'
+					project = _.find($scope.data.projects, (p) -> p._id == $scope.data.selectedProjectId)
+					$scope.data.name = if project?.name? then "#{project.name}.pdf" else 'output.pdf'
+				else
+					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
+				inFlight.projects = inFlight.entities = inFlight.compile = false
+				$scope.state.inflight = false
 				$scope.state.error = isError
 
+			$scope.toggleOutputFilesMode = () ->
+				return if !$scope.data.selectedProjectId
+				$scope.state.isOutputFilesMode = !$scope.state.isOutputFilesMode
+
 			$scope.shouldEnableProjectSelect = () ->
 				{ state, data } = $scope
 				return !state.inFlight.projects && data.projects
@@ -259,16 +284,33 @@ define [
 				{ state, data } = $scope
 				return !state.inFlight.projects && !state.inFlight.entities && data.projects && data.selectedProjectId
 
-			$scope.shouldEnableCreateButton = () ->
+			$scope.shouldEnableProjectOutputFileSelect = () ->
+				{ state, data } = $scope
+				return !state.inFlight.projects && !state.inFlight.compile && data.projects && data.selectedProjectId
+
+
+			validate = () ->
 				state = $scope.state
 				data = $scope.data
-				return !state.inFlight.projects &&
+				$scope.state.valid = !state.inFlight.projects &&
 					!state.inFlight.entities &&
 					data.projects &&
 					data.selectedProjectId &&
-					data.projectEntities &&
-					data.selectedProjectEntity &&
+					(
+						(
+							!$scope.state.isOutputFilesMode &&
+							data.projectEntities &&
+							data.selectedProjectEntity
+						) ||
+						(
+							$scope.state.isOutputFilesMode &&
+							data.projectOutputFiles &&
+							data.selectedProjectOutputFile
+						)
+					) &&
 					data.name
+			$scope.$watch 'state', validate, true
+			$scope.$watch 'data', validate, true
 
 			$scope.getUserProjects = () ->
 				_setInFlight('projects')
@@ -295,50 +337,84 @@ define [
 				.catch (err) ->
 					_reset(err: true)
 
+			$scope.compileProjectAndGetOutputFiles = (project_id) =>
+				_setInFlight('compile')
+				ide.$http.post("/project/#{project_id}/compile", {
+					check: "silent",
+					draft: false,
+					incrementalCompilesEnabled: false
+					_csrf: window.csrfToken
+				})
+				.then (resp) ->
+					if resp.data.status == 'success'
+						filteredFiles = resp.data.outputFiles.filter (f) ->
+							f.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
+						$scope.data.projectOutputFiles = filteredFiles
+						$scope.data.buildId = filteredFiles?[0]?.build
+						console.log ">> build_id", $scope.data.buildId
+						_reset(err: false)
+					else
+						$scope.data.projectOutputFiles = null
+						_reset(err: true)
+				.catch (err) ->
+					console.error(err)
+					_reset(err: true)
+
 			$scope.init = () ->
 				$scope.getUserProjects()
 			$timeout($scope.init, 0)
 
-			$scope.create = () ->
+			$scope.$on 'create', () ->
 				projectId = $scope.data.selectedProjectId
-				path = $scope.data.selectedProjectEntity
 				name = $scope.data.name
-				if !name || !path || !projectId
-					_reset(err: true)
-					return
+				if $scope.state.isOutputFilesMode
+					provider = 'project_output_file'
+					payload = {
+						source_project_id: projectId,
+						source_output_file_path: $scope.data.selectedProjectOutputFile,
+						build_id: $scope.data.buildId
+					}
+				else
+					provider = 'project_file'
+					payload = {
+						source_project_id: projectId,
+						source_entity_path: $scope.data.selectedProjectEntity
+					}
 				_setInFlight('create')
 				ide.fileTreeManager
-					.createLinkedFile(name, parent_folder, 'project_file', {
-						source_project_id: projectId,
-						source_entity_path: path
-					})
+					.createLinkedFile(name, $scope.parent_folder, provider, payload)
 					.then () ->
 						_reset(err: false)
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.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) ->
+	App.controller "UrlLinkedFileModalController", [
+		"$scope", "ide", "$timeout"
+		($scope,   ide,   $timeout) ->
 			$scope.inputs =
 				name: ""
 				url: ""
 			$scope.nameChangedByUser = false
-			$scope.state =
-				inflight: false
 
-			$modalInstance.opened.then () ->
-				$timeout () ->
-					$scope.$broadcast "open"
-				, 200
+			$timeout () ->
+				$scope.$broadcast "open"
+			, 200
+
+			validate = () ->
+				{name, url} = $scope.inputs
+				if !name? or name.length == 0
+					$scope.state.valid = false
+				else if !url? or url.length == 0
+					$scope.state.valid = false
+				else
+					$scope.state.valid = true
+			$scope.$watch 'inputs.name', validate
+			$scope.$watch 'inputs.url', validate
 
 			$scope.$watch "inputs.url", (url) ->
 				if url? and url != "" and !$scope.nameChangedByUser
@@ -347,7 +423,7 @@ define [
 					if parts.length > 1 # Wait for at one /
 						$scope.inputs.name = parts[0]
 
-			$scope.create = () ->
+			$scope.$on 'create', () ->
 				{name, url} = $scope.inputs
 				if !name? or name.length == 0
 					return
@@ -355,15 +431,13 @@ define [
 					return
 				$scope.state.inflight = true
 				ide.fileTreeManager
-					.createLinkedFile(name, parent_folder, 'url', {url})
+					.createLinkedFile(name, $scope.parent_folder, 'url', {url})
 					.then () ->
 						$scope.state.inflight = false
-						$modalInstance.close()
+						$scope.$emit 'done'
 					.catch (response)->
 						{ data } = response
 						$scope.error = data
 						$scope.state.inflight = false
 
-			$scope.cancel = () ->
-				$modalInstance.dismiss('cancel')
 	]

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

@@ -4,12 +4,6 @@ define [
 ], (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"

+ 40 - 0
services/web/public/stylesheets/app/editor/file-tree.less

@@ -261,3 +261,43 @@
 		}
 	}
 }
+
+.modal-new-file {
+	padding: 0;
+	table {
+		width: 100%;
+		td {
+			vertical-align: top;
+		}
+	}
+	.toggle-output-files-button {
+		font-size: 80%;
+	}
+}
+	.modal-new-file--list {
+		background-color: @modal-footer-background-color;
+		width: 220px;
+		ul {
+			li {
+				padding: (@line-height-computed / 4);
+				a {
+					color: @text-color;
+				}
+			}
+			li.active {
+				background-color: white;
+				a {
+					color: @link-color;
+				}
+			}
+		}
+	}
+
+	.modal-new-file--body {
+		padding: 20px;
+		padding-top: (@line-height-computed / 4);
+	}
+
+	.modal-new-file--body-upload {
+		padding-top: 20px;
+	}

+ 4 - 3
services/web/public/stylesheets/app/project-list.less

@@ -58,6 +58,10 @@
 			background-color: @sidebar-bg;
 			padding-top: @content-margin-vertical;
 			padding-bottom: @content-margin-vertical;
+			color: @sidebar-color;
+			.small {
+				color: @sidebar-color;
+			}
 		} 
 
 		.project-list-sidebar when (@is-overleaf) {
@@ -109,9 +113,6 @@
 	}
 	p {
 		margin-bottom: @line-height-computed / 4;
-		&.small {
-			color: @sidebar-color;
-		}
 	}
 }
 

+ 8 - 4
services/web/public/stylesheets/components/fineupload.less

@@ -10,13 +10,17 @@
 }
 .qq-uploader-selector {
 	text-align: center;
-	.drag-here {
-		border: 1px dashed #666;
-		vertical-align: middle;
-	}
+	border: 1px dashed #666;
+    border-radius: 6px;
+	vertical-align: middle;
 	.help {
 		margin-top: 6px;
 	}
+    min-height: 200px;
+    padding: 20px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
 }
 /*.qq-upload-button-selector {
     display: block;

+ 105 - 2
services/web/test/acceptance/coffee/LinkedFilesTests.coffee

@@ -8,6 +8,8 @@ MockFileStoreApi = require './helpers/MockFileStoreApi'
 request = require "./helpers/request"
 User = require "./helpers/User"
 
+MockClsiApi = require "./helpers/MockClsiApi"
+
 
 express = require("express")
 LinkedUrlProxy = express()
@@ -116,7 +118,6 @@ describe "LinkedFiles", ->
 						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()
@@ -149,7 +150,7 @@ describe "LinkedFiles", ->
 						source_entity_path: "/#{@source_doc_name}",
 			}, (error, response, body) =>
 				expect(response.statusCode).to.equal 403
-				expect(body).to.equal 'Cannot create linked file'
+				expect(body).to.equal 'You do not have access to this project'
 				done()
 
 	describe "with a linked project_file from a v1 project that has not been imported", ->
@@ -344,3 +345,105 @@ describe "LinkedFiles", ->
 
 		# TODO: Add test for asking for host that return ENOTFOUND
 		# (This will probably end up handled by the proxy)
+
+	describe "creating a linked output file", ->
+		before (done) ->
+			async.series [
+				(cb) =>
+					@owner.createProject 'output-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 'output-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)
+			], done
+
+		it 'should import the project.pdf file from the source project', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file",
+				json:
+					name: 'test.pdf',
+					parent_folder_id: @project_one_root_folder_id,
+					provider: 'project_output_file',
+					data:
+						source_project_id: @project_two_id,
+						source_output_file_path: "project.pdf",
+						build_id: '1234-abcd'
+			}, (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_output_file',
+						source_project_id: @project_two_id,
+						source_output_file_path: "project.pdf",
+						build_id: '1234-abcd'
+					}
+					expect(firstFile.name).to.equal('test.pdf')
+					done()
+
+		it 'should refresh the file', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file/#{@existing_file_id}/refresh",
+				json: true
+			}, (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
+				@refreshed_file_id = new_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.pdf')
+					done()
+
+	describe "with a linked project_output_file from a v1 project that has not been imported", ->
+		before (done) ->
+			async.series [
+				(cb) =>
+					@owner.createProject 'output-v1-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()
+						@project_one.rootFolder[0].fileRefs.push {
+							linkedFileData: {
+								provider: "project_output_file",
+								v1_source_doc_id: 9999999,  # We won't find this id in the database
+								source_output_file_path: "project.pdf",
+								build_id: '123'
+							},
+							_id: "abcdef",
+							rev: 0,
+							created: new Date(),
+							name: "whatever.pdf"
+						}
+						@owner.saveProject @project_one, cb
+			], done
+
+		it 'should refuse to refresh', (done) ->
+			@owner.request.post {
+				url: "/project/#{@project_one_id}/linked_file/abcdef/refresh",
+				json: true
+			}, (error, response, body) =>
+				expect(response.statusCode).to.equal 409
+				expect(body).to.equal "Sorry, the source project is not yet imported to Overleaf v2. Please import it to Overleaf v2 to refresh this file"
+				done()

+ 200 - 0
services/web/test/acceptance/coffee/UserEmailsTests.coffee

@@ -0,0 +1,200 @@
+expect = require("chai").expect
+async = require("async")
+User = require "./helpers/User"
+request = require "./helpers/request"
+settings = require "settings-sharelatex"
+{db, ObjectId} = require("../../../app/js/infrastructure/mongojs")
+MockV1Api = require "./helpers/MockV1Api"
+
+describe "UserEmails", ->
+	beforeEach (done) ->
+		@timeout(20000)
+		@user = new User()
+		@user.login done
+
+	describe 'confirming an email', ->
+		it 'should confirm the email', (done) ->
+			token = null
+			async.series [
+				(cb) =>
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails',
+						json:
+							email: 'newly-added-email@example.com'
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 204
+						cb()
+				(cb) =>
+					@user.request { url: '/user/emails', json: true }, (error, response, body) ->
+						expect(response.statusCode).to.equal 200
+						expect(body[0].confirmedAt).to.not.exist
+						expect(body[1].confirmedAt).to.not.exist
+						cb()
+				(cb) =>
+					db.tokens.find {
+						use: 'email_confirmation',
+						'data.user_id': @user._id,
+						usedAt: { $exists: false }
+					}, (error, tokens) =>
+						# There should only be one confirmation token at the moment
+						expect(tokens.length).to.equal 1
+						expect(tokens[0].data.email).to.equal 'newly-added-email@example.com'
+						expect(tokens[0].data.user_id).to.equal @user._id
+						token = tokens[0].token
+						cb()
+				(cb) =>
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails/confirm',
+						json:
+							token: token
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 200
+						cb()
+				(cb) =>
+					@user.request { url: '/user/emails', json: true }, (error, response, body) ->
+						expect(response.statusCode).to.equal 200
+						expect(body[0].confirmedAt).to.not.exist
+						expect(body[1].confirmedAt).to.exist
+						cb()
+				(cb) =>
+					db.tokens.find {
+						use: 'email_confirmation',
+						'data.user_id': @user._id,
+						usedAt: { $exists: false }
+					}, (error, tokens) =>
+						# Token should be deleted after use
+						expect(tokens.length).to.equal 0
+						cb()
+			], done
+
+		it 'should not allow confirmation of the email if the user has changed', (done) ->
+			token1 = null
+			token2 = null
+			@user2 = new User()
+			@email = 'duplicate-email@example.com'
+			async.series [
+				(cb) => @user2.login cb
+				(cb) =>
+					# Create email for first user
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails',
+						json: {@email}
+					}, cb
+				(cb) =>
+					db.tokens.find {
+						use: 'email_confirmation',
+						'data.user_id': @user._id,
+						usedAt: { $exists: false }
+					}, (error, tokens) =>
+						# There should only be one confirmation token at the moment
+						expect(tokens.length).to.equal 1
+						expect(tokens[0].data.email).to.equal @email
+						expect(tokens[0].data.user_id).to.equal @user._id
+						token1 = tokens[0].token
+						cb()
+				(cb) =>
+					# Delete the email from the first user
+					@user.request { 
+						method: 'DELETE',
+						url: '/user/emails',
+						json: {@email}
+					}, cb
+				(cb) =>
+					# Create email for second user
+					@user2.request { 
+						method: 'POST',
+						url: '/user/emails',
+						json: {@email}
+					}, cb
+				(cb) =>
+					# Original confirmation token should no longer work
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails/confirm',
+						json:
+							token: token1
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 404
+						cb()
+				(cb) =>
+					db.tokens.find {
+						use: 'email_confirmation',
+						'data.user_id': @user2._id,
+						usedAt: { $exists: false }
+					}, (error, tokens) =>
+						# The first token has been used, so this should be token2 now
+						expect(tokens.length).to.equal 1
+						expect(tokens[0].data.email).to.equal @email
+						expect(tokens[0].data.user_id).to.equal @user2._id
+						token2 = tokens[0].token
+						cb()
+				(cb) =>
+					# Second user should be able to confirm the email
+					@user2.request {
+						method: 'POST',
+						url: '/user/emails/confirm',
+						json:
+							token: token2
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 200
+						cb()
+				(cb) =>
+					@user2.request { url: '/user/emails', json: true }, (error, response, body) ->
+						expect(response.statusCode).to.equal 200
+						expect(body[0].confirmedAt).to.not.exist
+						expect(body[1].confirmedAt).to.exist
+						cb()
+			], done
+
+	describe "with an expired token", ->
+		it 'should not confirm the email', (done) ->
+			token = null
+			async.series [
+				(cb) =>
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails',
+						json:
+							email: @email = 'expired-token-email@example.com'
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 204
+						cb()
+				(cb) =>
+					db.tokens.find {
+						use: 'email_confirmation',
+						'data.user_id': @user._id,
+						usedAt: { $exists: false }
+					}, (error, tokens) =>
+						# There should only be one confirmation token at the moment
+						expect(tokens.length).to.equal 1
+						expect(tokens[0].data.email).to.equal @email
+						expect(tokens[0].data.user_id).to.equal @user._id
+						token = tokens[0].token
+						cb()
+				(cb) =>
+					db.tokens.update {
+						token: token
+					}, {
+						$set: { 
+							expiresAt: new Date(Date.now() - 1000000)
+						}
+					}, cb
+				(cb) =>
+					@user.request { 
+						method: 'POST',
+						url: '/user/emails/confirm',
+						json:
+							token: token
+					}, (error, response, body) =>
+						return done(error) if error?
+						expect(response.statusCode).to.equal 404
+						cb()
+			], done

+ 12 - 1
services/web/test/acceptance/coffee/helpers/MockClsiApi.coffee

@@ -1,9 +1,11 @@
 express = require("express")
+bodyParser = require "body-parser"
 app = express()
 
 module.exports = MockClsiApi =
 	run: () ->
-		app.post "/project/:project_id/compile", (req, res, next) =>
+
+		compile = (req, res, next) =>
 			res.status(200).send {
 				compile:
 					status: 'success'
@@ -21,6 +23,9 @@ module.exports = MockClsiApi =
 					]
 			}
 
+		app.post "/project/:project_id/compile", compile
+		app.post "/project/:project_id/user/:user_id/compile", compile
+
 		app.get "/project/:project_id/build/:build_id/output/*", (req, res, next) ->
 			filename = req.params[0]
 			if filename == 'project.pdf'
@@ -30,6 +35,12 @@ module.exports = MockClsiApi =
 			else
 				res.sendStatus(404)
 
+		app.get "/project/:project_id/user/:user_id/build/:build_id/output/:output_path", (req, res, next) =>
+			res.status(200).send("hello")
+
+		app.get "/project/:project_id/status", (req, res, next) =>
+			res.status(200).send()
+
 		app.listen 3013, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 8 - 0
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee

@@ -42,6 +42,14 @@ module.exports = MockV1Api =
 			@exportParams = Object.assign({}, req.body)
 			res.json exportId: @exportId
 
+		app.get "/api/v2/users/:userId/affiliations", (req, res, next) =>
+			res.json []
+
+		app.post "/api/v2/users/:userId/affiliations", (req, res, next) =>
+			res.sendStatus 201
+
+		app.delete "/api/v2/users/:userId/affiliations/:email", (req, res, next) =>
+			res.sendStatus 204
 
 		app.listen 5000, (error) ->
 			throw error if error?

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

@@ -41,7 +41,7 @@ describe "PasswordResetHandler", ->
 
 		it "should check the user exists", (done)->
 			@UserGetter.getUserByMainEmail.callsArgWith(1)
-			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
+			@OneTimeTokenHandler.getNewToken.yields()
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
 				done()
@@ -50,7 +50,7 @@ describe "PasswordResetHandler", ->
 		it "should send the email with the token", (done)->
 
 			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
-			@OneTimeTokenHandler.getNewToken.callsArgWith(1, null, @token)
+			@OneTimeTokenHandler.getNewToken.yields(null, @token)
 			@EmailHandler.sendEmail.callsArgWith(2)
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				@EmailHandler.sendEmail.called.should.equal true
@@ -63,7 +63,7 @@ describe "PasswordResetHandler", ->
 		it "should return exists = false for a holdingAccount", (done) ->
 			@user.holdingAccount = true
 			@UserGetter.getUserByMainEmail.callsArgWith(1, null, @user)
-			@OneTimeTokenHandler.getNewToken.callsArgWith(1)
+			@OneTimeTokenHandler.getNewToken.yields()
 			@PasswordResetHandler.generateAndEmailResetToken @user.email, (err, exists)=>
 				exists.should.equal false
 				done()
@@ -71,14 +71,14 @@ describe "PasswordResetHandler", ->
 	describe "setNewUserPassword", ->
 
 		it "should return false if no user id can be found", (done)->
-			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1)
+			@OneTimeTokenHandler.getValueFromTokenAndExpire.yields()
 			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
 				found.should.equal false
 				@AuthenticationManager.setUserPassword.called.should.equal false
 				done()		
 
 		it "should set the user password", (done)->
-			@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
+			@OneTimeTokenHandler.getValueFromTokenAndExpire.yields(null, @user_id)
 			@AuthenticationManager.setUserPassword.callsArgWith(2)
 			@PasswordResetHandler.setNewUserPassword @token, @password, (err, found, user_id) =>
 				found.should.equal true

+ 16 - 0
services/web/test/unit/coffee/Project/ProjectControllerTests.coffee

@@ -5,6 +5,7 @@ path = require('path')
 sinon = require('sinon')
 modulePath = path.join __dirname, "../../../../app/js/Features/Project/ProjectController"
 expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
 
 describe "ProjectController", ->
 
@@ -100,6 +101,7 @@ describe "ProjectController", ->
 			"../Collaborators/CollaboratorsHandler": @CollaboratorsHandler
 			"../../infrastructure/Modules": @Modules
 			"./ProjectEntityHandler": @ProjectEntityHandler
+			"../Errors/Errors": Errors
 
 		@projectName = "£12321jkj9ujkljds"
 		@req =
@@ -303,6 +305,20 @@ describe "ProjectController", ->
 				done()
 			@ProjectController.projectListPage @req, @res
 
+		it 'should send hasSubscription == false when no subscription', (done) ->
+			@res.render = (pageName, opts)=>
+				opts.hasSubscription.should.equal false
+				done()
+			@ProjectController.projectListPage @req, @res
+
+		it 'should send hasSubscription == true when there is a subscription', (done) ->
+			@LimitationsManager.userHasSubscriptionOrIsGroupMember = sinon.stub().callsArgWith(1, null, true)
+			@res.render = (pageName, opts)=>
+				opts.hasSubscription.should.equal true
+				done()
+			@ProjectController.projectListPage @req, @res
+
+
 		describe 'front widget', (done) ->
 			beforeEach ->
 				@settings.overleaf =

+ 33 - 2
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -44,6 +44,8 @@ describe 'ProjectEntityUpdateHandler', ->
 				else
 					@._id = file_id
 				@rev = 0
+				if options.linkedFileData?
+					@linkedFileData = options.linkedFileData
 
 		@docName = "doc-name"
 		@docLines = ['1234','abc']
@@ -121,6 +123,35 @@ describe 'ProjectEntityUpdateHandler', ->
 				.calledWithMatch(project_id, projectHistoryId, userId, changesMatcher)
 				.should.equal true
 
+	describe 'copyFileFromExistingProjectWithProject, with linkedFileData', ->
+
+		beforeEach ->
+			@oldProject_id = "123kljadas"
+			@oldFileRef = {
+				_id:"oldFileRef",
+				name:@fileName,
+				linkedFileData: @linkedFileData
+			}
+			@ProjectEntityMongoUpdateHandler._confirmFolder = sinon.stub().yields(folder_id)
+			@ProjectEntityMongoUpdateHandler._putElement = sinon.stub().yields(null, {path:{fileSystem: @fileSystemPath}})
+
+			@ProjectEntityUpdateHandler.copyFileFromExistingProjectWithProject @project, folder_id, @oldProject_id, @oldFileRef, userId, @callback
+
+		it 'should copy the file in FileStoreHandler', ->
+			@FileStoreHandler.copyFile
+				.calledWith(@oldProject_id, @oldFileRef._id, project_id, file_id)
+				.should.equal true
+
+		it 'should put file into folder by calling put element, with the linkedFileData', ->
+			@ProjectEntityMongoUpdateHandler._putElement
+				.calledWithMatch(
+					@project,
+					folder_id,
+					{ _id: file_id, name: @fileName, linkedFileData: @linkedFileData},
+					"file"
+				)
+				.should.equal true
+
 	describe 'updateDocLines', ->
 		beforeEach ->
 			@path = "/somewhere/something.tex"
@@ -285,7 +316,7 @@ describe 'ProjectEntityUpdateHandler', ->
 		beforeEach ->
 			@path = "/path/to/file"
 
-			@newFile = {_id: file_id, rev: 0, name: @fileName}
+			@newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
 			@TpdsUpdateSender.addFile = sinon.stub().yields()
 			@ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
 			@ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
@@ -330,7 +361,7 @@ describe 'ProjectEntityUpdateHandler', ->
 			@newFileUrl = "new-file-url"
 			@FileStoreHandler.uploadFileFromDisk = sinon.stub().yields(null, @newFileUrl)
 
-			@newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0
+			@newFile = _id: new_file_id, name: "dummy-upload-filename", rev: 0, linkedFileData: @linkedFileData
 			@oldFile = _id: file_id
 			@path = "/path/to/file"
 			@ProjectEntityMongoUpdateHandler._insertDeletedFileReference = sinon.stub().yields()

+ 80 - 45
services/web/test/unit/coffee/Security/OneTimeTokenHandlerTests.coffee

@@ -5,64 +5,99 @@ path = require('path')
 sinon = require('sinon')
 modulePath = path.join __dirname, "../../../../app/js/Features/Security/OneTimeTokenHandler"
 expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
+tk = require("timekeeper")
 
 describe "OneTimeTokenHandler", ->
-
 	beforeEach ->
-		@value = "user id here"
-		@stubbedToken = require("crypto").randomBytes(32)
-
-		@settings = 
-			redis:
-				web:{}
-		@redisMulti =
-			set:sinon.stub()
-			get:sinon.stub()
-			del:sinon.stub()
-			expire:sinon.stub()
-			exec:sinon.stub()
-		self = @
+		tk.freeze Date.now() # freeze the time for these tests
+		@stubbedToken = "mock-token"
+		@callback = sinon.stub()
 		@OneTimeTokenHandler = SandboxedModule.require modulePath, requires:
-			"../../infrastructure/RedisWrapper" :
-				client: =>
-					auth:->
-					multi: -> return self.redisMulti
-
 			"settings-sharelatex":@settings
 			"logger-sharelatex": log:->
 			"crypto": randomBytes: () => @stubbedToken
+			"../Errors/Errors": Errors
+			"../../infrastructure/mongojs": db: @db = tokens: {}
 
+	afterEach ->
+		tk.reset()
 
 	describe "getNewToken", ->
+		beforeEach ->
+			@db.tokens.insert = sinon.stub().yields()
+
+		describe 'normally', ->
+			beforeEach ->
+				@OneTimeTokenHandler.getNewToken 'password', 'mock-data-to-store', @callback
+
+			it "should insert a generated token with a 1 hour expiry", ->
+				@db.tokens.insert
+					.calledWith({
+						use: 'password'
+						token: @stubbedToken,
+						createdAt: new Date(),
+						expiresAt: new Date(Date.now() + 60 * 60 * 1000)
+						data: 'mock-data-to-store'
+					})
+					.should.equal true
+
+			it 'should call the callback with the token', ->
+				@callback.calledWith(null, @stubbedToken).should.equal true
+
+		describe 'with an optional expiresIn parameter', ->
+			beforeEach ->
+				@OneTimeTokenHandler.getNewToken 'password', 'mock-data-to-store', { expiresIn: 42 }, @callback
+
+			it "should insert a generated token with a custom expiry", ->
+				@db.tokens.insert
+					.calledWith({
+						use: 'password'
+						token: @stubbedToken,
+						createdAt: new Date(),
+						expiresAt: new Date(Date.now() + 42 * 1000)
+						data: 'mock-data-to-store'
+					})
+					.should.equal true
 
-		it "should set a new token into redis with a ttl", (done)->
-			@redisMulti.exec.callsArgWith(0) 
-			@OneTimeTokenHandler.getNewToken @value, (err, token) =>
-				@redisMulti.set.calledWith("password_token:#{@stubbedToken.toString("hex")}", @value).should.equal true
-				@redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", 60 * 60).should.equal true
-				done()
-
-		it "should return if there was an error", (done)->
-			@redisMulti.exec.callsArgWith(0, "error")
-			@OneTimeTokenHandler.getNewToken @value, (err, token)=>
-				err.should.exist
-				done()
-
-		it "should allow the expiry time to be overridden", (done) ->
-			@redisMulti.exec.callsArgWith(0) 
-			@ttl = 42
-			@OneTimeTokenHandler.getNewToken @value, {expiresIn: @ttl}, (err, token) =>
-				@redisMulti.expire.calledWith("password_token:#{@stubbedToken.toString("hex")}", @ttl).should.equal true
-				done()
+			it 'should call the callback with the token', ->
+				@callback.calledWith(null, @stubbedToken).should.equal true
 
 	describe "getValueFromTokenAndExpire", ->
+		describe 'successfully', ->
+			beforeEach ->
+				@db.tokens.findAndModify = sinon.stub().yields(null, { data: 'mock-data' })
+				@OneTimeTokenHandler.getValueFromTokenAndExpire 'password', 'mock-token', @callback
+
+			it 'should expire the token', ->
+				@db.tokens.findAndModify
+					.calledWith({
+						query: {
+							use: 'password'
+							token: 'mock-token',
+							expiresAt: { $gt: new Date() },
+							usedAt: { $exists: false }
+						},
+						update: {
+							$set: { usedAt: new Date() }
+						}
+					})
+					.should.equal true
+
+			it 'should return the data', ->
+				@callback.calledWith(null, 'mock-data').should.equal true
+
+		describe 'when a valid token is not found', ->
+			beforeEach ->
+				@db.tokens.findAndModify = sinon.stub().yields(null, null)
+				@OneTimeTokenHandler.getValueFromTokenAndExpire 'password', 'mock-token', @callback
+
+			it 'should return a NotFoundError', ->
+				@callback
+					.calledWith(sinon.match.instanceOf(Errors.NotFoundError))
+					.should.equal true
+
+
 
-		it "should get and delete the token", (done)->
-			@redisMulti.exec.callsArgWith(0, null, [@value]) 
-			@OneTimeTokenHandler.getValueFromTokenAndExpire @stubbedToken, (err, value)=>
-				value.should.equal @value
-				@redisMulti.get.calledWith("password_token:#{@stubbedToken}").should.equal true
-				@redisMulti.del.calledWith("password_token:#{@stubbedToken}").should.equal true
-				done()
 
 

+ 16 - 0
services/web/test/unit/coffee/Subscription/TeamInvitesHandlerTests.coffee

@@ -175,6 +175,22 @@ describe "TeamInvitesHandler", ->
 				).should.equal true
 				done()
 
+	describe "importInvite", ->
+		beforeEach ->
+			@sentAt = new Date()
+
+		it "can imports an invite from v1", ->
+			@TeamInvitesHandler.importInvite @subscription, "A-Team", "hannibal@a-team.org",
+				"secret", @sentAt, (error) =>
+					expect(error).not.to.exist
+
+					@subscription.save.calledOnce.should.eq true
+
+					invite = @subscription.teamInvites.find (i) -> i.email == "hannibal@a-team.org"
+					expect(invite.token).to.eq("secret")
+					expect(invite.sentAt).to.eq(@sentAt)
+
+
 	describe "acceptInvite", ->
 		beforeEach ->
 			@user = {

+ 125 - 0
services/web/test/unit/coffee/User/UserEmailsConfirmationHandlerTests.coffee

@@ -0,0 +1,125 @@
+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/UserEmailsConfirmationHandler"
+expect = require("chai").expect
+Errors = require "../../../../app/js/Features/Errors/Errors"
+EmailHelper = require "../../../../app/js/Features/Helpers/EmailHelper"
+
+describe "UserEmailsConfirmationHandler", ->
+	beforeEach ->
+		@UserEmailsConfirmationHandler = SandboxedModule.require modulePath, requires:
+			"settings-sharelatex": @settings =
+				siteUrl: "emails.example.com"
+			"logger-sharelatex": @logger = { log: sinon.stub() }
+			"../Security/OneTimeTokenHandler": @OneTimeTokenHandler = {}
+			"../Errors/Errors": Errors
+			"./UserUpdater": @UserUpdater = {}
+			"../Email/EmailHandler": @EmailHandler = {}
+			"../Helpers/EmailHelper": EmailHelper
+		@user_id = "mock-user-id"
+		@email = "mock@example.com"
+		@callback = sinon.stub()
+
+	describe "sendConfirmationEmail", ->
+		beforeEach ->
+			@OneTimeTokenHandler.getNewToken = sinon.stub().yields(null, @token = "new-token")
+			@EmailHandler.sendEmail = sinon.stub().yields()
+
+		describe 'successfully', ->
+			beforeEach ->
+				@UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, @email, @callback
+
+			it "should generate a token for the user which references their id and email", ->
+				@OneTimeTokenHandler.getNewToken
+					.calledWith(
+						'email_confirmation',
+						{@user_id, @email},
+						{ expiresIn: 365 * 24 * 60 * 60 }
+					)
+					.should.equal true
+
+			it 'should send an email to the user', ->
+				@EmailHandler.sendEmail
+					.calledWith('confirmEmail', {
+						to: @email,
+						confirmEmailUrl: 'emails.example.com/user/emails/confirm?token=new-token'
+					})
+					.should.equal true
+
+			it 'should call the callback', ->
+				@callback.called.should.equal true
+
+		describe 'with invalid email', ->
+			beforeEach ->
+				@UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, '!"£$%^&*()', @callback
+
+			it 'should return an error', ->
+				@callback.calledWith(sinon.match.instanceOf(Error)).should.equal true
+
+		describe 'a custom template', ->
+			beforeEach ->
+				@UserEmailsConfirmationHandler.sendConfirmationEmail @user_id, @email, 'myCustomTemplate', @callback
+
+			it 'should send an email with the given template', ->
+				@EmailHandler.sendEmail
+					.calledWith('myCustomTemplate')
+					.should.equal true
+
+	describe "confirmEmailFromToken", ->
+		beforeEach ->
+			@OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+				null, 
+				{@user_id, @email}
+			)
+			@UserUpdater.confirmEmail = sinon.stub().yields()
+
+		describe "successfully", ->
+			beforeEach ->
+				@UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+			it "should call getValueFromTokenAndExpire", ->
+				@OneTimeTokenHandler.getValueFromTokenAndExpire
+					.calledWith('email_confirmation', @token)
+					.should.equal true
+
+			it "should confirm the email of the user_id", ->
+				@UserUpdater.confirmEmail
+					.calledWith(@user_id, @email)
+					.should.equal true
+
+			it "should call the callback", ->
+				@callback.called.should.equal true
+
+		describe 'with an expired token', ->
+			beforeEach ->
+				@OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(null, null)
+				@UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+			it "should call the callback with a NotFoundError", ->
+				@callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+
+		describe 'with no user_id in the token', ->
+			beforeEach ->
+				@OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+					null, 
+					{@email}
+				)
+				@UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+			it "should call the callback with a NotFoundError", ->
+				@callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+
+		describe 'with no email in the token', ->
+			beforeEach ->
+				@OneTimeTokenHandler.getValueFromTokenAndExpire = sinon.stub().yields(
+					null, 
+					{@user_id}
+				)
+				@UserEmailsConfirmationHandler.confirmEmailFromToken @token = 'mock-token', @callback
+
+			it "should call the callback with a NotFoundError", ->
+				@callback.calledWith(sinon.match.instanceOf(Errors.NotFoundError)).should.equal true
+

+ 70 - 36
services/web/test/unit/coffee/User/UserEmailsControllerTests.coffee

@@ -8,6 +8,7 @@ modulePath = "../../../../app/js/Features/User/UserEmailsController.js"
 SandboxedModule = require('sandboxed-module')
 MockRequest = require "../helpers/MockRequest"
 MockResponse = require "../helpers/MockResponse"
+Errors = require("../../../../app/js/Features/Errors/Errors")
 
 describe "UserEmailsController", ->
 	beforeEach ->
@@ -30,6 +31,8 @@ describe "UserEmailsController", ->
 			"./UserGetter": @UserGetter
 			"./UserUpdater": @UserUpdater
 			"../Helpers/EmailHelper": @EmailHelper
+			"./UserEmailsConfirmationHandler": @UserEmailsConfirmationHandler = {}
+			"../Errors/Errors": Errors
 			"logger-sharelatex":
 				log: -> console.log(arguments)
 				err: ->
@@ -47,45 +50,46 @@ describe "UserEmailsController", ->
 					assertCalledWith @UserGetter.getUserFullEmails, @user._id
 					done()
 
-		it 'handles error', (done) ->
-			@UserGetter.getUserFullEmails.callsArgWith 1, new Error('Oups')
-
-			@UserEmailsController.list @req, 
-				sendStatus: (code) =>
-					code.should.equal 500
-					done()
-
 	describe 'Add', ->
 		beforeEach ->
 			@newEmail = 'new_email@baz.com'
-			@req.body.email = @newEmail
+			@req.body =
+				email: @newEmail
+				university: { name: 'University Name' }
+				department: 'Department'
+				role: 'Role'
 			@EmailHelper.parseEmail.returns @newEmail
+			@UserEmailsConfirmationHandler.sendConfirmationEmail = sinon.stub().yields()
+			@UserUpdater.addEmailAddress.callsArgWith 3, null
 
 		it 'adds new email', (done) ->
-			@UserUpdater.addEmailAddress.callsArgWith 2, null
-
 			@UserEmailsController.add @req, 
 				sendStatus: (code) =>
-					code.should.equal 200
+					code.should.equal 204
 					assertCalledWith @EmailHelper.parseEmail, @newEmail
 					assertCalledWith @UserUpdater.addEmailAddress, @user._id, @newEmail
-					done()
 
-		it 'handles email parse error', (done) ->
-			@EmailHelper.parseEmail.returns null
+					affiliationOptions = @UserUpdater.addEmailAddress.lastCall.args[2]
+					Object.keys(affiliationOptions).length.should.equal 3
+					affiliationOptions.university.should.equal @req.body.university
+					affiliationOptions.department.should.equal @req.body.department
+					affiliationOptions.role.should.equal @req.body.role
+
+					done()
 
+		it 'sends an email confirmation', (done) ->
 			@UserEmailsController.add @req, 
 				sendStatus: (code) =>
-					code.should.equal 422
-					assertNotCalled @UserUpdater.addEmailAddress
+					code.should.equal 204
+					assertCalledWith @UserEmailsConfirmationHandler.sendConfirmationEmail, @user._id, @newEmail
 					done()
 
-		it 'handles error', (done) ->
-			@UserUpdater.addEmailAddress.callsArgWith 2, new Error('Oups')
-
+		it 'handles email parse error', (done) ->
+			@EmailHelper.parseEmail.returns null
 			@UserEmailsController.add @req, 
 				sendStatus: (code) =>
-					code.should.equal 500
+					code.should.equal 422
+					assertNotCalled @UserUpdater.addEmailAddress
 					done()
 
 	describe 'remove', ->
@@ -113,15 +117,6 @@ describe "UserEmailsController", ->
 					assertNotCalled @UserUpdater.removeEmailAddress
 					done()
 
-		it 'handles error', (done) ->
-			@UserUpdater.removeEmailAddress.callsArgWith 2, new Error('Oups')
-
-			@UserEmailsController.remove @req, 
-				sendStatus: (code) =>
-					code.should.equal 500
-					done()
-
-
 	describe 'setDefault', ->
 		beforeEach ->
 			@email = "email_to_set_default@bar.com"
@@ -147,11 +142,50 @@ describe "UserEmailsController", ->
 					assertNotCalled @UserUpdater.setDefaultEmailAddress
 					done()
 
-		it 'handles error', (done) ->
-			@UserUpdater.setDefaultEmailAddress.callsArgWith 2, new Error('Oups')
+	describe 'confirm', ->
+		beforeEach ->
+			@UserEmailsConfirmationHandler.confirmEmailFromToken = sinon.stub().yields()
+			@res =
+				sendStatus: sinon.stub()
+				json: sinon.stub()
+			@res.status = sinon.stub().returns(@res)
+			@next = sinon.stub()
+			@token = 'mock-token'
+			@req.body = token: @token
+
+		describe 'successfully', ->
+			beforeEach ->
+				@UserEmailsController.confirm @req, @res, @next
+
+			it 'should confirm the email from the token', ->
+				@UserEmailsConfirmationHandler.confirmEmailFromToken
+					.calledWith(@token)
+					.should.equal true
+
+			it 'should return a 200 status', ->
+				@res.sendStatus.calledWith(200).should.equal true
+
+		describe 'without a token', ->
+			beforeEach ->
+				@req.body.token = null
+				@UserEmailsController.confirm @req, @res, @next
+
+			it 'should return a 422 status', ->
+				@res.sendStatus.calledWith(422).should.equal true
+
+		describe 'when confirming fails', ->
+			beforeEach ->
+				@UserEmailsConfirmationHandler.confirmEmailFromToken = sinon.stub().yields(
+					new Errors.NotFoundError('not found')
+				)
+				@UserEmailsController.confirm @req, @res, @next
+
+			it 'should return a 404 error code with a message', ->
+				@res.status.calledWith(404).should.equal true
+				@res.json.calledWith({
+					message: 'Sorry, your confirmation token is invalid or has expired. Please request a new email confirmation link.'
+				}).should.equal true
+
+
 
-			@UserEmailsController.setDefault @req, 
-				sendStatus: (code) =>
-					code.should.equal 500
-					done()
 

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

@@ -20,11 +20,15 @@ describe "UserGetter", ->
 		@Mongo =
 			db: users: findOne: @findOne
 			ObjectId: (id) -> return id
+		settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+		@request = sinon.stub()
 
 		@UserGetter = SandboxedModule.require modulePath, requires:
 			"logger-sharelatex": log:->
 			"../../infrastructure/mongojs": @Mongo
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
+			'settings-sharelatex': settings
+			'request': @request
 
 	describe "getUser", ->
 		it "should get user", (done)->
@@ -42,6 +46,9 @@ describe "UserGetter", ->
 				done()
 
 	describe "getUserFullEmails", -
+		beforeEach ->
+			@request.callsArgWith(1, null, { statusCode: 200 }, [])
+
 		it "should get user", (done)->
 			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @fakeUser)
 			projection = email: 1, emails: 1
@@ -59,6 +66,33 @@ describe "UserGetter", ->
 				]
 				done()
 
+		it "should merge affiliation data", (done)->
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @fakeUser)
+			affiliationsData = [
+				{
+					email: 'email1@foo.bar'
+					role: 'Prof'
+					department: 'Maths'
+					inferred: false
+					institution: { name: 'University Name', isUniversity: true }
+				}
+			]
+			@request.callsArgWith(1, null, { statusCode: 200 }, affiliationsData)
+			@UserGetter.getUserFullEmails @fakeUser._id, (error, fullEmails) =>
+				assert.deepEqual fullEmails, [
+					{
+						email: 'email1@foo.bar'
+						default: false
+						affiliation:
+							institution: affiliationsData[0].institution
+							inferred: affiliationsData[0].inferred
+							department: affiliationsData[0].department
+							role: affiliationsData[0].role
+					}
+					{ email: 'email2@foo.bar', default: true }
+				]
+				done()
+
 	describe "getUserbyMainEmail", ->
 		it "query user by main email", (done)->
 			email = 'hello@world.com'

+ 2 - 2
services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee

@@ -152,7 +152,7 @@ describe "UserRegistrationHandler", ->
 		beforeEach ->
 			@email = "email@example.com"
 			@crypto.randomBytes = sinon.stub().returns({toString: () => @password = "mock-password"})
-			@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token = "mock-token")
+			@OneTimeTokenHandler.getNewToken.yields(null, @token = "mock-token")
 			@handler.registerNewUser = sinon.stub()
 			@callback = sinon.stub()
 		
@@ -171,7 +171,7 @@ describe "UserRegistrationHandler", ->
 			it "should generate a new password reset token", ->
 				
 				@OneTimeTokenHandler.getNewToken
-					.calledWith(@user_id, expiresIn: 7 * 24 * 60 * 60)
+					.calledWith('password', @user_id, expiresIn: 7 * 24 * 60 * 60)
 					.should.equal true
 
 			it "should send a registered email", ->

+ 92 - 9
services/web/test/unit/coffee/User/UserUpdaterTests.coffee

@@ -5,12 +5,12 @@ path = require('path')
 sinon = require('sinon')
 modulePath = path.join __dirname, "../../../../app/js/Features/User/UserUpdater"
 expect = require("chai").expect
+tk = require('timekeeper')
 
 describe "UserUpdater", ->
 
 	beforeEach ->
-
-		@settings = {}
+		tk.freeze(Date.now())
 		@mongojs = 
 			db:{}
 			ObjectId:(id)-> return id
@@ -19,12 +19,15 @@ describe "UserUpdater", ->
 			getUserByAnyEmail: sinon.stub()
 			ensureUniqueEmailAddress: sinon.stub()
 		@logger = err: sinon.stub(), log: ->
+		settings = apis: { v1: { url: 'v1.url', user: '', pass: '' } }
+		@request = sinon.stub()
 		@UserUpdater = SandboxedModule.require modulePath, requires:
-			"settings-sharelatex":@settings
 			"logger-sharelatex": @logger
 			"./UserGetter": @UserGetter
 			"../../infrastructure/mongojs":@mongojs
 			"metrics-sharelatex": timeAsyncMethod: sinon.stub()
+			'settings-sharelatex': settings
+			'request': @request
 
 		@stubbedUser = 
 			_id: "3131231"
@@ -32,6 +35,9 @@ describe "UserUpdater", ->
 			email:"hello@world.com"
 		@newEmail = "bob@bob.com"
 
+	afterEach ->
+		tk.reset()
+
 	describe 'changeEmailAddress', ->
 		beforeEach ->
 			@UserGetter.getUserEmail.callsArgWith(1, null, @stubbedUser.email)
@@ -62,10 +68,10 @@ describe "UserUpdater", ->
 	describe 'addEmailAddress', ->
 		beforeEach ->
 			@UserGetter.ensureUniqueEmailAddress = sinon.stub().callsArgWith(1)
-
-		it 'add email', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null)
+			@request.callsArgWith(1, null, { statusCode: 201 })
 
+		it 'add email', (done)->
 			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
 				@UserGetter.ensureUniqueEmailAddress.called.should.equal true
 				should.not.exist(err)
@@ -75,6 +81,27 @@ describe "UserUpdater", ->
 				).should.equal true
 				done()
 
+		it 'add affiliation', (done)->
+			affiliationOptions =
+				university: { id: 1 }
+				role: 'Prof'
+				department: 'Math'
+			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, affiliationOptions, (err)=>
+				should.not.exist(err)
+				@request.calledOnce.should.equal true
+				requestOptions = @request.lastCall.args[0]
+				expectedUrl = "v1.url/api/v2/users/#{@stubbedUser._id}/affiliations"
+				requestOptions.url.should.equal expectedUrl
+				requestOptions.method.should.equal 'POST'
+
+				body = requestOptions.body
+				Object.keys(body).length.should.equal 4
+				body.email.should.equal @newEmail
+				body.university.should.equal affiliationOptions.university
+				body.department.should.equal affiliationOptions.department
+				body.role.should.equal affiliationOptions.role
+				done()
+
 		it 'handle error', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
 
@@ -83,10 +110,21 @@ describe "UserUpdater", ->
 				should.exist(err)
 				done()
 
+		it 'handle affiliation error', (done)->
+			body = errors: 'affiliation error message'
+			@request.callsArgWith(1, null, { statusCode: 422 }, body)
+			@UserUpdater.addEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				err.message.should.have.string 422
+				err.message.should.have.string body.errors
+				@UserUpdater.updateUser.called.should.equal false
+				done()
+
 	describe 'removeEmailAddress', ->
-		it 'remove email', (done)->
+		beforeEach ->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+			@request.callsArgWith(1, null, { statusCode: 404 })
 
+		it 'remove email', (done)->
 			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
 				should.not.exist(err)
 				@UserUpdater.updateUser.calledWith(
@@ -95,6 +133,17 @@ describe "UserUpdater", ->
 				).should.equal true
 				done()
 
+		it 'remove affiliation', (done)->
+			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@request.calledOnce.should.equal true
+				requestOptions = @request.lastCall.args[0]
+				expectedUrl = "v1.url/api/v2/users/#{@stubbedUser._id}/affiliations/"
+				expectedUrl += encodeURIComponent(@newEmail)
+				requestOptions.url.should.equal expectedUrl
+				requestOptions.method.should.equal 'DELETE'
+				done()
+
 		it 'handle error', (done)->
 			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
 
@@ -103,15 +152,22 @@ describe "UserUpdater", ->
 				done()
 
 		it 'handle missed update', (done)->
-			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
 
 			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
 				should.exist(err)
 				done()
 
+		it 'handle affiliation error', (done)->
+			@request.callsArgWith(1, null, { statusCode: 500 })
+			@UserUpdater.removeEmailAddress @stubbedUser._id, @newEmail, (err)=>
+				err.message.should.exist
+				@UserUpdater.updateUser.called.should.equal false
+				done()
+
 	describe 'setDefaultEmailAddress', ->
 		it 'set default', (done)->
-			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 1)
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
 
 			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
 				should.not.exist(err)
@@ -129,10 +185,37 @@ describe "UserUpdater", ->
 				done()
 
 		it 'handle missed update', (done)->
-			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, nMatched: 0)
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
 
 			@UserUpdater.setDefaultEmailAddress @stubbedUser._id, @newEmail, (err)=>
 				should.exist(err)
 				done()
 
+	describe 'confirmEmail', ->
+		it 'should update the email record', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 1)
+
+			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+				should.not.exist(err)
+				@UserUpdater.updateUser.calledWith(
+					{ _id: @stubbedUser._id, 'emails.email': @newEmail },
+					$set: { 'emails.$.confirmedAt': new Date() }
+				).should.equal true
+				done()
+
+		it 'handle error', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, new Error('nope'))
+
+			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+		it 'handle missed update', (done)->
+			@UserUpdater.updateUser = sinon.stub().callsArgWith(2, null, n: 0)
+
+			@UserUpdater.confirmEmail @stubbedUser._id, @newEmail, (err)=>
+				should.exist(err)
+				done()
+
+
 

+ 66 - 0
services/web/test/unit/coffee/infrastructure/ProxyManagerTests.coffee

@@ -0,0 +1,66 @@
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+chai = require('chai')
+should = chai.should()
+expect = chai.expect
+modulePath = '../../../../app/js/infrastructure/ProxyManager'
+SandboxedModule = require('sandboxed-module')
+MockRequest = require "../helpers/MockRequest" 
+MockResponse = require "../helpers/MockResponse" 
+
+describe "ProxyManager", ->
+	before ->
+		@settings = proxyUrls: {}
+		@request = sinon.stub().returns(
+			on: ()->
+			pipe: ()->
+		)
+		@proxyManager = SandboxedModule.require modulePath, requires:
+			'settings-sharelatex': @settings
+			'logger-sharelatex': log: ->
+			'request': @request
+		@proxyPath = '/foo/bar'
+		@req = new MockRequest()
+		@res = new MockResponse()
+		@next = sinon.stub()
+
+	describe 'proxyUrls', ->
+		beforeEach ->
+			@req.url = @proxyPath
+			@req.query = {}
+			@settings.proxyUrls = {}
+
+		afterEach ->
+			@next.reset()
+			@request.reset()
+
+		it 'calls next when no match', ->
+			@proxyManager.call(@req, @res, @next)
+			sinon.assert.called(@next)
+			sinon.assert.notCalled(@request)
+
+		it 'does not calls next when match', ->
+			@settings.proxyUrls[@proxyPath] = '/'
+			@proxyManager.call(@req, @res, @next)
+			sinon.assert.notCalled(@next)
+			sinon.assert.called(@request)
+
+		it 'proxy full URL', ->
+			targetUrl = 'https://user:pass@foo.bar:123/pa/th.ext?query#hash'
+			@settings.proxyUrls[@proxyPath] = targetUrl
+			@proxyManager.call(@req)
+			assertCalledWith(@request, targetUrl)
+
+		it 'overwrite query', ->
+			targetUrl = 'foo.bar/baz?query'
+			@req.query = { requestQuery: 'important' }
+			@settings.proxyUrls[@proxyPath] = targetUrl
+			@proxyManager.call(@req)
+			newTargetUrl = 'foo.bar/baz?requestQuery=important'
+			assertCalledWith(@request, newTargetUrl)
+
+		it 'handles target objects', ->
+			targetUrl = { baseUrl: 'api.v1', path: '/pa/th'}
+			@settings.proxyUrls[@proxyPath] = targetUrl
+			@proxyManager.call(@req, @res, @next)
+			assertCalledWith(@request, 'api.v1/pa/th')